From ab50d055b2341b552c11f8b638bceba500f523ce Mon Sep 17 00:00:00 2001 From: HyungWookChoi Date: Fri, 15 Dec 2023 13:43:30 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20login=20&=20sign-up=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 - .../login/_components/choose-profile.tsx | 82 --------- app/(route)/login/page.tsx | 23 +-- app/(route)/oauth/callback/kakao/page.tsx | 22 +-- app/(route)/sign-up/page.tsx | 163 ++++++++++++++++++ app/(route)/sign-up/queries.ts | 36 ++++ .../providers/QueryClientProvider.tsx | 1 + app/_constants/kakao.ts | 2 +- app/_constants/route.ts | 1 + app/_service/auth/auth.types.ts | 12 ++ app/_service/auth/index.ts | 15 +- app/_service/core/api.types.ts | 6 +- app/_utils/token.ts | 27 +++ package.json | 3 +- pnpm-lock.yaml | 12 ++ 15 files changed, 282 insertions(+), 125 deletions(-) delete mode 100644 .env delete mode 100644 app/(route)/login/_components/choose-profile.tsx create mode 100644 app/(route)/sign-up/page.tsx create mode 100644 app/(route)/sign-up/queries.ts create mode 100644 app/_utils/token.ts diff --git a/.env b/.env deleted file mode 100644 index e910de5..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_SERVER_URL=http://101.101.216.221:3000 -NEXT_PUBLIC_APP_ENV=development \ No newline at end of file diff --git a/app/(route)/login/_components/choose-profile.tsx b/app/(route)/login/_components/choose-profile.tsx deleted file mode 100644 index c25a248..0000000 --- a/app/(route)/login/_components/choose-profile.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' - -import Image from 'next/image' -import Link from 'next/link' - -import { useState } from 'react' - -import { ROUTE } from '@/app/_constants/route' - -type Profile = 'red' | 'yellow' | 'green' | 'blue' | 'beige' | 'pink' - -export default function ChooseProfile() { - const [selectedProfile, setSelectedProfile] = useState('red') - - const profiles = { - default: { - red: '/images/profile-red-1.png', - yellow: '/images/profile-yellow-1.png', - green: '/images/profile-green-1.png', - blue: '/images/profile-blue-1.png', - beige: '/images/profile-beige-1.png', - pink: '/images/profile-pink-1.png', - }, - selected: { - red: '/images/profile-red-1-selected.png', - yellow: '/images/profile-yellow-1-selected.png', - green: '/images/profile-green-1-selected.png', - blue: '/images/profile-blue-1-selected.png', - beige: '/images/profile-beige-1-selected.png', - pink: '/images/profile-pink-1-selected.png', - }, - } - - return ( -
-

프로필 생성

-
-
- -
- - 0/4 -
-
-
- {Object.keys(profiles.default).map((profile) => ( - - ))} -
- - 달 탐사 시작하기 - -
-
- ) -} diff --git a/app/(route)/login/page.tsx b/app/(route)/login/page.tsx index fdf2272..854bc5d 100644 --- a/app/(route)/login/page.tsx +++ b/app/(route)/login/page.tsx @@ -1,34 +1,17 @@ -'use client' - import Image from 'next/image' -import { useState } from 'react' - import { KAKAO } from '@/app/_constants/kakao' -import ChooseProfile from './_components/choose-profile' - -type Step = 'login' | 'profile' - export default function Home() { - const [step, setStep] = useState('login') - - if (step === 'profile') { - return - } - return (
도달 로고 1:1 매칭 목표 달성 서비스
- +
) } diff --git a/app/(route)/oauth/callback/kakao/page.tsx b/app/(route)/oauth/callback/kakao/page.tsx index 801d215..70d7368 100644 --- a/app/(route)/oauth/callback/kakao/page.tsx +++ b/app/(route)/oauth/callback/kakao/page.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { ROUTE } from '@/app/_constants/route' import { getTokenByAuthorizationCode } from '@/app/_service/auth' +import { setKakaoAccessToken } from '@/app/_utils/token' import { QUERY_KEY } from './queries' @@ -15,31 +16,22 @@ export default function KaKaoCallbackPage() { const router = useRouter() const searchParams = useSearchParams() - const code = searchParams.get('code') + const code = searchParams.get('code') as string const { isSuccess, data } = useQuery({ - queryKey: QUERY_KEY.KAKAO_TOKEN(code as string), + queryKey: QUERY_KEY.KAKAO_TOKEN(code), queryFn: async () => await getTokenByAuthorizationCode({ - code: code as string, + code, }), - enabled: code !== null, }) useEffect(() => { if (isSuccess) { - router.push(ROUTE.HOME) + setKakaoAccessToken(data.data.access_token) + router.push(ROUTE.SIGN_UP) } }, [isSuccess]) - if (!isSuccess) { - return null - } - - return ( -
- {code} - {data.data.access_token} -
- ) + return null } diff --git a/app/(route)/sign-up/page.tsx b/app/(route)/sign-up/page.tsx new file mode 100644 index 0000000..067bd2e --- /dev/null +++ b/app/(route)/sign-up/page.tsx @@ -0,0 +1,163 @@ +'use client' + +import Image from 'next/image' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +import { useEffect, useState } from 'react' + +import { useForm, type SubmitHandler } from 'react-hook-form' + +import { ROUTE } from '@/app/_constants/route' +import { type Champion } from '@/app/_service/auth/auth.types' +import { getKakaoAccessToken, removeKakaoAccessToken, setAccessToken } from '@/app/_utils/token' + +import { useLoginQuery, useSignUpMutation } from './queries' + +const PROFILES = { + DEFAULT: { + RED: '/images/profile-red-1.png', + YELLOW: '/images/profile-yellow-1.png', + GREEN: '/images/profile-green-1.png', + BLUE: '/images/profile-blue-1.png', + BEIGE: '/images/profile-beige-1.png', + PINK: '/images/profile-pink-1.png', + }, + SELECTED: { + RED: '/images/profile-red-1-selected.png', + YELLOW: '/images/profile-yellow-1-selected.png', + GREEN: '/images/profile-green-1-selected.png', + BLUE: '/images/profile-blue-1-selected.png', + BEIGE: '/images/profile-beige-1-selected.png', + PINK: '/images/profile-pink-1-selected.png', + }, +} + +interface Inputs { + nickname: string +} + +export default function Register() { + const router = useRouter() + + const [selectedChampion, setSelectedChampion] = useState('RED') + + const { register, handleSubmit, watch } = useForm({ + mode: 'onChange', + defaultValues: { + nickname: '', + }, + }) + + const loginQuery = useLoginQuery({ accessToken: getKakaoAccessToken() }) + const signUpMutation = useSignUpMutation() + + const onSubmit: SubmitHandler = (data) => { + signUpMutation.mutate( + { + accessToken: getKakaoAccessToken(), + nickname: data.nickname, + champion: selectedChampion, + }, + { + onSuccess: async () => { + const { data } = await loginQuery.refetch() + + removeKakaoAccessToken() + setAccessToken(data?.data.token) + + router.push(ROUTE.HOME) + }, + } + ) + } + const errorMessage = loginQuery.error?.response?.data.message + const invalidKakaoToken = errorMessage === '카카오 access 토큰이 유효하지 않습니다.' + const isNeedToRegister = + errorMessage === 'provider_id가 DB에 없어 회원가입 필요, JWT토큰 생성 불가. 요청에 실패했습니다.' + + useEffect(() => { + if (invalidKakaoToken) { + alert('카카오 로그인이 만료되었습니다. 다시 로그인해주세요.') + + router.push(ROUTE.LOGIN) + } + }, [loginQuery.isError, invalidKakaoToken]) + + useEffect(() => { + if (loginQuery.isSuccess) { + setAccessToken(loginQuery.data.data.token) + removeKakaoAccessToken() + + router.push(ROUTE.HOME) + } + }, [loginQuery.isSuccess]) + + if (invalidKakaoToken || !isNeedToRegister) { + return null + } + + return ( +
+

프로필 생성

+
+
+
+ + +
+ { + e.target.value = e.target.value.slice(0, 4) + }, + })} + /> + {watch('nickname').length >= 4 ? 4 : watch('nickname').length ?? 0} / 4 +
+
+
+ {Object.keys(PROFILES.DEFAULT).map((champion) => ( + + ))} +
+ +
+
+
+ ) +} diff --git a/app/(route)/sign-up/queries.ts b/app/(route)/sign-up/queries.ts new file mode 100644 index 0000000..5515dac --- /dev/null +++ b/app/(route)/sign-up/queries.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { type AxiosError } from 'axios' + +import { login, signUp } from '@/app/_service/auth' +import { type Champion, type LoginResponse } from '@/app/_service/auth/auth.types' +import { type ServerError, type ServerResponse } from '@/app/_service/core/api.types' + +const QUERY_KEY = { + LOGIN: (accessToken: string) => ['login', accessToken], +} + +export const useLoginQuery = ({ accessToken }: { accessToken: string }) => { + return useQuery, AxiosError>({ + queryKey: QUERY_KEY.LOGIN(accessToken), + queryFn: () => { + return login({ accessToken }) + }, + enabled: Boolean(accessToken), + }) +} + +export const useSignUpMutation = () => { + return useMutation({ + mutationFn: ({ + accessToken, + nickname, + champion, + }: { + accessToken: string + nickname: string + champion: Champion + }) => { + return signUp({ accessToken, nickname, champion }) + }, + }) +} diff --git a/app/_components/providers/QueryClientProvider.tsx b/app/_components/providers/QueryClientProvider.tsx index 4db192d..2ca2c7f 100644 --- a/app/_components/providers/QueryClientProvider.tsx +++ b/app/_components/providers/QueryClientProvider.tsx @@ -14,6 +14,7 @@ export default function Providers({ children }: PropsWithChildren) { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, + retry: false, }, }, }) diff --git a/app/_constants/kakao.ts b/app/_constants/kakao.ts index 3c6ca19..d2c54c7 100644 --- a/app/_constants/kakao.ts +++ b/app/_constants/kakao.ts @@ -1,5 +1,5 @@ export const KAKAO = { // REST_API_KEY: '8cb6bb4a47d00e99bb5a038f9b6467fe', REST_API_KEY: 'df0059498e461a8b0c76d9b4d537dbaa', - REDIRECT_URI: 'http://localhost:3000/oauth/callback/kakao', + REDIRECT_URI: 'http://localhost:4000/oauth/callback/kakao', } diff --git a/app/_constants/route.ts b/app/_constants/route.ts index 8f006c6..a561c11 100644 --- a/app/_constants/route.ts +++ b/app/_constants/route.ts @@ -1,6 +1,7 @@ export const ROUTE = { HOME: '/', LOGIN: '/login', + SIGN_UP: '/sign-up', ALARM: '/alarm', OAUTH: { KAKAO: { diff --git a/app/_service/auth/auth.types.ts b/app/_service/auth/auth.types.ts index ac84b6c..89cab79 100644 --- a/app/_service/auth/auth.types.ts +++ b/app/_service/auth/auth.types.ts @@ -9,3 +9,15 @@ export interface GetTokenFromKakaoResponse { refresh_token_expires_in: number token_type: string } + +export interface LoginResponse { + token: string +} + +export type Champion = 'RED' | 'YELLOW' | 'GREEN' | 'BLUE' | 'BEIGE' | 'PINK' + +export interface SignUpParams { + accessToken: string + nickname: string + champion: Champion +} diff --git a/app/_service/auth/index.ts b/app/_service/auth/index.ts index 691cf9f..14803e0 100644 --- a/app/_service/auth/index.ts +++ b/app/_service/auth/index.ts @@ -2,7 +2,12 @@ import axios from 'axios' import api from '../core/api' -import { type GetTokenFromKakaoParams, type GetTokenFromKakaoResponse } from './auth.types' +import { + type LoginResponse, + type GetTokenFromKakaoParams, + type GetTokenFromKakaoResponse, + type SignUpParams, +} from './auth.types' export const getTokenByAuthorizationCode = async ({ code }: GetTokenFromKakaoParams) => { return await axios.get(`/oauth/callback/kakao/api?code=${code}`) @@ -11,3 +16,11 @@ export const getTokenByAuthorizationCode = async ({ code }: GetTokenFromKakaoPar export const getUsers = () => { return api.get('/users') } + +export const login = ({ accessToken }: { accessToken: string }) => { + return api.post('/users/signIn', { accessToken }) +} + +export const signUp = ({ accessToken, nickname, champion }: SignUpParams) => { + return api.post('/users/signUp', { accessToken, nickname, champion }) +} diff --git a/app/_service/core/api.types.ts b/app/_service/core/api.types.ts index 1a13784..650cb38 100644 --- a/app/_service/core/api.types.ts +++ b/app/_service/core/api.types.ts @@ -5,7 +5,7 @@ export interface ServerResponse { } export interface ServerError { - responseMessage: string - data: boolean - statusCode: number + success: boolean + message: string + err: Error } diff --git a/app/_utils/token.ts b/app/_utils/token.ts new file mode 100644 index 0000000..ffaa0a2 --- /dev/null +++ b/app/_utils/token.ts @@ -0,0 +1,27 @@ +const KAKAO_ACCESS_TOKEN = 'KAT' + +const DODAL_TOKEN = 'DODAL_TOKEN' + +export const getKakaoAccessToken = () => { + return sessionStorage.getItem(KAKAO_ACCESS_TOKEN) +} + +export const setKakaoAccessToken = (token: string) => { + sessionStorage.setItem(KAKAO_ACCESS_TOKEN, token) +} + +export const removeKakaoAccessToken = () => { + sessionStorage.removeItem(KAKAO_ACCESS_TOKEN) +} + +export const getAccessToken = () => { + return localStorage.getItem(DODAL_TOKEN) +} + +export const setAccessToken = (token: string) => { + localStorage.setItem(DODAL_TOKEN, token) +} + +export const removeAccessToken = () => { + localStorage.removeItem(DODAL_TOKEN) +} diff --git a/package.json b/package.json index c98e673..2c1a9df 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 4000", "build": "next build", "start": "next start", "lint": "next lint", @@ -25,6 +25,7 @@ "next": "14.0.4", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.49.2", "tailwind-merge": "^2.1.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ee9ee..48c2ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: react-dom: specifier: ^18 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.49.2 + version: 7.49.2(react@18.2.0) tailwind-merge: specifier: ^2.1.0 version: 2.1.0 @@ -3646,6 +3649,15 @@ packages: scheduler: 0.23.0 dev: false + /react-hook-form@7.49.2(react@18.2.0): + resolution: {integrity: sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==} + engines: {node: '>=18', pnpm: '8'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true