diff --git a/src/api/login/oauth.tsx b/src/api/login/oauth.tsx new file mode 100644 index 00000000..0f436b73 --- /dev/null +++ b/src/api/login/oauth.tsx @@ -0,0 +1,15 @@ +import { Provider } from '@/types/login'; +import client from '../client'; + +export const getOauthAccessToken = async ( + provider: Provider, + code: string, + state: string +) => { + const response = await client.post(`/api/v1/auth/token/${provider}`, { + code: code, + state: state + }); + const { accessToken } = response.data; + return accessToken; +}; diff --git a/src/api/login/user.tsx b/src/api/login/user.tsx index 8ca3476d..b8788c4e 100644 --- a/src/api/login/user.tsx +++ b/src/api/login/user.tsx @@ -1,9 +1,10 @@ import { RegisterDataType } from '@/types/user'; import client, { authClient } from '../client'; import { getRefreshToken, setTokens } from '@/utils/storage'; +import { Provider } from '@/types/login'; // 로그인 -export const login = async (loginType: string, accessToken: string) => { +export const login = async (loginType: Provider, accessToken: string) => { return await client.post(`/api/v1/auth/login/${loginType}`, { accessToken: accessToken }); diff --git a/src/app/login/auth/page.tsx b/src/app/login/auth/page.tsx index d8c715b0..2c44e80e 100644 --- a/src/app/login/auth/page.tsx +++ b/src/app/login/auth/page.tsx @@ -1,9 +1,16 @@ 'use client'; +import { getOauthAccessToken } from '@/api/login/oauth'; import { login } from '@/api/login/user'; import Loader from '@/components/common/Loader'; import { signupState } from '@/recoil/signupStore'; -import { clearLetterUrl, setOnboarding, setTokens } from '@/utils/storage'; +import { Provider } from '@/types/login'; +import { + clearLetterUrl, + setOnboarding, + setRecentLogin, + setTokens +} from '@/utils/storage'; import axios from 'axios'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -13,12 +20,10 @@ import styled from 'styled-components'; const Auth = () => { const [registerToken, setRegisterToken] = useRecoilState(signupState); const router = useRouter(); - const REST_API_KEY = process.env.NEXT_PUBLIC_REST_API_KEY; const [absoluteUrl, setAbsoluteUrl] = useState(''); const [storeUrl, setstoreUrl] = useState(''); const [type, setType] = useState(''); const [oauthAccessToken, setOauthAccessToken] = useState(''); - const [provider, setProvider] = useState(''); useEffect(() => { if (typeof window !== 'undefined') { @@ -39,6 +44,7 @@ const Auth = () => { const AUTHORIZATION_CODE = new URL(window.location.href).searchParams.get( 'code' ); + const STATE = new URL(window.location.href).searchParams.get('state'); const TYPE = new URL(window.location.href).searchParams.get('type'); @@ -50,13 +56,12 @@ const Auth = () => { //type에 따라 다른 토큰 url 지정 switch (TYPE) { case 'kakao': - setProvider('KAKAO'); try { const response = await axios.post( 'https://kauth.kakao.com/oauth/token', new URLSearchParams({ grant_type: 'authorization_code', - client_id: REST_API_KEY, + client_id: process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY, redirect_uri: absoluteUrl, code: AUTHORIZATION_CODE }), @@ -73,7 +78,6 @@ const Auth = () => { break; case 'google': - setProvider('GOOGLE'); try { const body = new URLSearchParams({ grant_type: 'authorization_code', @@ -100,6 +104,18 @@ const Auth = () => { } break; case 'naver': + try { + const response = await getOauthAccessToken( + 'NAVER' as Provider, + AUTHORIZATION_CODE, + STATE + ); + setOauthAccessToken(response); + } catch (error) { + console.error('Unsupported OAuth type:', type); + clearLetterUrl(); + return; + } break; default: console.error('Unknown OAuth type:', TYPE); @@ -112,12 +128,14 @@ const Auth = () => { useEffect(() => { try { if (oauthAccessToken) { - login(provider, oauthAccessToken) + login(type?.toUpperCase() as Provider, oauthAccessToken) .then((res) => { console.log('accessToken', res.data.accessToken); setTokens(res.data.accessToken, res.data.refreshToken); /* 온보딩 여부 저장 */ setOnboarding(res.data.isProcessedOnboarding); + /* 최근 로그인 정보 저장 */ + setRecentLogin(type); if (storeUrl) { router.push(`/verify/letter?url=${storeUrl}`); clearLetterUrl(); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1815cdb0..760717e3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,45 +1,17 @@ 'use client'; -import SocialKakao from '@/components/signup/SocialKakao'; -import SocialGoogle from '@/components/signup/SocialGoogle'; +import Loader, { LoaderContainer } from '@/components/common/Loader'; +import OauthButton from '@/components/signup/OauthButton'; +import { OAUTH } from '@/constants/oauth'; import { theme } from '@/styles/theme'; +import { OAuthType } from '@/types/login'; +import { Suspense } from 'react'; import styled from 'styled-components'; -import Image from 'next/image'; - -interface OauthButtonProps { - bgColor: string; -} const notReady = () => { alert('준비 중입니다.'); }; -const oauthButtons = [ - { - key: 'naver', - bgColor: '#03CF5D', - component: ( - Naver alert('준비 중입니다.')} - /> - ) - }, - { - key: 'google', - bgColor: '#FFFFFF', - component: - }, - { - key: 'kakao', - bgColor: '#FEE500', - component: - } -]; - export default function Login() { return ( @@ -48,11 +20,23 @@ export default function Login() { 편지로 수놓는 나의 스페이스 - {oauthButtons.map(({ key, bgColor, component }) => ( - - {component} - - ))} + + + + } + > + {OAUTH.map((item) => ( + + ))} + 로그인 없이 편지 작성해보기 @@ -157,20 +141,6 @@ const OauthWrapper = styled.div` justify-content: center; `; -const OauthButton = styled.button` - width: 69px; - height: 69px; - border-radius: 50%; - border: none; - background-color: ${({ bgColor }) => bgColor}; - display: flex; - align-items: center; - overflow: hidden; - justify-content: center; - cursor: pointer; - transition: background-color 0.3s; -`; - const LetterBtnText = styled.div` ${(props) => props.theme.fonts.caption02}; color: ${theme.colors.gray400}; diff --git a/src/app/mypage/page.tsx b/src/app/mypage/page.tsx index 0119427f..6f894ccb 100644 --- a/src/app/mypage/page.tsx +++ b/src/app/mypage/page.tsx @@ -2,9 +2,9 @@ import { getLetterCount } from '@/api/letter/letter'; import { getUserInfo, logout } from '@/api/mypage/user'; -import Button from '@/components/common/Button'; import Loader, { LoaderContainer } from '@/components/common/Loader'; import NavigatorBar from '@/components/common/NavigatorBar'; +import { OAUTH } from '@/constants/oauth'; import { theme } from '@/styles/theme'; import { clearOnboarding, clearTokens, getRefreshToken } from '@/utils/storage'; import { useRouter } from 'next/navigation'; @@ -65,7 +65,6 @@ const MyPage = () => { setName(response.data.name); setEmail(response.data.email); setPlatform(response.data.socialPlatform); - // console.log('회원정보 조회 성공:', response.data); } catch (error) { console.error('회원정보 조회 실패:', error); } @@ -81,18 +80,9 @@ const MyPage = () => { } }; - const EmailType = (platform): string => { - switch (platform) { - case 'GOOGLE': - return '/assets/icons/ic_google.svg'; - case 'KAKAO': - return '/assets/icons/ic_kakao_profile.svg'; - case 'NAVER': - return '/assets/icons/ic_naver.svg'; - default: - return ''; - } - }; + const profileSrc = OAUTH.find( + (oauth) => oauth.key === platform.toLowerCase() + )?.profile; return ( @@ -112,11 +102,7 @@ const MyPage = () => { {name}님의 스페이스 - +
{email}
@@ -258,15 +244,9 @@ const ProfileImage = styled.img` } `; -const iconSizes = { - GOOGLE: 20, - KAKAO: 20, - NAVER: 20 -} as const; - -const StyledIcon = styled.img<{ platform: keyof typeof iconSizes }>` - width: ${({ platform }) => iconSizes[platform]}px; - height: ${({ platform }) => iconSizes[platform]}px; +const StyledIcon = styled.img` + width: 20px; + height: 20px; `; const ProfileInfo = styled.div` diff --git a/src/app/signup/complete/page.tsx b/src/app/signup/complete/page.tsx index db62f2ee..9f911e79 100644 --- a/src/app/signup/complete/page.tsx +++ b/src/app/signup/complete/page.tsx @@ -68,9 +68,7 @@ const Container = styled.div` width: 100%; max-width: 393px; height: 100%; - max-height: 853px; flex-direction: column; - //overflow: scroll; justify-content: space-between; background-image: url('/assets/signup/signup_bg.png'); background-size: cover; diff --git a/src/components/signup/OauthButton.tsx b/src/components/signup/OauthButton.tsx new file mode 100644 index 00000000..d327a28d --- /dev/null +++ b/src/components/signup/OauthButton.tsx @@ -0,0 +1,155 @@ +import React, { Suspense, useEffect, useState } from 'react'; +import Image from 'next/image'; +import styled from 'styled-components'; +import { useSearchParams } from 'next/navigation'; +import { getRecentLogin, setLetterUrl } from '@/utils/storage'; +import { OAuthType } from '@/types/login'; +import { theme } from '@/styles/theme'; +import { float } from '@/styles/animation'; + +interface OauthButtonProps { + loginType: OAuthType; + bgColor: string; + icon: string; + size: number; +} +const OauthButton = (props: OauthButtonProps) => { + const { loginType, bgColor, icon, size } = props; + + const searchParams = useSearchParams(); + const url = searchParams.get('url'); + const [redirectUri, setRedirectUri] = useState(''); + const [recentLogin, setRecentLogin] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + setRecentLogin(getRecentLogin()); + setRedirectUri( + window.location.protocol + + '//' + + window.location.host + + `/login/auth?type=${loginType}` + ); + } + }, []); + + const handleLogin = () => { + if (url) { + setLetterUrl(url); + } + let authUrl = ''; + + switch (loginType) { + case 'google': { + const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; + const scope = 'openid profile email'; + authUrl = [ + 'https://accounts.google.com/o/oauth2/v2/auth', + `?client_id=${GOOGLE_CLIENT_ID}`, + `&redirect_uri=${encodeURIComponent(redirectUri)}`, + `&response_type=code`, + `&scope=${encodeURIComponent(scope)}`, + `&access_type=offline`, + `&prompt=consent` + ].join(''); + break; + } + + case 'kakao': { + const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY; + authUrl = [ + 'https://kauth.kakao.com/oauth/authorize', + `?client_id=${KAKAO_CLIENT_ID}`, + `&redirect_uri=${encodeURIComponent(redirectUri)}`, + `&response_type=code` + ].join(''); + break; + } + + case 'naver': { + const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_CLIENT_ID; + const state = Math.random().toString(36).substring(2); + authUrl = [ + 'https://nid.naver.com/oauth2.0/authorize', + `?client_id=${NAVER_CLIENT_ID}`, + `&redirect_uri=${redirectUri}`, + `&response_type=code`, + `&state=${state}` + ].join(''); + break; + } + } + + if (authUrl) { + window.location.href = authUrl; + } + }; + + return ( + + {recentLogin === loginType && 최근에 로그인했어요} + + + + + ); +}; + +export default OauthButton; + +const Wrapper = styled.div` + width: 69px; + height: 69px; + position: relative; + display: flex; + flex-direction: column; + align-items: center; +`; + +const SocialButton = styled.button<{ $bgColor: string }>` + width: 69px; + height: 69px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + border: none; + background-color: ${({ $bgColor }) => $bgColor}; + cursor: pointer; + transition: background-color 0.3s; +`; + +const SocialIcon = styled(Image)` + width: 100%; + height: 100%; + padding: 0 16px; + object-fit: contain; +`; + +const Bubble = styled.div` + height: 28px; + position: absolute; + top: -45px; + left: 50%; + transform: translateX(-50%); + background-color: ${theme.colors.blue}; + color: ${theme.colors.white}; + padding: 5px 10px; + border-radius: 8px; + ${theme.fonts.caption03}; + white-space: nowrap; + animation: ${float} 2s ease-in-out infinite; + + &::after { + content: ''; + position: absolute; + bottom: -9px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-width: 10px 6px 0 6px; + border-style: solid; + border-color: ${theme.colors.blue} transparent transparent transparent; + } +`; diff --git a/src/components/signup/SocialGoogle.tsx b/src/components/signup/SocialGoogle.tsx deleted file mode 100644 index 4d46f065..00000000 --- a/src/components/signup/SocialGoogle.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { Suspense, useEffect, useState } from 'react'; -import Image from 'next/image'; -import styled from 'styled-components'; -import { useSearchParams } from 'next/navigation'; -import { setLetterUrl } from '@/utils/storage'; -import Loader, { LoaderContainer } from '../common/Loader'; - -const SocialGoogle = () => { - const searchParams = useSearchParams(); - const url = searchParams.get('url'); - const [absoluteUrl, setabsoluteUrl] = useState(''); - - useEffect(() => { - if (typeof window !== 'undefined') { - setabsoluteUrl( - window.location.protocol + - '//' + - window.location.host + - '/login/auth?type=google' - ); - } - }, []); - - const handleLogin = () => { - if (url) { - setLetterUrl(url); - } - const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; - const scope = 'openid profile email'; - - const authUrl = [ - 'https://accounts.google.com/o/oauth2/v2/auth', - `?client_id=${GOOGLE_CLIENT_ID}`, - `&redirect_uri=${encodeURIComponent(absoluteUrl)}`, - `&response_type=code`, - `&scope=${encodeURIComponent(scope)}`, - `&access_type=offline`, - `&prompt=consent` - ].join(''); - window.location.href = authUrl; - }; - - return ( - - - - ); -}; - -export default function SocialGooglePaging() { - return ( - - - - } - > - - - ); -} - -const SocialLoginBox = styled.div` - display: flex; - box-sizing: border-box; - width: 100%; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; -`; - -const StyledImage = styled(Image)` - width: 100%; - height: 100%; - padding: 0 16px; - object-fit: contain; -`; diff --git a/src/components/signup/SocialKakao.tsx b/src/components/signup/SocialKakao.tsx deleted file mode 100644 index 6128cb43..00000000 --- a/src/components/signup/SocialKakao.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { Suspense, useEffect, useState } from 'react'; -import Image from 'next/image'; -import styled from 'styled-components'; -import { useSearchParams } from 'next/navigation'; -import { setLetterUrl } from '@/utils/storage'; -import Loader, { LoaderContainer } from '../common/Loader'; - -const SocialKakao = () => { - const searchParams = useSearchParams(); - const url = searchParams.get('url'); - const REST_API_KEY = process.env.NEXT_PUBLIC_REST_API_KEY; - const [redirectUrl, setRedirectUrl] = useState(''); - const KAKAO_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${redirectUrl}&response_type=code&url=${url}`; - - useEffect(() => { - if (typeof window !== 'undefined') { - setRedirectUrl( - window.location.protocol + - '//' + - window.location.host + - '/login/auth?type=kakao' - ); - } - }, []); - - const handleLogin = () => { - //이때 localStorage에 저장된 accessToken이 만료되었는지 확인해야함. - // if (accessToken) { - // if (url) { - // router.push(`/verify?url=${url}`); - // } else { - // router.push("/"); - // } - // setAccessToken(accessToken); - // } else { - //받은 편지를 통해 들어올 경우 url를 저장한다. - if (url) { - setLetterUrl(url); - } - // redirectUri가 준비된 뒤에 인가 URL 생성 - const params = new URLSearchParams({ - client_id: REST_API_KEY, - redirect_uri: redirectUrl, - response_type: 'code' - }); - if (url) { - params.set('url', url); - } - - window.location.href = `https://kauth.kakao.com/oauth/authorize?${params.toString()}`; - window.location.href = KAKAO_URL; - }; - - return ( - - - - ); -}; - -export default function SocialKakaoPaging() { - return ( - - - - } - > - - - ); -} - -const SocialLoginBox = styled.div` - display: flex; - box-sizing: border-box; - width: 100%; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; -`; - -const StyledImage = styled(Image)` - width: 100%; - height: 100%; - padding: 0 16px; - object-fit: contain; -`; diff --git a/src/constants/oauth.ts b/src/constants/oauth.ts new file mode 100644 index 00000000..dbbd033f --- /dev/null +++ b/src/constants/oauth.ts @@ -0,0 +1,23 @@ +export const OAUTH = [ + { + key: 'naver', + bgColor: '#03CF5D', + icon: '/assets/icons/ic_naver.svg', + size: 26, + profile: "/assets/icons/ic_naver.svg" + }, + { + key: 'google', + bgColor: '#FFFFFF', + icon: '/assets/icons/ic_google.svg', + size: 32, + profile: "/assets/icons/ic_googler.svg" + }, + { + key: 'kakao', + bgColor: '#FEE500', + icon: '/assets/icons/ic_kakaotalk.svg', + size: 38, + profile: '/assets/icons/ic_kakao_profile.svg', + } +]; \ No newline at end of file diff --git a/src/hooks/useKakaoSDK.tsx b/src/hooks/useKakaoSDK.tsx index 747e31a1..6bf54fa8 100644 --- a/src/hooks/useKakaoSDK.tsx +++ b/src/hooks/useKakaoSDK.tsx @@ -1,17 +1,17 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react'; const useKakaoSDK = () => { const [isKakaoLoaded, setIsKakaoLoaded] = useState(false); - const JS_KEY = process.env.NEXT_PUBLIC_JAVASCRIPT_KEY; + const JS_KEY = process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY; useEffect(() => { if (!JS_KEY) { - console.error("Kakao JavaScript key is missing"); + console.error('Kakao JavaScript key is missing'); return; } - const script = document.createElement("script"); - script.src = "https://developers.kakao.com/sdk/js/kakao.js"; + const script = document.createElement('script'); + script.src = 'https://developers.kakao.com/sdk/js/kakao.js'; script.async = true; script.onload = () => { diff --git a/src/styles/animation.ts b/src/styles/animation.ts index c636eaa5..e6800433 100644 --- a/src/styles/animation.ts +++ b/src/styles/animation.ts @@ -11,3 +11,15 @@ export const flipAnimation = keyframes` transform: rotateY(0deg); } `; + +export const float = keyframes` + 0% { + transform: translate(-50%, 0); + } + 50% { + transform: translate(-50%, -6px); + } + 100% { + transform: translate(-50%, 0); + } +`; \ No newline at end of file diff --git a/src/styles/theme.ts b/src/styles/theme.ts index ef813049..b2836ca1 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -1,26 +1,27 @@ import { DefaultTheme } from "styled-components"; const colors = { - main01: "#424DA0", - sub01: "#2C3361", - sub02: "#565C81", - sub03: "#7783C5", + main01: '#424DA0', + sub01: '#2C3361', + sub02: '#565C81', + sub03: '#7783C5', - gray900: "#181B29", - gray800: "#202232", - gray700: "#2E3040", - gray600: "#3E4151", - gray500: "#5B5F70", - gray400: "#818491", - gray300: "#9FA1AC", - gray200: "#BEC0C8", - gray100: "#D5D7DE", - gray50: "#F7F8F9", + gray900: '#181B29', + gray800: '#202232', + gray700: '#2E3040', + gray600: '#3E4151', + gray500: '#5B5F70', + gray400: '#818491', + gray300: '#9FA1AC', + gray200: '#BEC0C8', + gray100: '#D5D7DE', + gray50: '#F7F8F9', - bg: "#060812", - white: "#FFFFFF", - black: "#000000", - red: "#E1303E", + bg: '#060812', + white: '#FFFFFF', + black: '#000000', + red: '#E1303E', + blue: '#3399FF' } as const; interface Font { diff --git a/src/types/login.ts b/src/types/login.ts new file mode 100644 index 00000000..13fa2fca --- /dev/null +++ b/src/types/login.ts @@ -0,0 +1,2 @@ +export type OAuthType = 'naver' | 'google' | 'kakao'; +export type Provider = 'GOOGLE' | 'KAKAO' | 'NAVER'; \ No newline at end of file diff --git a/src/utils/storage.ts b/src/utils/storage.ts index d96834aa..7324835a 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -47,6 +47,21 @@ export const clearOnboarding = () => { removeCookie("lettering-onboarding") } +/* 최근 로그인 방식 */ +export const setRecentLogin = (loginType: string) => { + if (typeof window !== "undefined") { + localStorage.setItem('recent_login', loginType); + } + return null; +}; + +export const getRecentLogin = () => { + if (typeof window !== 'undefined') { + return localStorage.getItem('recent_login'); + } + return null; +}; + /* letter URL */ export const setLetterUrl = (url: string) => { if (typeof window !== "undefined") {