From 8b30c7e8744f2161486c503b90717fbfa6b24342 Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:51:11 +0900 Subject: [PATCH 01/23] =?UTF-8?q?[Chore]=20heroicons,=20react-hook-form=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98,=20=EC=9D=98=EC=A1=B4=EC=84=B1=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 --- package.json | 2 ++ pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/package.json b/package.json index 6312a28..a62c360 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "build-storybook": "storybook build" }, "dependencies": { + "@heroicons/react": "^2.2.0", "@reduxjs/toolkit": "^2.4.0", "@tanstack/react-query": "^5.62.2", "@types/react-redux": "^7.1.34", "axios": "^1.7.9", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.54.1", "react-redux": "^9.1.2", "react-router": "^7.0.2", "styled-components": "^6.1.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18933c8..7afddd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@heroicons/react': + specifier: ^2.2.0 + version: 2.2.0(react@18.3.1) '@reduxjs/toolkit': specifier: ^2.4.0 version: 2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -26,6 +29,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.54.1 + version: 7.54.1(react@18.3.1) react-redux: specifier: ^9.1.2 version: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) @@ -541,6 +547,11 @@ packages: resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1926,6 +1937,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.54.1: + resolution: {integrity: sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2753,6 +2770,10 @@ snapshots: dependencies: levn: 0.4.1 + '@heroicons/react@2.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4197,6 +4218,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.54.1(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {} From 1d9c0bef690f688b4a6a98365586bed905afa8b0 Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:51:49 +0900 Subject: [PATCH 02/23] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B3=A0=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/assets/qublogo.svg | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/assets/qublogo.svg diff --git a/src/assets/qublogo.svg b/src/assets/qublogo.svg new file mode 100644 index 0000000..3ae79fb --- /dev/null +++ b/src/assets/qublogo.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 021c9ec5f6a763f2b7269bd4d744dc40277d8423 Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:53:28 +0900 Subject: [PATCH 03/23] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/JoinForm.tsx | 200 ++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/components/JoinForm.tsx diff --git a/src/components/JoinForm.tsx b/src/components/JoinForm.tsx new file mode 100644 index 0000000..144025c --- /dev/null +++ b/src/components/JoinForm.tsx @@ -0,0 +1,200 @@ +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import PasswordGuideLines from './atoms/PasswordGuideLines'; + +export interface JoinProps { + email: string; + nickname: string; + password: string; + confirmPassword: string; +} + +function JoinForm() { + const navigate = useNavigate(); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm(); + + const password = watch('password'); + + const onSubmit = (data: JoinProps) => { + console.log('가입 정보:', data); + navigate('/login'); + }; + + return ( + + + navigate('/')}> + + + +
+ + + + {errors.email && {errors.email.message}} + + + + + {errors.nickname && {errors.nickname.message}} + + + + + {errors.password && {errors.password.message}} + + + + value === watch('password') || + '비밀번호가 일치하지 않습니다.', + })} + /> + + {errors.confirmPassword && ( + {errors.confirmPassword.message} + )} + + + + + + 회원가입 +
+
+
+ ); +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const PasswordGuideLinesWrapper = styled.div` + width: 100%; + text-align: left; + margin-left: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 15px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; + +export default JoinForm; From 091bd626dc9b51d8d3261afd743d0213eb9809eb Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:53:39 +0900 Subject: [PATCH 04/23] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LoginForm.tsx | 163 +++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/components/LoginForm.tsx diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..dedbc83 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,163 @@ +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import { useForm } from 'react-hook-form'; + +interface LoginFormData { + email: string; + password: string; +} + +function LoginPage() { + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const onSubmit = (data: LoginFormData) => { + console.log('로그인 정보:', data); + navigate('/'); + }; + + return ( + + + navigate('/')}> + + + +
+ + + + {errors.email && {errors.email.message}} + + + + + {errors.password && {errors.password.message}} + + 로그인 +
+ + + 아직 회원이 아니신가요? + navigate('/join')}>이메일 회원가입 + +
+
+ ); +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 18px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } +`; + +const BottomSection = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 3.5rem; + gap: 1rem; +`; + +const InfoText = styled.span` + color: #a7a7a7; + font-size: 12px; +`; + +const LinkText = styled.span` + color: #000000; + font-size: 12px; + cursor: pointer; +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; + +export default LoginPage; From c6e3ba1ccd9a71155e7a62ba1ff9ed67cee39c17 Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:54:24 +0900 Subject: [PATCH 05/23] =?UTF-8?q?[Feat]=20=EC=9D=B8=ED=92=8B=20=ED=8F=BC?= =?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/atoms/Input.tsx | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/components/atoms/Input.tsx diff --git a/src/components/atoms/Input.tsx b/src/components/atoms/Input.tsx new file mode 100644 index 0000000..acb5a77 --- /dev/null +++ b/src/components/atoms/Input.tsx @@ -0,0 +1,89 @@ +import React, { ForwardedRef, useState } from 'react'; +import styled from 'styled-components'; + +interface InputFieldProps extends React.InputHTMLAttributes { + label: string; + inputType?: 'text' | 'email' | 'password' | 'number' | 'nickname'; +} + +const Container = styled.div` + position: relative; + width: 100%; + align-items: center; + margin-top: 1.5rem; + justify-content: center; +`; + +interface LabelProps { + isActive: boolean; +} + +const StyledLabel = styled.label` + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: #727272; + font-family: sans-serif; + transition: all 0.2s ease-in-out; + + ${({ isActive }) => + isActive && + ` + top: 25%; + font-size: 0.75rem; /* text-xs */ + color: #6b7280; /* text-gray-500 */ + `} +`; + +const StyledInput = styled.input` + align-items: center; + width: 100%; + height: 70px; + padding: 0.1rem 1rem; + background-color: #ffffff; + border-radius: 0.375rem; + border: 0.5px solid #000000; + color: #727272; + font-size: 1.25rem; + + &:focus { + outline: none; + } + + ::placeholder { + color: transparent; /* placeholder-transparent */ + } +`; + +const Input = React.forwardRef( + ( + { label, inputType = 'text', onChange, ...props }: InputFieldProps, + ref: ForwardedRef + ) => { + const [isFocused, setIsFocused] = useState(false); + const [hasValue, setHasValue] = useState(false); + + const handleInputFocus = () => setIsFocused(true); + const handleInputBlur = (e: React.ChangeEvent) => { + setIsFocused(false); + setHasValue(e.target.value !== ''); + }; + + return ( + + {label} + + + ); + } +); + +export default Input; From ecb17526f08061aee5029735c34eeeac50622cdd Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:55:21 +0900 Subject: [PATCH 06/23] =?UTF-8?q?[Refactor]=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/atoms/Input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/atoms/Input.tsx b/src/components/atoms/Input.tsx index acb5a77..22af179 100644 --- a/src/components/atoms/Input.tsx +++ b/src/components/atoms/Input.tsx @@ -52,7 +52,7 @@ const StyledInput = styled.input` } ::placeholder { - color: transparent; /* placeholder-transparent */ + color: transparent; } `; From 5aa9a5cc64c43068c35894b6e6b0579405baa78d Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:55:54 +0900 Subject: [PATCH 07/23] =?UTF-8?q?[Feat]=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=9D=BC=EC=9D=B8=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/atoms/PasswordGuideLines.tsx | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/components/atoms/PasswordGuideLines.tsx diff --git a/src/components/atoms/PasswordGuideLines.tsx b/src/components/atoms/PasswordGuideLines.tsx new file mode 100644 index 0000000..f5f03be --- /dev/null +++ b/src/components/atoms/PasswordGuideLines.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { CheckCircleIcon } from '@heroicons/react/20/solid'; +import styled from 'styled-components'; + +interface Guideline { + label: string; + isValid: boolean; + check: (password: string) => boolean; +} + +const Container = styled.div` + display: flex; + flex-direction: column; + > * + * { + margin-top: 0.5rem; + } +`; + +const GuidelineItem = styled.div` + display: flex; + align-items: center; +`; + +const StyledCheckCircleIcon = styled(CheckCircleIcon)` + width: 1.25rem; + height: 1.25rem; + color: #32c040; +`; + +const EmptyCircle = styled.span` + margin-left: 0.15rem; + width: 0.85rem; + height: 0.85rem; + border: 1px solid #374151; + border-radius: 9999px; + display: inline-block; +`; + +const GuidelineLabel = styled.span<{ isValid: boolean }>` + margin-left: 0.5rem; + font-size: 0.875rem; + color: ${(props) => (props.isValid ? '#32C040' : '#6b7280')}; +`; + +const PasswordGuideLines = ({ password }: { password: string }) => { + const [guidelines, setGuidelines] = useState([ + { + label: '8자 이상, 15자 이하로 설정해 주세요', + isValid: false, + check: (password: string) => + password.length >= 8 && password.length <= 15, + }, + { + label: '특수 문자를 사용해 주세요', + isValid: false, + check: (password: string) => /[!@#$%^&*(),.?":{}|<>]/.test(password), + }, + { + label: '동일한 문자가 4번 반복되면 안돼요', + isValid: false, + check: (password: string) => + !/(.)\1{3}/.test(password.replace(/\s/g, '')), + }, + ]); + + useEffect(() => { + setGuidelines((prevGuidelines) => + prevGuidelines.map((guideline) => ({ + ...guideline, + isValid: guideline.check(password), + })) + ); + }, [password]); + + return ( + + {guidelines.map(({ label, isValid }, index) => ( + + {isValid ? : } + {label} + + ))} + + ); +}; + +export default PasswordGuideLines; From 47b1a0e309c8a6f620cbba04d053beab2d4b982a Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:56:12 +0900 Subject: [PATCH 08/23] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/JoinPage.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/pages/JoinPage.tsx diff --git a/src/pages/JoinPage.tsx b/src/pages/JoinPage.tsx new file mode 100644 index 0000000..83309fe --- /dev/null +++ b/src/pages/JoinPage.tsx @@ -0,0 +1,11 @@ +import JoinForm from '@/components/JoinForm'; + +const JoinPage = () => { + return ( +
+ +
+ ); +}; + +export default JoinPage; From e2c9920e14f877fa07e1139bf9c488ba85dd5345 Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:56:22 +0900 Subject: [PATCH 09/23] =?UTF-8?q?[Feat]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/pages/LoginPage.tsx diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..fc8527c --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,11 @@ +import LoginForm from '@/components/LoginForm'; + +const LoginPage = () => { + return ( +
+ +
+ ); +}; + +export default LoginPage; From 912bba5ef9154780f8b2096c6c51431179a3605a Mon Sep 17 00:00:00 2001 From: leechan Date: Fri, 13 Dec 2024 16:57:59 +0900 Subject: [PATCH 10/23] =?UTF-8?q?[Feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85,=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/router.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/router.tsx b/src/routes/router.tsx index b3db9b9..dae66f1 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,10 +1,20 @@ import HomePage from '@/pages/HomePage'; +import JoinPage from '@/pages/JoinPage'; +import LoginPage from '@/pages/LoginPage'; const router = [ { path: '/', element: , }, + { + path: '/login', + element: , + }, + { + path: '/join', + element: , + }, ]; export default router; From e6c4bcd174e572c393a23ac476a35245c9e4688b Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 15:48:50 +0900 Subject: [PATCH 11/23] =?UTF-8?q?[Feat]=20jwt-decode=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 ++ pnpm-lock.yaml | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/package.json b/package.json index a62c360..a233820 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@tanstack/react-query": "^5.62.2", "@types/react-redux": "^7.1.34", "axios": "^1.7.9", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", @@ -35,6 +36,7 @@ "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", + "@types/jwt-decode": "^3.1.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7afddd5..c0049ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: axios: specifier: ^1.7.9 version: 1.7.9 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -69,6 +72,9 @@ importers: '@storybook/test': specifier: ^8.4.7 version: 8.4.7(storybook@8.4.7) + '@types/jwt-decode': + specifier: ^3.1.0 + version: 3.1.0 '@types/react': specifier: ^18.3.12 version: 18.3.14 @@ -977,6 +983,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jwt-decode@3.1.0': + resolution: {integrity: sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w==} + deprecated: This is a stub types definition. jwt-decode provides its own type definitions, so you do not need this installed. + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -1713,6 +1723,10 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3236,6 +3250,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jwt-decode@3.1.0': + dependencies: + jwt-decode: 4.0.0 + '@types/mdx@2.0.13': {} '@types/node@22.10.2': @@ -3996,6 +4014,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 From fad712bb86a9841acbb024910b7b0bfda8f72f60 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 16:40:57 +0900 Subject: [PATCH 12/23] =?UTF-8?q?[Refactor]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=8F=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/JoinForm.tsx | 200 ----------------------------------- src/components/LoginForm.tsx | 163 ---------------------------- 2 files changed, 363 deletions(-) delete mode 100644 src/components/JoinForm.tsx delete mode 100644 src/components/LoginForm.tsx diff --git a/src/components/JoinForm.tsx b/src/components/JoinForm.tsx deleted file mode 100644 index 144025c..0000000 --- a/src/components/JoinForm.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useForm } from 'react-hook-form'; -import styled from 'styled-components'; -import { useNavigate } from 'react-router'; -import qublogo from '@/assets/qublogo.svg'; -import Input from '@/components/atoms/Input'; -import PasswordGuideLines from './atoms/PasswordGuideLines'; - -export interface JoinProps { - email: string; - nickname: string; - password: string; - confirmPassword: string; -} - -function JoinForm() { - const navigate = useNavigate(); - - const { - register, - handleSubmit, - formState: { errors }, - watch, - } = useForm(); - - const password = watch('password'); - - const onSubmit = (data: JoinProps) => { - console.log('가입 정보:', data); - navigate('/login'); - }; - - return ( - - - navigate('/')}> - - - -
- - - - {errors.email && {errors.email.message}} - - - - - {errors.nickname && {errors.nickname.message}} - - - - - {errors.password && {errors.password.message}} - - - - value === watch('password') || - '비밀번호가 일치하지 않습니다.', - })} - /> - - {errors.confirmPassword && ( - {errors.confirmPassword.message} - )} - - - - - - 회원가입 -
-
-
- ); -} - -const Container = styled.div` - background-color: #ffffff; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100vh; -`; - -const InnerWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 24rem; -`; - -const LogoWrapper = styled.div` - margin-bottom: 2.5rem; - cursor: pointer; -`; - -const LogoImage = styled.img` - width: 150px; - height: 80px; - object-fit: contain; -`; - -const Form = styled.form` - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -`; - -const InputWrapper = styled.div` - width: 100%; - display: flex; - justify-content: center; - margin-bottom: 1rem; -`; - -const StyledInput = styled(Input)` - max-width: 350px; - width: 100%; - margin: 0 auto; - display: block; -`; - -const PasswordGuideLinesWrapper = styled.div` - width: 100%; - text-align: left; - margin-left: 1rem; - margin-top: 1rem; - margin-bottom: 1rem; -`; - -const SubmitButton = styled.button` - width: 300px; - height: 70px; - background-color: #ffffff; - border: 1px solid #000000; - border-radius: 9999px; - display: flex; - justify-content: center; - align-items: center; - color: #000000; - font-size: 15px; - font-weight: 400; - cursor: pointer; - margin: 0 auto; - margin-top: 2rem; - - &:hover { - background-color: #e5e7eb; - } -`; - -const ErrorText = styled.p` - color: #ef4444; - font-size: 0.875rem; - margin-bottom: 1rem; - text-align: left; - width: 100%; - max-width: 350px; -`; - -export default JoinForm; diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx deleted file mode 100644 index dedbc83..0000000 --- a/src/components/LoginForm.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import styled from 'styled-components'; -import { useNavigate } from 'react-router'; -import qublogo from '@/assets/qublogo.svg'; -import Input from '@/components/atoms/Input'; -import { useForm } from 'react-hook-form'; - -interface LoginFormData { - email: string; - password: string; -} - -function LoginPage() { - const navigate = useNavigate(); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm(); - - const onSubmit = (data: LoginFormData) => { - console.log('로그인 정보:', data); - navigate('/'); - }; - - return ( - - - navigate('/')}> - - - -
- - - - {errors.email && {errors.email.message}} - - - - - {errors.password && {errors.password.message}} - - 로그인 -
- - - 아직 회원이 아니신가요? - navigate('/join')}>이메일 회원가입 - -
-
- ); -} - -const Container = styled.div` - background-color: #ffffff; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100vh; -`; - -const InnerWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 24rem; -`; - -const LogoWrapper = styled.div` - margin-bottom: 2.5rem; - cursor: pointer; -`; - -const LogoImage = styled.img` - width: 150px; - height: 80px; - object-fit: contain; -`; - -const Form = styled.form` - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -`; - -const InputWrapper = styled.div` - width: 100%; - display: flex; - justify-content: center; - margin-bottom: 1rem; -`; - -const StyledInput = styled(Input)` - max-width: 350px; - width: 100%; - margin: 0 auto; - display: block; -`; - -const SubmitButton = styled.button` - width: 300px; - height: 70px; - background-color: #ffffff; - border: 1px solid #000000; - border-radius: 9999px; - display: flex; - justify-content: center; - align-items: center; - color: #000000; - font-size: 18px; - font-weight: 400; - cursor: pointer; - margin: 0 auto; - margin-top: 2rem; - - &:hover { - background-color: #e5e7eb; - } -`; - -const BottomSection = styled.div` - width: 100%; - display: flex; - justify-content: center; - align-items: center; - margin-top: 3.5rem; - gap: 1rem; -`; - -const InfoText = styled.span` - color: #a7a7a7; - font-size: 12px; -`; - -const LinkText = styled.span` - color: #000000; - font-size: 12px; - cursor: pointer; -`; - -const ErrorText = styled.p` - color: #ef4444; - font-size: 0.875rem; - margin-bottom: 1rem; - text-align: left; - width: 100%; - max-width: 350px; -`; - -export default LoginPage; From fe87fc5e85a82f8d951f14908d2a91c529753dda Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 16:42:35 +0900 Subject: [PATCH 13/23] =?UTF-8?q?[Refactor]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=8F=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=9B=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/JoinPage.tsx | 201 ++++++++++++++++++++++++++++++++++++++-- src/pages/LoginPage.tsx | 164 ++++++++++++++++++++++++++++++-- 2 files changed, 353 insertions(+), 12 deletions(-) diff --git a/src/pages/JoinPage.tsx b/src/pages/JoinPage.tsx index 83309fe..857fe49 100644 --- a/src/pages/JoinPage.tsx +++ b/src/pages/JoinPage.tsx @@ -1,11 +1,200 @@ -import JoinForm from '@/components/JoinForm'; +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import PasswordGuideLines from '@/components/atoms/PasswordGuideLines'; + +export interface JoinProps { + email: string; + nickname: string; + password: string; + confirmPassword: string; +} + +function JoinPage() { + const navigate = useNavigate(); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm(); + + const password = watch('password'); + + const onSubmit = (data: JoinProps) => { + console.log('가입 정보:', data); + navigate('/login'); + }; -const JoinPage = () => { return ( -
- -
+ + + navigate('/')}> + + + +
+ + + + {errors.email && {errors.email.message}} + + + + + {errors.nickname && {errors.nickname.message}} + + + + + {errors.password && {errors.password.message}} + + + + value === watch('password') || + '비밀번호가 일치하지 않습니다.', + })} + /> + + {errors.confirmPassword && ( + {errors.confirmPassword.message} + )} + + + + + + 회원가입 +
+
+
); -}; +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const PasswordGuideLinesWrapper = styled.div` + width: 100%; + text-align: left; + margin-left: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 15px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; export default JoinPage; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index fc8527c..dedbc83 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,11 +1,163 @@ -import LoginForm from '@/components/LoginForm'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import { useForm } from 'react-hook-form'; + +interface LoginFormData { + email: string; + password: string; +} + +function LoginPage() { + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const onSubmit = (data: LoginFormData) => { + console.log('로그인 정보:', data); + navigate('/'); + }; -const LoginPage = () => { return ( -
- -
+ + + navigate('/')}> + + + +
+ + + + {errors.email && {errors.email.message}} + + + + + {errors.password && {errors.password.message}} + + 로그인 +
+ + + 아직 회원이 아니신가요? + navigate('/join')}>이메일 회원가입 + +
+
); -}; +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 18px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } +`; + +const BottomSection = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 3.5rem; + gap: 1rem; +`; + +const InfoText = styled.span` + color: #a7a7a7; + font-size: 12px; +`; + +const LinkText = styled.span` + color: #000000; + font-size: 12px; + cursor: pointer; +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; export default LoginPage; From cc4a35c35957bfb3fe7199691b441e29e7e5b7cd Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:42:49 +0900 Subject: [PATCH 14/23] =?UTF-8?q?[Refactor]=20env=20=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f940a99..1ab3c71 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Environment variables +.env + # Editor directories and files .vscode/* !.vscode/extensions.json From 6b9f093317b96c0904766461a85ab707965c4b7b Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:43:24 +0900 Subject: [PATCH 15/23] =?UTF-8?q?[Feat]=20=EC=83=81=ED=83=9C=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 93b98f4..8f202cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import Button from '@/components/atoms/Button'; import { RouterProvider, createBrowserRouter } from 'react-router'; import router from '@/routes/router'; +import { Provider } from 'react-redux'; // Redux Provider 임포트 +import { store } from '@/store/store'; // 설정된 Redux 스토어 임포트 const queryClient = new QueryClient({ defaultOptions: { @@ -16,10 +17,11 @@ const queryClient = new QueryClient({ const App = () => { const appRouter = createBrowserRouter(router); return ( - - - - + + + + + ); }; From aacc705a56d15b7db8c2e81eae46dda1861c4e00 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:44:14 +0900 Subject: [PATCH 16/23] =?UTF-8?q?[Refactor]=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 74566ee..4ee7573 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,22 +3,6 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import '@/styles/reset.css'; -// if (process.env.NODE_ENV === 'development') { -// worker.start().then(() => { -// createRoot(document.getElementById('root')!).render( -// -// -// -// ); -// }); -// } else { -// createRoot(document.getElementById('root')!).render( -// -// -// -// ); -// } - createRoot(document.getElementById('root')!).render( From 18d9c29a30a5df7cb06988d4087e492bcae1749c Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:47:31 +0900 Subject: [PATCH 17/23] =?UTF-8?q?[Feat]=20auth=20API=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=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/apis/auth.api.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/apis/auth.api.ts diff --git a/src/apis/auth.api.ts b/src/apis/auth.api.ts new file mode 100644 index 0000000..8c22891 --- /dev/null +++ b/src/apis/auth.api.ts @@ -0,0 +1,70 @@ +import httpClient from './http.api'; // Axios 인스턴스 가져오기 +import { JoinProps } from '@/pages/JoinPage'; +import { LoginProps } from '@/pages/LoginPage'; + +// 회원가입 요청 +export const join = async (userData: JoinProps) => { + try { + const { data, status } = await httpClient.post('/api/users/join', userData); + + if (status === 200) { + console.log('회원가입 성공:', data); + return data; + } else if (status === 400 && data.message.includes('입력해주세요')) { + console.error('필수 입력값 없음:', data.message); + throw new Error(data.message); + } else if (status === 400 && data.message.includes('중복된')) { + console.error('중복 오류:', data.message); + throw new Error(data.message); + } else { + throw new Error('알 수 없는 회원가입 에러가 발생했습니다.'); + } + } catch (error) { + console.error('회원가입 요청 중 에러 발생:', error); + throw new Error('회원가입 요청에 실패했습니다. 관리자에게 문의하세요.'); + } +}; + +interface LoginResponse { + message: any; + success: boolean; + token?: string; +} + +// 로그인 요청 +export const login = async (data: LoginProps): Promise => { + try { + const { data: responseData, status } = await httpClient.post( + '/api/users/login', + data + ); + + if (status === 200 && responseData.success) { + console.log('로그인 성공:', responseData); + return responseData; + } else if ( + status === 400 && + responseData.message.includes('입력해주세요') + ) { + console.error('필수 입력값 없음:', responseData.message); + throw new Error(responseData.message); + } else if ( + status === 404 && + responseData.message.includes('등록되지 않은 이메일') + ) { + console.error('등록되지 않은 이메일:', responseData.message); + throw new Error(responseData.message); + } else if ( + status === 401 && + responseData.message.includes('비밀번호가 틀렸습니다') + ) { + console.error('비밀번호 오류:', responseData.message); + throw new Error(responseData.message); + } else { + throw new Error('알 수 없는 로그인 에러가 발생했습니다.'); + } + } catch (error) { + console.error('로그인 요청 중 에러 발생:', error); + throw new Error('로그인 요청에 실패했습니다. 관리자에게 문의하세요.'); + } +}; From e4c0621a078c998f08fa45f3f0b667dd5f03d5d8 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:48:25 +0900 Subject: [PATCH 18/23] =?UTF-8?q?[Feat]=20HTTP=20API=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20Axios=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=84=B4=EC=8A=A4=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/http.api.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/apis/http.api.ts diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts new file mode 100644 index 0000000..6d496d0 --- /dev/null +++ b/src/apis/http.api.ts @@ -0,0 +1,95 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { getToken, removeToken } from '@/store/slices/authSlice'; // 토큰 유틸리티 함수 import +import { logout } from '@/store/slices/authSlice'; +import { store } from '@/store/store'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3333'; +const DEFAULT_TIMEOUT = 30000; // 요청 제한 시간 + +// Axios 인스턴스 생성 함수 +export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { + const axiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: DEFAULT_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, + ...config, + }); + + // 요청 인터셉터: Authorization 헤더 동적 설정 + axiosInstance.interceptors.request.use( + (config) => { + const accessToken = getToken(); + if (accessToken) { + config.headers = { + ...config.headers, // 기존 헤더 유지 + Authorization: `Bearer ${accessToken}`, // 토큰 추가 + }; + } + return config; + }, + (error) => Promise.reject(error) + ); + + // 응답 인터셉터: 401 상태 처리 + axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + console.warn('401 Unauthorized: Logging out user...'); + removeToken(); // 토큰 삭제 + store.dispatch(logout()); // Redux 상태 초기화 + window.location.href = '/login'; // 로그인 페이지로 리다이렉트 + return; + } + return Promise.reject(error); + } + ); + + return axiosInstance; +}; + +// 기본 Axios 인스턴스 생성 +export const httpClient = createClient(); + +// API 요청 함수 +export const authApi = { + join: async (data: { email: string; password: string; nickname: string }) => { + try { + const response = await httpClient.post('/api/signup', data); + return response.data; + } catch (error) { + console.error('Signup API Error:', error); + throw error; + } + }, + login: async (data: { email: string; password: string }) => { + try { + const response = await httpClient.post('/api/login', data); + const { token } = response.data; + + // 토큰 저장 + localStorage.setItem('accessToken', token); // 필요 시 유틸리티 함수 사용 가능 + return response.data; + } catch (error) { + console.error('Login API Error:', error); + throw error; + } + }, + logout: async () => { + try { + await httpClient.post('/api/logout'); + removeToken(); // 토큰 삭제 + store.dispatch(logout()); // Redux 상태 초기화 + window.location.href = '/login'; // 로그인 페이지로 리다이렉트 + } catch (error) { + console.error('Logout API Error:', error); + throw error; + } + }, +}; + +// 기본 Axios 인스턴스 내보내기 +export default httpClient; From 242fe754e31b7044ac9f3d8a25488b00d179521f Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:49:38 +0900 Subject: [PATCH 19/23] =?UTF-8?q?[Refactor]=20Authorization=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EB=AC=B8=EC=A0=9C=20-=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=ED=95=84=EC=9A=94=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/http.api.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts index 6d496d0..67909a3 100644 --- a/src/apis/http.api.ts +++ b/src/apis/http.api.ts @@ -23,14 +23,24 @@ export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { (config) => { const accessToken = getToken(); if (accessToken) { - config.headers = { - ...config.headers, // 기존 헤더 유지 - Authorization: `Bearer ${accessToken}`, // 토큰 추가 - }; + if (config.headers && typeof config.headers.set === 'function') { + // AxiosHeaders 객체 처리 + config.headers.set('Authorization', `Bearer ${accessToken}`); + } else { + // 일반 객체 초기화 + config.headers = { + ...config.headers, // 기존 헤더 유지 + Authorization: `Bearer ${accessToken}`, + } as any; + } } + console.log('Request Headers:', config.headers); // 디버깅용 로그 return config; }, - (error) => Promise.reject(error) + (error) => { + console.error('Request Error:', error); + return Promise.reject(error); + } ); // 응답 인터셉터: 401 상태 처리 From 49090d653a570e10b469e034bf148ee81adb7b29 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:50:11 +0900 Subject: [PATCH 20/23] =?UTF-8?q?[Feat]=20=EB=A6=AC=EB=8D=95=EC=8A=A4=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20store=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/store.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/store/store.ts diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..5db4517 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from '@/store/slices/authSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; From b4985a190808849f1c5afffc3e5cb55f8b2c7243 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:50:45 +0900 Subject: [PATCH 21/23] =?UTF-8?q?[Refactor]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/JoinPage.tsx | 46 +++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/pages/JoinPage.tsx b/src/pages/JoinPage.tsx index 857fe49..4cf756d 100644 --- a/src/pages/JoinPage.tsx +++ b/src/pages/JoinPage.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import styled from 'styled-components'; import { useNavigate } from 'react-router'; import qublogo from '@/assets/qublogo.svg'; import Input from '@/components/atoms/Input'; import PasswordGuideLines from '@/components/atoms/PasswordGuideLines'; +import { join } from '@/apis/auth.api'; export interface JoinProps { email: string; @@ -14,6 +16,7 @@ export interface JoinProps { function JoinPage() { const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); const { register, @@ -22,11 +25,24 @@ function JoinPage() { watch, } = useForm(); - const password = watch('password'); - - const onSubmit = (data: JoinProps) => { - console.log('가입 정보:', data); - navigate('/login'); + const password = watch('password'); // PasswordGuideLines에서 실시간 사용 + + const onSubmit = async (data: JoinProps) => { + setIsLoading(true); + try { + await join(data); // 회원가입 요청 + alert('회원가입이 완료되었습니다!'); + navigate('/login'); // 로그인 페이지로 이동 + } catch (error: any) { + console.error('회원가입 중 에러 발생:', error); + if (error.response?.data?.message) { + alert(error.response.data.message); // 서버에서 반환된 에러 메시지 표시 + } else { + alert('회원가입에 실패했습니다. 다시 시도해주세요.'); + } + } finally { + setIsLoading(false); + } }; return ( @@ -37,6 +53,7 @@ function JoinPage() {
+ {/* 이메일 */} {errors.email && {errors.email.message}} + {/* 닉네임 */} {errors.nickname && {errors.nickname.message}} + {/* 비밀번호 */} {errors.password && {errors.password.message}} + {/* 비밀번호 확인 */} - value === watch('password') || - '비밀번호가 일치하지 않습니다.', + value === password || '비밀번호가 일치하지 않습니다.', })} /> @@ -98,11 +117,15 @@ function JoinPage() { {errors.confirmPassword.message} )} + {/* 비밀번호 가이드라인 */} - 회원가입 + {/* 제출 버튼 */} + + {isLoading ? '처리 중...' : '회원가입'} + @@ -186,6 +209,11 @@ const SubmitButton = styled.button` &:hover { background-color: #e5e7eb; } + + &:disabled { + background-color: #e5e7eb; + cursor: not-allowed; + } `; const ErrorText = styled.p` From d3dd90a544ec760590fcbee1d00848c1d5ed171d Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:50:58 +0900 Subject: [PATCH 22/23] =?UTF-8?q?[Refactor]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage.tsx | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index dedbc83..2575c59 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -3,8 +3,10 @@ import { useNavigate } from 'react-router'; import qublogo from '@/assets/qublogo.svg'; import Input from '@/components/atoms/Input'; import { useForm } from 'react-hook-form'; +import { login } from '@/apis/auth.api'; +import { useState } from 'react'; -interface LoginFormData { +export interface LoginProps { email: string; password: string; } @@ -15,11 +17,22 @@ function LoginPage() { register, handleSubmit, formState: { errors }, - } = useForm(); - - const onSubmit = (data: LoginFormData) => { - console.log('로그인 정보:', data); - navigate('/'); + } = useForm(); + + const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 + + const onSubmit = async (data: LoginProps) => { + setIsLoading(true); + try { + await login(data); + alert('로그인에 성공했습니다!'); + navigate('/'); // 로그인 성공 시 메인 페이지로 이동 + } catch (error: any) { + console.error('로그인 실패:', error); + alert(error.message || '로그인에 실패했습니다. 다시 시도해주세요.'); + } finally { + setIsLoading(false); + } }; return ( @@ -48,7 +61,9 @@ function LoginPage() { {errors.password && {errors.password.message}} - 로그인 + + {isLoading ? '로그인 중...' : '로그인'} + @@ -129,6 +144,11 @@ const SubmitButton = styled.button` &:hover { background-color: #e5e7eb; } + + &:disabled { + background-color: #e5e7eb; + cursor: not-allowed; + } `; const BottomSection = styled.div` From f862a16ef2273511119721e6f7d28e0febb98471 Mon Sep 17 00:00:00 2001 From: leechan Date: Sat, 14 Dec 2024 19:51:32 +0900 Subject: [PATCH 23/23] =?UTF-8?q?[Feat]=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20slice=20=EA=B8=B0?= =?UTF-8?q?=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/store/slices/authSlice.ts | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/store/slices/authSlice.ts diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 0000000..cad3317 --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,208 @@ +// src/store/slices/authSlice.ts + +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { jwtDecode } from 'jwt-decode'; + +// 사용자 정보 인터페이스 +interface User { + email: string; + nickname: string; + role: 'user' | 'admin'; +} + +// 토큰 인터페이스 +interface Tokens { + accessToken: string; +} + +// 인증 상태 인터페이스 +export interface AuthState { + isLoggedIn: boolean; + user: User | null; + tokens: Tokens | null; + error: string | null; +} + +// 로그인 요청 페이로드 인터페이스 +interface LoginPayload { + email: string; + password: string; +} + +// 회원가입 요청 페이로드 인터페이스 +interface SignupPayload { + email: string; + password: string; + nickname: string; +} + +// JWT 디코딩 인터페이스 +interface DecodedToken { + email: string; + nickname: string; + roles: string[]; + exp: number; +} + +// LocalStorage 관련 유틸리티 함수 +export const getToken = (): string | null => + localStorage.getItem('accessToken'); +export const setToken = (token: string): void => + localStorage.setItem('accessToken', token); +export const removeToken = (): void => localStorage.removeItem('accessToken'); + +export const getNickname = (): string | null => + localStorage.getItem('nickname'); +export const setNickname = (nickname: string): void => + localStorage.setItem('nickname', nickname); +export const removeNickname = (): void => localStorage.removeItem('nickname'); + +// 비동기 Thunk 액션 생성 + +// 회원가입 Thunk +export const signup = createAsyncThunk< + { success: boolean }, + SignupPayload, + { rejectValue: string } +>('auth/signup', async (data, thunkAPI) => { + try { + console.log('회원가입 요청 데이터:', data); + const response = await axios.post('/api/signup', data); + console.log('회원가입 성공 응답:', response.data); + return response.data; + } catch (error: any) { + console.error( + '회원가입 실패:', + error.response?.data?.message || error.message + ); + return thunkAPI.rejectWithValue( + error.response?.data?.message || '회원가입 실패' + ); + } +}); + +// 로그인 Thunk +export const login = createAsyncThunk< + { user: User; tokens: Tokens }, + LoginPayload, + { rejectValue: string } +>('auth/login', async (credentials, thunkAPI) => { + try { + console.log('로그인 요청 데이터:', credentials); + const response = await axios.post('/api/login', credentials); + const { token } = response.data; + + // JWT 디코딩하여 사용자 정보 추출 + const decoded: DecodedToken = jwtDecode(token); + const role = decoded.roles.includes('admin') ? 'admin' : 'user'; + + const userData: User = { + email: decoded.email, + nickname: decoded.nickname, + role, + }; + + const tokensData: Tokens = { + accessToken: token, + }; + + // 로컬스토리지에 토큰 저장 + setToken(tokensData.accessToken); + setNickname(userData.nickname); + + console.log('로그인 성공:', { user: userData, tokens: tokensData }); + + return { user: userData, tokens: tokensData }; + } catch (error: any) { + console.error( + '로그인 실패:', + error.response?.data?.message || error.message + ); + return thunkAPI.rejectWithValue( + error.response?.data?.message || '로그인 실패' + ); + } +}); + +// 초기 상태 설정 +const initialState: AuthState = { + isLoggedIn: !!getToken(), + user: getNickname() + ? { + email: '', + nickname: getNickname() || '', + role: 'user', + } + : null, + tokens: getToken() + ? { + accessToken: getToken()!, + } + : null, + error: null, +}; + +// 슬라이스 생성 +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + // 로그아웃 액션 + logout: (state) => { + console.log('로그아웃 처리'); + state.isLoggedIn = false; + state.user = null; + state.tokens = null; + state.error = null; + removeToken(); + removeNickname(); + }, + // 에러 설정 액션 + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + // 에러 클리어 액션 + clearError: (state) => { + state.error = null; + }, + }, + extraReducers: (builder) => { + // 회원가입 + builder.addCase(signup.pending, (state) => { + console.log('회원가입 진행 중...'); + state.error = null; + }); + builder.addCase(signup.fulfilled, (state) => { + console.log('회원가입 성공'); + state.error = null; + }); + builder.addCase(signup.rejected, (state, action) => { + console.log('회원가입 실패:', action.payload || '알 수 없는 오류'); + state.error = action.payload || '회원가입 실패'; + }); + + // 로그인 + builder.addCase(login.pending, (state) => { + console.log('로그인 진행 중...'); + state.error = null; + }); + builder.addCase( + login.fulfilled, + (state, action: PayloadAction<{ user: User; tokens: Tokens }>) => { + console.log('로그인 성공'); + state.isLoggedIn = true; + state.user = action.payload.user; + state.tokens = action.payload.tokens; + } + ); + builder.addCase(login.rejected, (state, action) => { + console.log('로그인 실패:', action.payload || '알 수 없는 오류'); + state.error = action.payload || '로그인 실패'; + }); + }, +}); + +// 액션 및 리듀서 내보내기 +export const { logout, setError, clearError } = authSlice.actions; +export default authSlice.reducer;