From 876b33c73b96f6080f781d23dfff3aec0d2e4f96 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: Thu, 19 Dec 2024 22:13:52 +0900 Subject: [PATCH 01/37] =?UTF-8?q?=F0=9F=92=84=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=85=80=EB=A0=89=ED=8A=B8=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EC=97=90=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=B0=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Controls.jsx | 376 ++++++++++++++--------------- src/components/Controls.module.css | 27 ++- 2 files changed, 209 insertions(+), 194 deletions(-) diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index 2861ce5d..a2df0150 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -8,6 +8,155 @@ import { fetchWithAuth } from "@/utils/auth/tokenUtils"; import { useSelector } from "react-redux"; import { useRouter } from "next/navigation"; +export const CustomScrollbar = ({ containerRef, trackStyle }) => { + // 상수 정의 + const TRACK_PADDING = 5; // px + const TOTAL_PADDING = TRACK_PADDING * 2; + + const thumbRef = useRef(null); + const [thumbHeight, setThumbHeight] = useState(0); + const [thumbTop, setThumbTop] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [startY, setStartY] = useState(0); + const [startScrollTop, setStartScrollTop] = useState(0); + const [container, setContainer] = useState(null); + + const calculateThumbSize = useCallback(() => { + if (!container) return; + + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const trackHeight = clientHeight - TOTAL_PADDING; + + const heightPercentage = (clientHeight / scrollHeight) * 100; + const minHeight = 20; + const calculatedHeight = Math.max( + minHeight, + (trackHeight * heightPercentage) / 100 + ); + + setThumbHeight(calculatedHeight); + }, [container]); + + const handleScroll = useCallback(() => { + if (!container || isDragging) return; + + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const scrollTop = container.scrollTop; + const trackHeight = clientHeight - thumbHeight - TOTAL_PADDING; + + const scrollDistance = scrollHeight - clientHeight; + const percentage = scrollDistance > 0 ? scrollTop / scrollDistance : 0; + const newThumbTop = Math.min( + Math.max(TRACK_PADDING, percentage * trackHeight + TRACK_PADDING), + trackHeight + TRACK_PADDING + ); + + setThumbTop(newThumbTop); + }, [container, isDragging, thumbHeight]); + + useEffect(() => { + // containerRef를 통해 직접 요소 참조 + const element = containerRef?.current; + if (!element) return; + + setContainer(element); + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + calculateThumbSize(); + handleScroll(); + }); + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, [calculateThumbSize, handleScroll, containerRef]); + + useEffect(() => { + if (!container) return; + + const scrollHandler = () => { + requestAnimationFrame(() => { + handleScroll(); + }); + }; + + container.addEventListener("scroll", scrollHandler); + return () => container.removeEventListener("scroll", scrollHandler); + }, [container, handleScroll]); + + const handleMouseDown = (e) => { + e.preventDefault(); + setIsDragging(true); + setStartY(e.clientY); + setStartScrollTop(container.scrollTop); + }; + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e) => { + const deltaY = e.clientY - startY; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + const trackHeight = clientHeight - thumbHeight; + + const percentage = deltaY / trackHeight; + const scrollDistance = scrollHeight - clientHeight; + const newScrollTop = Math.min( + Math.max(0, startScrollTop + percentage * scrollDistance), + scrollDistance + ); + + container.scrollTop = newScrollTop; + + const thumbPercentage = newScrollTop / scrollDistance; + const newThumbTop = Math.min( + Math.max(0, thumbPercentage * trackHeight), + trackHeight + ); + + setThumbTop(newThumbTop); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, startY, startScrollTop, thumbHeight, container]); + + if (container && container.scrollHeight <= container.clientHeight) { + return null; + } + + return ( +
+ + ); +}; + export function Button({ highlight, children, @@ -255,32 +404,42 @@ export function Select({ )} {isOpen && ( - +
+
    + {options.map((option, index) => ( +
  • handleOptionClick(option, e)} + onMouseEnter={() => setFocusedIndex(index)} + tabIndex={0} + onKeyDown={handleKeyDown} + > + {option.label} +
  • + ))} +
+ +
)} - + {isIdValid
- + {!isNameValid && "이름은 2~20자로 입력해주세요."} + {isNameValid
- +
setPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePasswordVerify(e)} + required + className={styles["form-input"]} + /> + +
+ + ) : ( +
+
+ + setNewEmail(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && handleSendVerification(e) + } + required + className={styles["form-input"]} + /> + +
+ + {isEmailSent && ( +
+ + setVerificationCode(e.target.value)} + required + maxLength={6} + className={styles["form-input"]} + /> + +
+ )} +
+ )} + + + ); +} From 87dbc1a4141dfe98e4c00f28f91a4df105c7f847 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 04:50:12 +0900 Subject: [PATCH 16/37] =?UTF-8?q?=F0=9F=92=84=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=A0=9C=EA=B1=B0:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20CSS=EC=97=90=EC=84=9C=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=90=9C=20=EC=9E=85=EB=A0=A5=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modify-email/ModifyEmail.module.css | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/app/account/modify-email/ModifyEmail.module.css b/src/app/account/modify-email/ModifyEmail.module.css index 61c07d5e..5b056830 100644 --- a/src/app/account/modify-email/ModifyEmail.module.css +++ b/src/app/account/modify-email/ModifyEmail.module.css @@ -36,21 +36,6 @@ grid-area: label; } -/* -.form-input { - width: 100%; - padding: 0.5rem; - border: 1px solid #d1d5db; - border-radius: 4px; - margin-bottom: 0.5rem; -} - -.form-input:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} */ - .form-button { width: 100%; } @@ -60,11 +45,6 @@ color: white; } -.submit-button:disabled { - background-color: #9ca3af; - cursor: not-allowed; -} - .error { background-color: #fee2e2; border: 1px solid #ef4444; From 6921b6d7d8bde16776232ae432f321822496c85b 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 04:50:27 +0900 Subject: [PATCH 17/37] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modify-password/ModifyPassword.module.css | 87 +++++++ src/app/account/modify-password/page.js | 230 ++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 src/app/account/modify-password/ModifyPassword.module.css create mode 100644 src/app/account/modify-password/page.js diff --git a/src/app/account/modify-password/ModifyPassword.module.css b/src/app/account/modify-password/ModifyPassword.module.css new file mode 100644 index 00000000..06c47ebb --- /dev/null +++ b/src/app/account/modify-password/ModifyPassword.module.css @@ -0,0 +1,87 @@ +.page-title { + font-size: var(--h2-font-size); + font-weight: 600; + margin-bottom: 2rem; + color: #333; +} + +.password-form { + background: var(--background-white); + padding: 2rem; + border-radius: 1rem; + filter: drop-shadow(6px 6px 9.4px rgba(0, 0, 0, 0.15)); +} + +.form-fieldset { + border: none; + padding: 0; +} + +.form-fieldset legend { + font-size: var(--h3-font-size); +} + +.input-group { + margin-bottom: 1.5rem; + display: grid; + align-items: center; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto auto; + grid-template-areas: "label label" "input rabbit" "msg msg"; + gap: 0.5rem; +} + +.input-group img { + grid-area: rabbit; +} + +.input-label { + display: block; + margin-bottom: 0.5rem; + grid-area: label; +} + +.input-group input { + grid-area: input; +} + +.form-button { + width: 100%; +} + +.submit-button { + width: 100%; + color: white; +} + +.error { + background-color: #fee2e2; + border: 1px solid #ef4444; + color: #dc2626; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.validation-list { + list-style: none; + padding: 0; + margin: 0.5rem 0; + grid-column: span 2; + grid-area: submit; +} + +.validation-item { + color: #dc2626; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.validation-item:last-child { + margin-bottom: 0; +} + +.mismatch-message { + grid-area: msg; + color: var(--invalid-text-color); +} diff --git a/src/app/account/modify-password/page.js b/src/app/account/modify-password/page.js new file mode 100644 index 00000000..cae6f0b3 --- /dev/null +++ b/src/app/account/modify-password/page.js @@ -0,0 +1,230 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button, Input } from "@/components/Controls"; +import styles from "./ModifyPassword.module.css"; +import { verifyPassword } from "@/utils/auth/verifyPassword"; +import Loading from "@/components/Loading"; +import { auth } from "@/lib/firebase"; +import { updatePassword } from "firebase/auth"; + +const 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 { + isValid: + hasUpperCase && + hasLowerCase && + hasNumber && + hasSpecialChar && + isValidLength, + checks: { + hasUpperCase, + hasLowerCase, + hasNumber, + hasSpecialChar, + isValidLength, + }, + }; +}; + +export default function ModifyPassword() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isVerifyingPassword, setIsVerifyingPassword] = useState(false); + const [isPasswordVerified, setIsPasswordVerified] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + const [validationErrors, setValidationErrors] = useState({}); + const [isValidation, setIsValidation] = useState(false); + const [isPasswordMatch, setIsPasswordMatch] = useState(false); + + useEffect(() => { + if (newPassword) { + const { isValid } = validatePassword(newPassword); + setIsValidation(isValid); + } + }, [newPassword]); + + useEffect(() => { + if (newPassword !== confirmPassword || confirmPassword === "") { + setIsPasswordMatch(false); + } else { + setIsPasswordMatch(true); + } + }, [newPassword, confirmPassword]); + + const handlePasswordVerify = async (e) => { + e.preventDefault(); + setIsVerifyingPassword(true); + try { + await verifyPassword(currentPassword); + setIsPasswordVerified(true); + setError(""); + } catch (err) { + setError("비밀번호가 일치하지 않습니다."); + } finally { + setIsVerifyingPassword(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + // 새 비밀번호 유효성 검사 + const { isValid } = validatePassword(newPassword); + if (!isValid) { + throw new Error("비밀번호가 유효성 검사 규칙을 만족하지 않습니다."); + } + + if (newPassword !== confirmPassword) { + throw new Error("새 비밀번호가 일치하지 않습니다."); + } + + const user = auth.currentUser; + if (!user) { + throw new Error("로그인이 필요합니다."); + } + + // Firebase 비밀번호 업데이트 + await updatePassword(user, newPassword); + + // 성공 시 처리 + alert("비밀번호가 성공적으로 변경되었습니다.\n다시 로그인해주세요."); + location.href = "/logout"; + } catch (err) { + setError( + err.message || "비밀번호 변경에 실패했습니다. 다시 시도해주세요." + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

비밀번호 변경

+ {error && ( +

+ {error} +

+ )} + +
+ {!isPasswordVerified ? ( +
+
+ + setCurrentPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePasswordVerify(e)} + required + className={styles["form-input"]} + /> + +
+
+ ) : ( +
+
+ + setNewPassword(e.target.value)} + required + className={styles["form-input"]} + /> + + {isValidation + ? "" + : "비밀번호는 6자 이상, 영문 대,소문자, 숫자, 특수문자를 포함해주세요"} + + { +
+ +
+ + setConfirmPassword(e.target.value)} + required + className={styles["form-input"]} + /> + + {isPasswordMatch ? "" : "비밀번호가 일치하지 않습니다."} + + { +
+ +
+ )} +
+
+ ); +} From edca45dd06d9edd5212604fccac7bb17d6ffe7a1 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 04:51:01 +0900 Subject: [PATCH 18/37] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=20=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=84=EC=86=8C=ED=99=94:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=97=AC=EB=B6=80=EB=A7=8C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A1=B4=EC=9E=AC=20=EC=97=AC=EB=B6=80=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/check-email/route.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/api/auth/check-email/route.js b/src/app/api/auth/check-email/route.js index 5b344e26..37bf825a 100644 --- a/src/app/api/auth/check-email/route.js +++ b/src/app/api/auth/check-email/route.js @@ -16,15 +16,7 @@ export async function POST(req) { try { const userRecord = await auth.getUserByEmail(email); - // 임시 계정 체크: - // 1. 이메일이 인증되어 있고 (emailVerified: true) - // 2. 생성 시간과 마지막 로그인 시간이 같으면 (방금 생성된 임시 계정) - const isTemporaryAccount = - userRecord.emailVerified && - userRecord.metadata.creationTime === userRecord.metadata.lastSignInTime; - - // 임시 계정이 아닌 경우에만 exists: true 반환 - return NextResponse.json({ exists: !isTemporaryAccount }); + return NextResponse.json({ exists: userRecord.emailVerified }); } catch (error) { if (error.code === "auth/user-not-found") { return NextResponse.json({ exists: false }); From 5551acedff18847aef8f830ed1c722ae5578f489 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 04:52:11 +0900 Subject: [PATCH 19/37] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80:=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/update-email/route.js | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/app/api/auth/update-email/route.js diff --git a/src/app/api/auth/update-email/route.js b/src/app/api/auth/update-email/route.js new file mode 100644 index 00000000..81acd597 --- /dev/null +++ b/src/app/api/auth/update-email/route.js @@ -0,0 +1,95 @@ +import { auth, db } from "@/lib/firebaseAdmin"; +import { getFirestore, Timestamp } from "firebase-admin/firestore"; + +export async function PUT(req) { + try { + const { email, code, uid } = await req.json(); + + // 1. 인증 코드 확인 + 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(); + + // 2. 인증 코드 유효성 검사 + 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: verificationData.attempts + 1, + }); + return Response.json( + { error: "잘못된 인증 코드입니다." }, + { status: 400 } + ); + } + + // 3. 이메일 중복 확인 + try { + const existingUser = await auth.getUserByEmail(email); + if (existingUser) { + return Response.json( + { error: "이미 사용 중인 이메일입니다." }, + { status: 400 } + ); + } + } catch (error) { + if (error.code !== "auth/user-not-found") { + throw error; + } + } + + // 4. Firebase Auth 이메일 업데이트 + await auth.updateUser(uid, { + email: email, + emailVerified: true, // 이미 인증을 거쳤으므로 true로 설정 + }); + + // 5. 인증 코드 verified 처리 + await verificationDoc.ref.update({ + verified: true, + verifiedAt: Timestamp.now(), + }); + + return Response.json({ success: true }); + } catch (error) { + console.error("이메일 업데이트 오류:", error); + + if (error.code === "auth/user-not-found") { + return Response.json( + { error: "사용자를 찾을 수 없습니다." }, + { status: 404 } + ); + } + + return Response.json( + { error: "이메일 변경 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} From 6f10d13b334c48aa441563e8b94e651ca195e99f 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 04:52:38 +0900 Subject: [PATCH 20/37] =?UTF-8?q?=F0=9F=92=84=20ButtonLink=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=88=98=EC=A0=95:=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EC=83=81=ED=83=9C=20=EC=A7=80=EC=9B=90=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/components/Controls.jsx | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index 95963f9b..d083d400 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -204,16 +204,32 @@ export function ButtonLabel({ highlight, children, htmlFor }) { ); } -export function ButtonLink({ highlight, children, href }) { +export function ButtonLink({ highlight, children, disabled, href, type }) { const buttonClass = highlight ? `${styles["button-highlight"]} ${styles["button"]}` : styles["button"]; - return ( - - {children} - - ); + if (disabled) { + return ( + + ); + } + + if (type === "a") { + return ( + + {children} + + ); + } else { + return ( + + {children} + + ); + } } export function Input({ @@ -226,6 +242,7 @@ export function Input({ onBlur, minLength, maxLength, + onKeyDown, }) { let inputClass = styles["input"]; if (type === "text" || type === "password" || type === "email") { @@ -242,6 +259,7 @@ export function Input({ disabled={disabled} style={background === "white" ? { backgroundColor: "white" } : {}} onBlur={onBlur} + onKeyDown={onKeyDown} minLength={minLength} maxLength={maxLength} /> From 59e1ed031938a67127bc62242d38d50fda574326 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 04:53:00 +0900 Subject: [PATCH 21/37] =?UTF-8?q?=E2=9C=A8=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20=ED=98=84=EC=9E=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9E=AC=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/auth/verifyPassword.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/utils/auth/verifyPassword.js diff --git a/src/utils/auth/verifyPassword.js b/src/utils/auth/verifyPassword.js new file mode 100644 index 00000000..4c4de8af --- /dev/null +++ b/src/utils/auth/verifyPassword.js @@ -0,0 +1,32 @@ +import { EmailAuthProvider, reauthenticateWithCredential } from "firebase/auth"; +import { auth } from "@/lib/firebase"; + +export const verifyPassword = async (password) => { + try { + const user = auth.currentUser; + if (!user) { + throw new Error("로그인된 사용자가 없습니다."); + } + + // 현재 사용자의 이메일과 입력받은 비밀번호로 credential 생성 + const credential = EmailAuthProvider.credential(user.email, password); + + // 재인증 시도 + await reauthenticateWithCredential(user, credential); + + return true; + } catch (error) { + console.error("Password verification error:", error); + + // Firebase 에러 코드에 따른 적절한 에러 메시지 반환 + if (error.code === "auth/wrong-password") { + throw new Error("비밀번호가 일치하지 않습니다."); + } else if (error.code === "auth/too-many-requests") { + throw new Error( + "너무 많은 시도가 있었습니다. 잠시 후 다시 시도해주세요." + ); + } else { + throw new Error("비밀번호 확인 중 오류가 발생했습니다."); + } + } +}; From 297df2f29ce91cb67ad7f5b7e57cf059b10e4d05 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 04:53:08 +0900 Subject: [PATCH 22/37] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=B0=8F=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/auth/updateEmail.js | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/utils/auth/updateEmail.js diff --git a/src/utils/auth/updateEmail.js b/src/utils/auth/updateEmail.js new file mode 100644 index 00000000..ece82e28 --- /dev/null +++ b/src/utils/auth/updateEmail.js @@ -0,0 +1,69 @@ +import { auth } from "@/lib/firebase"; +import { verifyPassword } from "./verifyPassword"; + +export const updateEmail = async ( + currentPassword, + newEmail, + verificationCode +) => { + try { + // 1. 비밀번호 확인 + await verifyPassword(currentPassword); + + const user = auth.currentUser; + if (!user) { + throw new Error("로그인이 필요합니다."); + } + + // 2. 이메일 업데이트 API 호출 + const updateRes = await fetch("/api/auth/update-email", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: newEmail, + code: verificationCode, + uid: user.uid, + }), + }); + + if (!updateRes.ok) { + const error = await updateRes.json(); + throw new Error(error.error || "이메일 변경 실패"); + } + + // 3. 새 토큰 발급 + const newIdToken = await user.getIdToken(true); + + // 4. 새 토큰으로 세션 쿠키 업데이트 + const tokenRes = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ idToken: newIdToken }), + }); + + if (!tokenRes.ok) { + throw new Error("세션 업데이트 실패"); + } + + // 5. 현재 사용자 새로고침 (새 이메일로 업데이트) + await user.reload(); + + return { success: true }; + } catch (error) { + console.error("이메일 업데이트 오류:", error); + + // 토큰 만료 에러인 경우에도 성공으로 처리 + if ( + error?.code === "auth/requires-recent-login" || + error?.code === "auth/user-token-expired" + ) { + return { success: true, requiresReauth: true }; + } + + throw error; + } +}; From a01552c677a4a8a503ec83627c9004566c5a3f06 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 05:31:36 +0900 Subject: [PATCH 23/37] =?UTF-8?q?=E2=9C=A8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80:?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=95=EC=9D=B8=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Controls.jsx | 143 +++++++++++++++++++++++++++++ src/components/Controls.module.css | 83 +++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index d083d400..7c70b0e4 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { fetchWithAuth } from "@/utils/auth/tokenUtils"; import { useSelector } from "react-redux"; import { useRouter } from "next/navigation"; +import { auth } from "@/lib/firebase"; export const CustomScrollbar = ({ containerRef, trackStyle }) => { // 상수 정의 @@ -779,3 +780,145 @@ export function UsersList({ ); } + +export function WithdrawModal({ isOpen, closeModal, userId }) { + const dialogRef = useRef(null); + const [step, setStep] = useState(1); + const [confirmUserId, setConfirmUserId] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + const dialog = dialogRef.current; + const html = document.querySelector("html"); + if (isOpen) { + dialog?.showModal(); + html.style.overflowY = "hidden"; + } else { + html.style.overflowY = "scroll"; + dialog?.close(); + // 모달이 닫힐 때 상태 초기화 + setStep(1); + setConfirmUserId(""); + setError(""); + } + }, [isOpen]); + + const handleClose = () => { + const html = document.querySelector("html"); + html.style.overflowY = "scroll"; + closeModal(); + }; + + // 백드롭 클릭을 감지하는 이벤트 핸들러 + const handleClick = (e) => { + const dialogDimensions = dialogRef.current?.getBoundingClientRect(); + if (dialogDimensions) { + const isClickedInDialog = + e.clientX >= dialogDimensions.left && + e.clientX <= dialogDimensions.right && + e.clientY >= dialogDimensions.top && + e.clientY <= dialogDimensions.bottom; + + if (!isClickedInDialog) { + handleClose(); + } + } + }; + + const handleWithdraw = async () => { + if (confirmUserId !== userId) { + setError("아이디가 일치하지 않습니다."); + return; + } + + try { + // API 호출을 통한 회원 탈퇴 처리 + const response = await fetchWithAuth("/api/auth/withdraw", { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error( + error.error || "회원 탈퇴 처리 중 오류가 발생했습니다." + ); + } + + // 성공 시 처리 + alert( + "회원 탈퇴가 완료되었습니다.\n그동안 DREMAER를 이용해 주셔서 감사합니다." + ); + location.href = "/logout"; + } catch (error) { + setError(error.message); + console.error("Withdraw error:", error); + } + }; + + return ( + + + + {step === 1 ? ( +
e.stopPropagation()} + > +

회원 탈퇴

+

정말로 탈퇴하시겠습니까?

+

+ 탈퇴하시면 모든 데이터가 삭제되며, 복구할 수 없습니다. +

+
+ + +
+
+ ) : ( +
e.stopPropagation()} + > +

회원 탈퇴 확인

+

+ 회원 탈퇴를 진행하시려면 아이디를 입력해주세요. +
이 작업은 취소할 수 없습니다. +

+
{ + e.preventDefault(); + handleWithdraw(); + }} + > +
+ setConfirmUserId(e.target.value)} + placeholder={userId} + required + /> +
+ {error &&

{error}

} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/components/Controls.module.css b/src/components/Controls.module.css index 2ae83294..76a4ec06 100644 --- a/src/components/Controls.module.css +++ b/src/components/Controls.module.css @@ -550,3 +550,86 @@ label.checkbox a { margin-left: auto; height: fit-content; } + +.withdraw-modal { + width: 24rem; + display: flex; + flex-direction: column; + padding: 1.2rem 1rem 2rem; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--background-white); + color: var(--black-font-color); + border: none; + border-radius: 1rem; + z-index: 1000; + word-break: keep-all; +} + +.withdraw-modal::backdrop { + background-color: var(--background-dimmed); + z-index: 999; +} + +.withdraw-modal .btn-close { + margin-left: auto; +} + +.withdraw-modal .btn-close img { + width: 1.6rem; + height: 1.6rem; +} + +.withdraw-modal .modal-content { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 0.8rem; + padding: 0 1rem; +} + +.withdraw-modal .modal-content h2 { + font-size: var(--h2-font-size); + font-weight: 700; +} + +.withdraw-modal .modal-content p { + font-size: var(--body-font-size); + color: var(--black-font-color); + text-align: center; +} + +.withdraw-modal .warning-text { + color: var(--error-color) !important; + font-size: var(--small-text-font-size) !important; +} + +.withdraw-modal .button-group { + display: grid; + grid-template-columns: 1fr 1fr; + justify-items: center; + gap: 1rem; + width: 100%; + margin-top: 1rem; +} + +.withdraw-modal .input-group { + width: 100%; + margin: 1rem 0; +} + +.withdraw-modal .error { + color: var(--error-color); + font-size: var(--small-text-font-size); + margin-top: -0.5rem; + margin-bottom: 0.5rem; +} + +/* Input 스타일 오버라이드 */ +.withdraw-modal .input-group input { + width: 100%; + text-align: center; + font-size: var(--body-font-size); +} From 44a6f4c8b4e09acd2b6a6b3dde1c2dde5259c987 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 05:31:50 +0900 Subject: [PATCH 24/37] =?UTF-8?q?=E2=9C=A8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/auth/withdraw/route.js | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/app/api/auth/withdraw/route.js diff --git a/src/app/api/auth/withdraw/route.js b/src/app/api/auth/withdraw/route.js new file mode 100644 index 00000000..61fa2414 --- /dev/null +++ b/src/app/api/auth/withdraw/route.js @@ -0,0 +1,77 @@ +// /api/auth/withdraw/route.js +import { headers } from "next/headers"; +import { auth, db } from "@/lib/firebaseAdmin"; +import { verifyUser } from "@/lib/api/auth"; + +export async function DELETE(request) { + try { + // 1. 사용자 인증 처리 + const headersList = headers(); + const authorization = headersList.get("Authorization"); + if (!authorization) { + return Response.json( + { error: "인증 토큰이 필요합니다." }, + { status: 401 } + ); + } + + const idToken = authorization.split("Bearer ")[1]; + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; + + // 사용자 인증 확인 + const userData = await verifyUser(baseUrl, idToken); + if (!userData.exists) { + return Response.json( + { error: "인증되지 않은 사용자입니다." }, + { status: 401 } + ); + } + + const uid = userData.uid; + + // 2. Firestore 데이터 삭제 + // 2-1. 사용자의 게시글 조회 + const postsSnapshot = await db + .collection("posts") + .where("authorUid", "==", uid) + .get(); + + // 2-2. 배치 작업 설정 + const batch = db.batch(); + + // 2-3. 사용자의 게시글 소프트 삭제 + postsSnapshot.docs.forEach((doc) => { + batch.update(doc.ref, { + isDeleted: true, + deletedAt: new Date().toISOString(), + }); + }); + + // 2-4. 사용자 문서 삭제 + const userRef = db.collection("users").doc(uid); + batch.delete(userRef); + + // 2-5. 배치 작업 실행 + await batch.commit(); + + // 3. Firebase Authentication에서 사용자 삭제 + await auth.deleteUser(uid); + + return Response.json({ success: true }); + } catch (error) { + console.error("회원 탈퇴 처리 중 오류:", error); + + // 특정 에러에 따른 응답 + if (error.code === "auth/user-not-found") { + return Response.json( + { error: "존재하지 않는 사용자입니다." }, + { status: 404 } + ); + } + + return Response.json( + { error: "회원 탈퇴 처리 중 오류가 발생했습니다." }, + { status: 500 } + ); + } +} From 4f4037f07bd34d94592b371cd825a0244fa8fa9b 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 05:32:04 +0900 Subject: [PATCH 25/37] =?UTF-8?q?=E2=9C=A8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80:?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EC=97=90=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/account/page.js | 95 ++++++++++++++++++++++++----------------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/src/app/account/page.js b/src/app/account/page.js index a031468b..385dbd78 100644 --- a/src/app/account/page.js +++ b/src/app/account/page.js @@ -1,6 +1,6 @@ "use client"; -import { Button, ButtonLink } from "@/components/Controls"; +import { Button, ButtonLink, WithdrawModal } from "@/components/Controls"; import Loading from "@/components/Loading"; import { fetchWithAuth } from "@/utils/auth/tokenUtils"; import { useState, useEffect } from "react"; @@ -12,6 +12,7 @@ export default function Account() { const [userName, setUserName] = useState(""); const [via, setVia] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); useEffect(() => { const fetchAccountInfo = async () => { @@ -35,47 +36,61 @@ export default function Account() { if (isLoading) return ; return ( -
-
-

계정 설정

-
+ <> +
+
+

계정 설정

+
-
-

계정 정보

-
-
이메일
-
{email}
-
아이디
-
{userId}
-
사용자 이름
-
{userName}
-
계정 유형
-
- {via === "google" ? "Google 계정" : "이메일 계정"} -
-
-
+
+

계정 정보

+
+
이메일
+
{email}
+
아이디
+
{userId}
+
사용자 이름
+
{userName}
+
계정 유형
+
+ {via === "google" ? "Google 계정" : "이메일 계정"} +
+
+
-
-

계정 관리

-
- +

계정 관리

+
+ + 이메일 변경 + + + 비밀번호 변경 + +
+
- -
-
+ 회원 탈퇴 + + +
+ {isWithdrawModalOpen && ( + setIsWithdrawModalOpen(false)} + userId={userId} + /> + )} + ); } From 93e43f50a66498987f7473a1f91b000dc7faa8bf 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 05:39:17 +0900 Subject: [PATCH 26/37] =?UTF-8?q?=E2=9C=A8=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=84=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=84=A4=EC=A0=95:?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EB=B9=84=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=ED=95=9C=20=EB=A1=9C=EC=A7=81=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/components/auth/AuthStateHandler.jsx | 47 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/auth/AuthStateHandler.jsx b/src/components/auth/AuthStateHandler.jsx index 4ce6d7c9..672a5fff 100644 --- a/src/components/auth/AuthStateHandler.jsx +++ b/src/components/auth/AuthStateHandler.jsx @@ -13,32 +13,54 @@ import { checkUserExists } from "@/utils/auth/checkUser"; import Loading from "@/components/Loading"; import NavProvider from "../NavProvider"; +// 인증 상태별 접근 제어 라우트 설정 +const authRoutes = { + // 로그인한 사용자가 접근할 수 없는 페이지 + authenticatedBlocked: ["/join", "/signup"], + + // 로그인하지 않은 사용자가 접근할 수 없는 페이지 + unauthenticatedBlocked: [ + "/debug", + "/admin", + "/account", + "/account/modify-email", + "/account/modify-password", + ], + + // 인증 체크를 하지 않는 페이지 + noAuthCheck: ["/terms", "/privacy", "/join/verify-email"], +}; + export default function AuthStateHandler({ children }) { const dispatch = useDispatch(); const router = useRouter(); const pathname = usePathname(); const [isAuthChecked, setIsAuthChecked] = useState(false); - const isRegistering = useSelector((state) => state.auth.isRegistering); // 회원가입 중인지 확인 + const isRegistering = useSelector((state) => state.auth.isRegistering); const isEmailVerified = useSelector( (state) => state.auth.user?.emailVerified - ); // 이메일 인증 여부 + ); - const exceptPaths = ["/terms", "/privacy", "/join", "/join/verify-email"]; - const isExceptPath = exceptPaths.includes(pathname); + const isExceptPath = authRoutes.noAuthCheck.includes(pathname); useEffect(() => { - // 이용약관, 개인정보처리방침 페이지에서는 인증 체크를 하지 않음 if (isExceptPath) return; const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user) { try { + // 로그인한 사용자가 접근할 수 없는 페이지 체크 + if (authRoutes.authenticatedBlocked.includes(pathname)) { + console.log("Authenticated user blocked from:", pathname); + router.push("/"); + return; + } + const result = await checkUserExists(dispatch); if (result === true) { dispatch(setRegistrationComplete()); } else if (!isRegistering && isEmailVerified) { - // 회원가입 중이 아닐 때만 리다이렉트 dispatch(resetRegistrationComplete()); console.log("Redirect to signup"); router.push("/signup"); @@ -49,12 +71,23 @@ export default function AuthStateHandler({ children }) { } } else { dispatch(logout()); + + // 로그인하지 않은 사용자가 접근할 수 없는 페이지 체크 + if ( + authRoutes.unauthenticatedBlocked.some((route) => + pathname.startsWith(route) + ) + ) { + console.log("Unauthenticated user blocked from:", pathname); + router.push("/join"); + return; + } } setIsAuthChecked(true); }); return () => unsubscribe(); - }, [dispatch, router, isRegistering]); + }, [dispatch, router, pathname, isRegistering]); if (isExceptPath) { return {children}; From 0dfe7778db37ef72c40fc6348fd207c14f7545a2 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 06:04:20 +0900 Subject: [PATCH 27/37] =?UTF-8?q?=F0=9F=92=84=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=84=ED=8A=BC=EC=9D=98=20alt=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20'Google=EB=A1=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8'=EC=9C=BC=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/SocialLogin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/login/SocialLogin.jsx b/src/components/login/SocialLogin.jsx index a9282ae3..4d64270f 100644 --- a/src/components/login/SocialLogin.jsx +++ b/src/components/login/SocialLogin.jsx @@ -191,7 +191,7 @@ export default function SocialLogin() { src="/images/google-logo.svg" width={40} height={40} - alt="google 로그인" + alt="Google로 로그인" />
From 2ab8abb00109385298af14a2cee3c088b093987a 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 06:04:35 +0900 Subject: [PATCH 28/37] =?UTF-8?q?=F0=9F=90=9B=20Google=EB=A1=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=ED=96=88=EC=9D=84=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=9C=A0=ED=98=95=EC=9D=84=20=EC=9D=B8?= =?UTF-8?q?=EC=8B=9D=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/account/page.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/account/page.js b/src/app/account/page.js index 385dbd78..77d87006 100644 --- a/src/app/account/page.js +++ b/src/app/account/page.js @@ -53,7 +53,7 @@ export default function Account() {
{userName}
계정 유형
- {via === "google" ? "Google 계정" : "이메일 계정"} + {via === "google.com" ? "Google 계정" : "이메일 계정"}
From e3a4a4ef616214ab4ea1cd48b04675ddc5c31387 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 06:06:18 +0900 Subject: [PATCH 29/37] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20'/tomong'=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EC=9D=B8=EC=A6=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=A5=BC=20=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A9=EB=A1=9D=EC=97=90=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/components/auth/AuthStateHandler.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/auth/AuthStateHandler.jsx b/src/components/auth/AuthStateHandler.jsx index 672a5fff..7d90233e 100644 --- a/src/components/auth/AuthStateHandler.jsx +++ b/src/components/auth/AuthStateHandler.jsx @@ -25,6 +25,7 @@ const authRoutes = { "/account", "/account/modify-email", "/account/modify-password", + "/tomong", ], // 인증 체크를 하지 않는 페이지 From a3186c7f77e226b32f298f5e4a112658401ead53 Mon Sep 17 00:00:00 2001 From: jini0012 Date: Fri, 20 Dec 2024 09:29:04 +0900 Subject: [PATCH 30/37] =?UTF-8?q?=F0=9F=94=A5=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/signup/page.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/signup/page.module.css b/src/app/signup/page.module.css index 59d0499a..c4811c2c 100644 --- a/src/app/signup/page.module.css +++ b/src/app/signup/page.module.css @@ -11,7 +11,6 @@ body:has(.signup-main) { padding: 4.4rem 0rem 5.3rem 0rem; border-radius: 2rem; background: var(--background-white); - /* min-width: 39rem; 반응형 문제 해결을 위해 주석 처리 */ } @media (max-width: 720px) { From cf3a36f8aed2da8c431d716c21d74c39657feae2 Mon Sep 17 00:00:00 2001 From: jini0012 Date: Fri, 20 Dec 2024 09:29:47 +0900 Subject: [PATCH 31/37] =?UTF-8?q?=F0=9F=92=84=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=9C=EC=86=A1=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/EmailSignup.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/login/EmailSignup.module.css b/src/components/login/EmailSignup.module.css index a0e4d0c4..d9b9335f 100644 --- a/src/components/login/EmailSignup.module.css +++ b/src/components/login/EmailSignup.module.css @@ -88,6 +88,7 @@ main:has(.email-signup) { .email-verify-btn, .code-verify-btn { right: 8.4rem; + top: 3.7rem; } .next-btn { bottom: 1.6rem; @@ -101,7 +102,6 @@ main:has(.email-signup) { } .email-verify-btn, .code-verify-btn { - top: 4.2rem; padding: 0.6rem 1.2rem; } .invalid-text, From 2dc6c44c95827e3614c81546ffbfde15b9872417 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 09:38:10 +0900 Subject: [PATCH 32/37] Update AuthStateHandler.jsx --- src/components/auth/AuthStateHandler.jsx | 49 +++++++++++++++++------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/components/auth/AuthStateHandler.jsx b/src/components/auth/AuthStateHandler.jsx index 7d90233e..0de1f4b9 100644 --- a/src/components/auth/AuthStateHandler.jsx +++ b/src/components/auth/AuthStateHandler.jsx @@ -13,10 +13,9 @@ import { checkUserExists } from "@/utils/auth/checkUser"; import Loading from "@/components/Loading"; import NavProvider from "../NavProvider"; -// 인증 상태별 접근 제어 라우트 설정 const authRoutes = { - // 로그인한 사용자가 접근할 수 없는 페이지 - authenticatedBlocked: ["/join", "/signup"], + // 로그인한 사용자이면서 Firestore에도 등록된 사용자가 접근할 수 없는 페이지 + registeredBlocked: ["/join"], // 로그인하지 않은 사용자가 접근할 수 없는 페이지 unauthenticatedBlocked: [ @@ -25,7 +24,7 @@ const authRoutes = { "/account", "/account/modify-email", "/account/modify-password", - "/tomong", + "/signup", // signup도 인증이 필요 ], // 인증 체크를 하지 않는 페이지 @@ -37,6 +36,7 @@ export default function AuthStateHandler({ children }) { const router = useRouter(); const pathname = usePathname(); const [isAuthChecked, setIsAuthChecked] = useState(false); + const [isUserRegistered, setIsUserRegistered] = useState(false); const isRegistering = useSelector((state) => state.auth.isRegistering); const isEmailVerified = useSelector( (state) => state.auth.user?.emailVerified @@ -50,21 +50,41 @@ export default function AuthStateHandler({ children }) { const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user) { try { - // 로그인한 사용자가 접근할 수 없는 페이지 체크 - if (authRoutes.authenticatedBlocked.includes(pathname)) { - console.log("Authenticated user blocked from:", pathname); - router.push("/"); - return; - } - const result = await checkUserExists(dispatch); if (result === true) { dispatch(setRegistrationComplete()); - } else if (!isRegistering && isEmailVerified) { + setIsUserRegistered(true); + + // Firestore에 등록된 사용자가 /signup에 접근하려고 할 때 + if (pathname === "/signup") { + console.log("Registered user blocked from signup"); + router.push("/"); + return; + } + + // Firestore에 등록된 사용자가 /join에 접근하려고 할 때 + if (authRoutes.registeredBlocked.includes(pathname)) { + console.log("Registered user blocked from:", pathname); + router.push("/"); + return; + } + } else { dispatch(resetRegistrationComplete()); - console.log("Redirect to signup"); - router.push("/signup"); + setIsUserRegistered(false); + + // Firestore에 미등록된 사용자는 /signup으로 리다이렉트 + // 단, 이미 /signup에 있거나 예외 경로에 있는 경우는 제외 + if ( + !isRegistering && + isEmailVerified && + pathname !== "/signup" && + !isExceptPath + ) { + console.log("Redirect to signup"); + router.push("/signup"); + return; + } } } catch (error) { console.error("Auth check error:", error); @@ -72,6 +92,7 @@ export default function AuthStateHandler({ children }) { } } else { dispatch(logout()); + setIsUserRegistered(false); // 로그인하지 않은 사용자가 접근할 수 없는 페이지 체크 if ( From 7e9dc08fb3a106377a6d864100f41fa9614d9a6f Mon Sep 17 00:00:00 2001 From: jini0012 Date: Fri, 20 Dec 2024 09:39:30 +0900 Subject: [PATCH 33/37] =?UTF-8?q?=F0=9F=92=84=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=9C=EC=86=A1=20input=20padding=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/EmailSignup.module.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/login/EmailSignup.module.css b/src/components/login/EmailSignup.module.css index d9b9335f..7fa9fa41 100644 --- a/src/components/login/EmailSignup.module.css +++ b/src/components/login/EmailSignup.module.css @@ -27,6 +27,9 @@ main:has(.email-signup) { .input-container input.invalid { border: 0.2rem solid var(--invalid-border-color); } +.input-container input:first-of-type { + padding-right: 15rem; +} .email-input, .code-input { @@ -100,6 +103,9 @@ main:has(.email-signup) { .input-container input { max-height: 4.8rem; } + .input-container input:first-of-type { + padding-right: 12rem; + } .email-verify-btn, .code-verify-btn { padding: 0.6rem 1.2rem; From 34d712cfedd33c2bbeb0b707db9b8bf4a8bd504c 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 11:16:35 +0900 Subject: [PATCH 34/37] =?UTF-8?q?=E2=9C=A8=20MyPost=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20setIsWriteModalOpen=20prop=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EC=97=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dropDown/DropDown.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/dropDown/DropDown.jsx b/src/components/dropDown/DropDown.jsx index 44567f97..804f37cb 100644 --- a/src/components/dropDown/DropDown.jsx +++ b/src/components/dropDown/DropDown.jsx @@ -3,7 +3,10 @@ import styles from "./DropDown.module.css"; import { fetchWithAuth } from "@/utils/auth/tokenUtils"; export const MyPost = forwardRef( - ({ style, togglePostPrivacy, postId, postIsPrivate }, ref) => { + ( + { style, togglePostPrivacy, postId, postIsPrivate, setIsWriteModalOpen }, + ref + ) => { async function deletePost() { try { const response = await fetchWithAuth(`/api/post/delete/${postId}`, { @@ -22,6 +25,7 @@ export const MyPost = forwardRef(
  • From 3fba9f8114624af923ec85edecd05d5712ba2989 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 11:16:52 +0900 Subject: [PATCH 35/37] =?UTF-8?q?=E2=9C=A8=20WritePost=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/write/WritePost.jsx | 295 ++++++++++++++++++++--------- 1 file changed, 208 insertions(+), 87 deletions(-) diff --git a/src/components/write/WritePost.jsx b/src/components/write/WritePost.jsx index 80ac1445..3433fe9b 100644 --- a/src/components/write/WritePost.jsx +++ b/src/components/write/WritePost.jsx @@ -1,6 +1,12 @@ "use client"; -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import React, { + useEffect, + useLayoutEffect, + useRef, + useState, + useCallback, +} from "react"; import Image from "next/image"; import styles from "./WritePost.module.css"; import StopModal from "./StopModal"; @@ -11,13 +17,22 @@ import { fetchWithAuth } from "@/utils/auth/tokenUtils"; import useTheme from "@/hooks/styling/useTheme"; import { useRouter } from "next/navigation"; import Uploading from "./Uploading"; - -export default function WritePost({ isWriteModalOpen, closeWriteModal }) { +import { DREAM_GENRES, DREAM_MOODS } from "@/utils/constants"; +import Loading from "../Loading"; +import { ConfirmModal } from "../Controls"; + +export default function WritePost({ + isWriteModalOpen, + closeWriteModal, + modifyId, +}) { const [isWritingModalOpen, setIsWritingModalOpen] = useState(false); const [inputValue, setInputValue] = useState(""); const [contentValue, setContentValue] = useState(""); const [isContentChanged, setIsContentChanged] = useState(false); const [imageFiles, setImageFiles] = useState(null); + const [remainingImages, setRemainingImages] = useState([]); + const [isFetching, setIsFetching] = useState(!!modifyId); const [isLoading, setIsLoading] = useState(false); const { user } = useSelector((state) => state.auth); const { theme } = useTheme(); @@ -27,16 +42,191 @@ export default function WritePost({ isWriteModalOpen, closeWriteModal }) { const userId = user?.userId; const userName = user?.userName; - // 해시태그/기분 클릭 목록 const [selectedGenres, setSelectedGenres] = useState([]); const [selectedMoods, setSelectedMoods] = useState([]); - const [rating, setRating] = useState(null); // 별점 - const [isPrivate, setIsPrivate] = useState(false); // 비공개 - // 모달 열림 확인 + const [rating, setRating] = useState(null); + const [isPrivate, setIsPrivate] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [isMoodModalOpen, setIsMoodModalOpen] = useState(false); const [isStopModalOpen, setIsStopModalOpen] = useState(false); + // 수정 모드일 때 데이터 불러오기 + useEffect(() => { + const fetchPost = async () => { + if (modifyId && isWriteModalOpen) { + setIsWritingModalOpen(true); + try { + const response = await fetchWithAuth(`/api/post/search/${modifyId}`); + if (response.ok) { + const { post } = await response.json(); + setInputValue(post.title); + setContentValue(post.content); + setSelectedGenres( + post.dreamGenres.map((id) => { + const genre = DREAM_GENRES.find((g) => g.id === id); + return { + id: genre.id, + text: genre.text, + lightColor: genre.lightColor, + darkColor: genre.darkColor, + }; + }) + ); + setSelectedMoods( + post.dreamMoods.map((id) => { + const mood = DREAM_MOODS.find((m) => m.id === id); + return { + id: mood.id, + text: mood.text, + }; + }) + ); + setRating(post.dreamRating.toString()); + setIsPrivate(post.isPrivate); + setRemainingImages(post.imageUrls); + setIsFetching(false); + } + } catch (error) { + console.error("게시글 불러오기 실패:", error); + } + } + }; + + fetchPost(); + }, [modifyId, isWriteModalOpen]); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (contentValue === "") { + alert("작성된 내용이 없습니다"); + return; + } + + const formData = new FormData(); + formData.append( + "title", + inputValue === "" ? `${year}년 ${month}월 ${date}일 꿈 일기` : inputValue + ); + formData.append("content", contentValue); + formData.append("genres", JSON.stringify(genresId)); + formData.append("moods", JSON.stringify(moodsId)); + formData.append("rating", rating === null ? "0" : rating); + formData.append("isPrivate", isPrivate ? "true" : "false"); + + if (modifyId) { + formData.append("remainingImages", JSON.stringify(remainingImages)); + } + + if (imageFiles?.length > 0) { + Array.from(imageFiles).forEach((file) => { + formData.append("images", file); + }); + } + + try { + setIsLoading(true); + const endpoint = modifyId + ? `/api/post/update/${modifyId}` + : "/api/post/create"; + + const response = await fetchWithAuth(endpoint, { + method: "POST", + body: formData, + }); + + if (response.ok) { + const result = await response.json(); + const postId = result.postId || modifyId; + location.href = `/users/${user.userId}?post=${postId}`; + closeWriteModal(); + resetForm(); + } else { + alert(modifyId ? "게시글 수정 실패" : "게시글 작성 실패"); + } + } catch (error) { + console.error("에러", error); + } finally { + setIsLoading(false); + } + }; + + const imageOrderRef = useRef([]); + + useEffect(() => { + imageOrderRef.current = remainingImages.map((url, index) => ({ + id: `image-${index}`, + url, + })); + }, [remainingImages.length]); + + // 기존 이미지 삭제 + const handleDeleteExistingImage = useCallback((imageToDelete) => { + setRemainingImages((prev) => { + const newImages = prev.filter((url) => url !== imageToDelete.url); + imageOrderRef.current = imageOrderRef.current.filter( + (item) => item.url !== imageToDelete.url + ); + return newImages; + }); + }, []); + + // 이미지 미리보기 섹션 수정 + const renderImagePreviews = () => { + return ( +
    + {isFetching && } + {imageOrderRef.current.map((image, index) => ( +
    + + {`기존 +
    + ))} + {imageFiles && + Array.from(imageFiles).map((img, index) => ( +
    + + {`새 +
    + ))} +
    + ); + }; + const genresId = selectedGenres.map((item) => item.id); const moodsId = selectedMoods.map((item) => item.id); @@ -240,53 +430,6 @@ export default function WritePost({ isWriteModalOpen, closeWriteModal }) { const month = (today.getMonth() + 1).toString().padStart(2, "0"); const date = today.getDate().toString().padStart(2, "0"); - const handleSubmit = async (e) => { - e.preventDefault(); - - if (contentValue === "") { - alert("작성된 내용이 없습니다"); - return; - } - - const formData = new FormData(); - formData.append( - "title", - inputValue === "" ? `${year}년 ${month}월 ${date}일 꿈 일기` : inputValue - ); - formData.append("content", contentValue); - formData.append("genres", JSON.stringify(genresId)); - formData.append("moods", JSON.stringify(moodsId)); - formData.append("rating", rating === null ? "0" : rating); - formData.append("isPrivate", isPrivate ? "true" : "false"); - if (imageFiles?.length > 0) { - Array.from(imageFiles).forEach((file) => { - formData.append("images", file); - }); - } - - try { - setIsLoading(true); - const response = await fetchWithAuth("/api/post/create", { - method: "POST", - body: formData, - }); - - if (response.ok) { - const result = await response.json(); - const postId = result.postId; - location.href = `/users/${user.userId}?post=${postId}`; - closeWriteModal(); - resetForm(); - } else { - alert("게시글 작성 실패"); - } - } catch (error) { - console.error("에러", error); - } finally { - setIsLoading(false); - } - }; - useEffect(() => { if (isWriteModalOpen) { document.documentElement.style.overflow = "hidden"; @@ -319,7 +462,9 @@ export default function WritePost({ isWriteModalOpen, closeWriteModal }) { className={styles["modal-contents"]} onClick={(e) => e.stopPropagation()} > -

    새로운 글 작성

    +

    + {modifyId ? "게시글 수정하기" : "새로운 글 작성"} +

    -

    +

    글 작성