diff --git a/.eslintrc b/.eslintrc index 53e94de..dedb583 100644 --- a/.eslintrc +++ b/.eslintrc @@ -59,6 +59,7 @@ "@typescript-eslint/method-signature-style": [ // 특정 메서드 syntax 사용해서 시행 "error" ], + "@typescript-eslint/no-explicit-any": "off", "unused-imports/no-unused-imports": [ // 사용하지 않는 es6 모듈 import를 찾아 제거 "error" ], @@ -66,6 +67,7 @@ "error", "first" ], + "no-restricted-globals": "off", "react/jsx-curly-newline": "off", // jsx curly 내에서 일관된 줄 바꿈 적용 "react/jsx-one-expression-per-line": "off", // JSX에서 한 줄에 하나의 표현식으로 제한 "react/jsx-props-no-spreading": "off", // JSX props 확산 방지 diff --git a/images/icon/kakao.svg b/images/icon/kakao.svg deleted file mode 100644 index e69de29..0000000 diff --git a/index.html b/index.html index fa679ed..af56c69 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,13 @@ - - - - - - 하루한냥 - - -
- - + + + + + + 하루한냥 + + +
+ + diff --git a/package-lock.json b/package-lock.json index 2a3e8b5..ef18755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@types/qs": "^6.9.7", "axios": "^1.4.0", + "qs": "^6.11.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.0", @@ -2485,6 +2487,11 @@ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + }, "node_modules/@types/react": { "version": "18.2.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.12.tgz", @@ -3321,7 +3328,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -4990,7 +4996,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -5199,7 +5204,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -5211,7 +5215,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -7905,7 +7908,6 @@ "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8409,6 +8411,20 @@ } ] }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -8769,7 +8785,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", diff --git a/package.json b/package.json index f832cd5..9509289 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@types/qs": "^6.9.7", "axios": "^1.4.0", + "qs": "^6.11.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.0", diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..90253d9 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,65 @@ +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import qs from 'qs'; +import { API_TIMEOUT, BASE_URL } from '@lib/const/config'; +import { ACCESS_TOKEN } from '@lib/const/localstorage'; + +type APIResponse = { + success: boolean; + msg: string; + data?: T; +}; + +export const client = axios.create({ + baseURL: BASE_URL, + timeout: API_TIMEOUT, + timeoutErrorMessage: '서버 요청 시간 초과', + paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'comma' }), +}); + +const handleHeadersWithAccessToken = (config: AxiosRequestConfig): InternalAxiosRequestConfig => { + const accessToken = localStorage.getItem(ACCESS_TOKEN) || ''; + config.headers = { + ...config.headers, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }; + + return config as InternalAxiosRequestConfig; +}; + +client.interceptors.request.use(handleHeadersWithAccessToken); +client.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => Promise.reject(error), +); + +export const http = { + get: function get(url: string, config?: AxiosRequestConfig) { + return client.get>(url, config).then((res) => res.data); + }, + post: function post(url: string, data?: Request, config?: AxiosRequestConfig) { + return client.post>(url, data, config).then((res) => res.data); + }, + put: function put(url: string, data?: Request, config?: AxiosRequestConfig) { + return client.put>(url, data, config).then((res) => res.data); + }, + patch: function patch(url: string, data?: Request, config?: AxiosRequestConfig) { + return client.patch>(url, data, config).then((res) => res.data); + }, + delete: function del(url: string, config?: AxiosRequestConfig) { + return client.delete>(url, config).then((res) => res.data); + }, +}; + +export const handleAxiosError = (error: any) => { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + console.error('Axios Error:', axiosError); + return axiosError.response?.data as APIResponse; + } + console.log('Unknown Error: ', error); + return { + success: false, + msg: '알 수 없는 오류가 발생했습니다.', + }; +}; diff --git a/src/lib/const/config.ts b/src/lib/const/config.ts new file mode 100644 index 0000000..fb482da --- /dev/null +++ b/src/lib/const/config.ts @@ -0,0 +1,6 @@ +export const BASE_URL = 'http://133.186.144.153:3001/api'; +export const API_TIMEOUT = 3000; + +const KAKAO_CLIENT_ID = 'a3d08c94732b0c85334d04b474f49873'; +const KAKAO_REDIRECT_URL = 'http://localhost:5173/oauth/kakao'; +export const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URL}&response_type=code`; diff --git a/src/lib/const/localstorage.ts b/src/lib/const/localstorage.ts new file mode 100644 index 0000000..79c65b1 --- /dev/null +++ b/src/lib/const/localstorage.ts @@ -0,0 +1,2 @@ +export const ACCESS_TOKEN = 'accessToken'; +export const USER = 'user'; diff --git a/src/lib/const/path.ts b/src/lib/const/path.ts index a2946bb..c224a3a 100644 --- a/src/lib/const/path.ts +++ b/src/lib/const/path.ts @@ -6,4 +6,5 @@ export const PATH = { TIMELINE: '/timeline', REPORT: '/report', SETTING: '/setting', + OAUTH_KAKAO: '/oauth/kakao', }; diff --git a/src/lib/types/user.ts b/src/lib/types/user.type.ts similarity index 88% rename from src/lib/types/user.ts rename to src/lib/types/user.type.ts index 5cdc05e..4b443a8 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.type.ts @@ -1,4 +1,4 @@ -export type User = { +export type UserType = { email: string; password: string; passwordCheck: string; diff --git a/src/main.tsx b/src/main.tsx index 16f0797..bfaf0ee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,8 +2,4 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - , -); +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/src/pages/AuthKakaoPage.tsx b/src/pages/AuthKakaoPage.tsx new file mode 100644 index 0000000..74b33f3 --- /dev/null +++ b/src/pages/AuthKakaoPage.tsx @@ -0,0 +1,40 @@ +import { useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { PATH } from '@lib/const/path'; +import { ACCESS_TOKEN, USER } from '@lib/const/localstorage'; +import { handleAxiosError, http } from '../api/http'; + +export default function AuthKakaoPage() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const code = params.get('code'); + + const handleSigninKakao = useCallback(async () => { + try { + const responseSignIn = await http.post<{ token: string; user: { name: string } }>('/user/oauth/kakao', { code }); + const isSuccess = responseSignIn.success; + if (isSuccess && responseSignIn.data) { + const accessToken = responseSignIn.data.token; + const userProfile = { + name: responseSignIn.data.user.name, + }; + localStorage.setItem(ACCESS_TOKEN, JSON.stringify(accessToken)); + localStorage.setItem(USER, JSON.stringify(userProfile)); + navigate(PATH.CALENDAR); + } + } catch (e) { + const error = handleAxiosError(e); + alert(error.msg); + navigate(PATH.HOME); + } + }, [code, navigate]); + + useEffect(() => { + if (code) { + handleSigninKakao(); + } + }, [code, handleSigninKakao]); + + return
; +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 581da5f..9356d02 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ import styleTokenCss from '@ui/styles/styleToken.css'; import { useNavigate } from 'react-router'; import { PATH } from '@lib/const/path'; import SignButton from '@ui/components/SignButton'; +import { KAKAO_AUTH_URL } from '@lib/const/config'; export default function HomePage() { const navigate = useNavigate(); @@ -12,6 +13,10 @@ export default function HomePage() { navigate(PATH.SIGN_IN); }; + const handlePageAuthKakao = () => { + location.href = KAKAO_AUTH_URL; + }; + return ( @@ -22,15 +27,16 @@ export default function HomePage() { diff --git a/src/pages/SettingPage.tsx b/src/pages/SettingPage.tsx index 2d3b3e5..c1f9563 100644 --- a/src/pages/SettingPage.tsx +++ b/src/pages/SettingPage.tsx @@ -1,13 +1,56 @@ -import Header from '@ui/components/layout/Header'; import Body from '@ui/components/layout/Body'; import Menu from '@ui/components/layout/Menu'; +import SignButton from '@ui/components/SignButton'; +import styleTokenCss from '@ui/styles/styleToken.css'; +import styled from '@emotion/styled'; +import { useNavigate } from 'react-router'; +import { PATH } from '@lib/const/path'; export default function SettingPage() { + const navigate = useNavigate(); + + const handleClickLogout = () => { + const isLogout = confirm('로그아웃 하시겠습니까?'); + if (isLogout) { + localStorage.clear(); + navigate(PATH.HOME); + } + }; + return ( <> -
- setting page + 마이 페이지 + + + ); } + +const Title = styled.h2` + background-color: ${styleTokenCss.color.background}; + color: ${styleTokenCss.color.gray2}; + font-size: 24px; + font-weight: 600; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + width: 100%; + height: 100px; + padding: 35px; +`; + +const Container = styled(Body)` + padding: 35px; + justify-content: flex-start; + align-items: center; + overflow-y: auto; +`; diff --git a/src/pages/SigninPage.tsx b/src/pages/SigninPage.tsx index 1cfd5db..8345203 100644 --- a/src/pages/SigninPage.tsx +++ b/src/pages/SigninPage.tsx @@ -1,18 +1,20 @@ import Body from '@ui/components/layout/Body'; import styled from '@emotion/styled'; import styleTokenCss from '@ui/styles/styleToken.css'; -import React, { ChangeEvent, useMemo, useState } from 'react'; +import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { PATH } from '@lib/const/path'; -import { User, UserValidation } from '@lib/types/user'; +import { UserType, UserValidation } from '@lib/types/user.type'; import NavigationHeader from '@ui/components/layout/NavigationHeader'; import getValidationUser from '@lib/utils/getValidationUser'; import SignButton from '@ui/components/SignButton'; import InputBox from '@ui/components/InputBox'; +import { ACCESS_TOKEN, USER } from '@lib/const/localstorage'; +import { handleAxiosError, http } from '../api/http'; export default function SigninPage() { const navigate = useNavigate(); - const [user, setUser] = useState>({ + const [user, setUser] = useState>({ email: '', password: '', }); @@ -41,19 +43,42 @@ export default function SigninPage() { [userValidation.email, userValidation.password], ); + const handleClickSignIn = async () => { + if (isDisabledSubmit) { + return; + } + + try { + const responseSignIn = await http.post<{ user: { user_token: string; name: string } }>('/user/signin', { + email: user.email, + password: user.password, + }); + if (responseSignIn.data) { + const accessToken = responseSignIn.data.user.user_token; + const userProfile = { + name: responseSignIn.data.user.name, + }; + localStorage.setItem(ACCESS_TOKEN, JSON.stringify(accessToken)); + localStorage.setItem(USER, JSON.stringify(userProfile)); + navigate(PATH.CALENDAR); + } + } catch (e) { + const error = handleAxiosError(e); + alert(error.msg); + } + }; + const handlePageSignup = (e: React.MouseEvent) => { e.preventDefault(); navigate(PATH.SIGN_UP); }; - const handleClickSignin = () => { - if (!isDisabledSubmit) { - alert('로그인에 성공했습니다.'); + useEffect(() => { + const isAccessToken = localStorage.getItem('ACCESS_TOKEN'); + if (isAccessToken) { navigate(PATH.CALENDAR); - } else { - alert('이메일, 비밀번호를 확인해 주세요.'); } - }; + }, [navigate]); return ( <> @@ -82,7 +107,7 @@ export default function SigninPage() { ({ + const [user, setUser] = useState({ email: '', password: '', passwordCheck: '', @@ -64,18 +66,54 @@ export default function SignupPage() { [userValidation.email, userValidation.password, userValidation.passwordCheck, userValidation.name, isChecked], ); - const handlePageSignUp = () => { - alert('회원가입에 성공했습니다!'); - navigate(PATH.CALENDAR); + const handleClickSignUp = async () => { + try { + const responseSignUp = await http.post<{ user: { user_token: string; name: string } }>('/user/signup', { + email: user.email, + password: user.password, + name: user.name, + }); + if (responseSignUp.data) { + const accessToken = responseSignUp.data.user.user_token; + const userProfile = { + name: responseSignUp.data.user.name, + }; + localStorage.setItem(ACCESS_TOKEN, JSON.stringify(accessToken)); + localStorage.setItem(USER, JSON.stringify(userProfile)); + navigate(PATH.CALENDAR); + } + } catch (e) { + const error = handleAxiosError(e); + alert(error.msg); + } }; const isError = { - email: user.email.length > 0 && !userValidation.email, - password: user.password.length > 0 && user.password.length < 9 && !userValidation.password, - passwordCheck: user.passwordCheck.length > 0 && !userValidation.passwordCheck, - name: user.name.length > 0 && user.name.length < 2, + email: { + error: user.email.length > 0 && !userValidation.email, + message: '이메일 형식이 올바르지 않습니다.', + }, + password: { + error: user.password.length > 0 && user.password.length < 9 && !userValidation.password, + message: '8글자 이상 입력해 주세요.', + }, + passwordCheck: { + error: user.passwordCheck.length > 0 && !userValidation.passwordCheck, + message: '비밀번호가 일치하지 않습니다.', + }, + name: { + error: user.name.length > 0 && user.name.length < 2, + message: '닉네임 형식이 올바르지 않습니다.', + }, }; + useEffect(() => { + const isAccessToken = localStorage.getItem('ACCESS_TOKEN'); + if (isAccessToken) { + navigate(PATH.CALENDAR); + } + }, [navigate]); + return ( <> @@ -90,7 +128,7 @@ export default function SignupPage() { placeholder="이메일을 입력해 주세요." onChange={handleChangeUser} /> - {isError.email && '이메일 형식이 올바르지 않습니다.'} + {isError.email.error && isError.email.message} @@ -101,7 +139,7 @@ export default function SignupPage() { placeholder="비밀번호를 입력해 주세요." onChange={handleChangeUser} /> - {isError.password && '8글자 이상 입력해 주세요.'} + {isError.password.error && isError.password.message} @@ -112,7 +150,7 @@ export default function SignupPage() { placeholder="비밀번호를 다시 입력해 주세요." onChange={handleChangeUser} /> - {isError.passwordCheck && '비밀번호가 일치하지 않습니다.'} + {isError.passwordCheck.error && isError.passwordCheck.message} @@ -123,7 +161,7 @@ export default function SignupPage() { placeholder="닉네임을 입력해 주세요." onChange={handleChangeUser} /> - {isError.name && '닉네임 형식이 올바르지 않습니다.'} + {isError.name.error && isError.name.message} diff --git a/src/routes.tsx b/src/routes.tsx index 5de35f8..f05e7b7 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -7,6 +7,7 @@ import CalendarPage from './pages/CalendarPage'; import TimelinePage from './pages/TimelinePage'; import ReportPage from './pages/ReportPage'; import SettingPage from './pages/SettingPage'; +import AuthKakaoPage from './pages/AuthKakaoPage'; const routes = [ { @@ -19,6 +20,7 @@ const routes = [ { path: PATH.TIMELINE, element: }, { path: PATH.REPORT, element: }, { path: PATH.SETTING, element: }, + { path: PATH.OAUTH_KAKAO, element: }, ], }, ];