-
Notifications
You must be signed in to change notification settings - Fork 1
Feat: goolgle login 페이지 추가 및 연결 #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ca7a600
b747e01
d71dfb2
da7dde3
fc71ef3
3ec770d
8e7eae0
8b29d4b
957f124
8b7e9e1
f6c2c49
4363251
f80ebd2
110754a
8e77caf
38d302f
3fec7cc
48e386a
b87b0cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import apiRequest from '@shared/apis/setting/axiosInstance'; | ||
| import LoadingChippi from '@shared/components/loadingChippi/LoadingChippi'; | ||
| import { useEffect } from 'react'; | ||
| import { useNavigate, useSearchParams } from 'react-router-dom'; | ||
|
|
||
| const GoogleCallback = () => { | ||
| const navigate = useNavigate(); | ||
| const [searchParams] = useSearchParams(); | ||
|
|
||
| useEffect(() => { | ||
| const code = searchParams.get('code'); | ||
|
|
||
| if (!code) { | ||
| alert('로그인 실패. 다시 시도해주세요.'); | ||
| navigate('/onboarding?step=SOCIAL_LOGIN'); | ||
| return; | ||
| } | ||
|
|
||
| loginWithCode(code); | ||
| }, []); | ||
|
|
||
| const handleUserLogin = ( | ||
| isUser: boolean, | ||
| accessToken: string | undefined | ||
| ) => { | ||
| if (isUser) { | ||
| if (accessToken) { | ||
| localStorage.setItem('token', accessToken); | ||
| } | ||
| navigate('/'); | ||
| } else { | ||
| navigate('/onboarding?step=ALARM'); | ||
| } | ||
| }; | ||
|
|
||
| const loginWithCode = async (code: string) => { | ||
| try { | ||
| const res = await apiRequest.post('/api/v2/auth/google', { code }); | ||
| const { isUser, userId, email, accessToken } = res.data.data; | ||
|
|
||
| localStorage.setItem('email', email); | ||
| localStorage.setItem('userId', userId); | ||
|
|
||
| handleUserLogin(isUser, accessToken); | ||
| } catch (error) { | ||
| console.error('로그인 오류:', error); | ||
| navigate('/onboarding?step=SOCIAL_LOGIN'); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="flex h-screen flex-col items-center justify-center"> | ||
| <LoadingChippi className="mb-6" /> | ||
| <p className="text-font-black-2 head3 mt-[1.6rem]"> | ||
| 잠시만 기다려주세요… | ||
| </p> | ||
| <p className="body1-m text-font-gray-3 text-center"> | ||
| 치삐가 로그인 중입니다 | ||
| </p> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default GoogleCallback; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,28 @@ | ||
| import { Progress, Button } from '@pinback/design-system/ui'; | ||
| import { useState, useEffect, lazy, Suspense } from 'react'; | ||
| import { motion, AnimatePresence } from 'framer-motion'; | ||
| import SocialLoginStep from './step/SocialLoginStep'; | ||
| const StoryStep = lazy(() => import('./step/StoryStep')); | ||
| const AlarmStep = lazy(() => import('./step/AlarmStep')); | ||
| const MacStep = lazy(() => import('./step/MacStep')); | ||
| const FinalStep = lazy(() => import('./step/FinalStep')); | ||
|
|
||
| import { cva } from 'class-variance-authority'; | ||
| import { usePostSignUp } from '@shared/apis/queries'; | ||
| const stepProgress = [{ progress: 30 }, { progress: 60 }, { progress: 100 }]; | ||
| import { AlarmsType } from '@constants/alarms'; | ||
| import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime'; | ||
| import { useNavigate, useLocation } from 'react-router-dom'; | ||
| import { firebaseConfig } from '../../../../firebase-config'; | ||
| import { initializeApp } from 'firebase/app'; | ||
| import { getMessaging, getToken } from 'firebase/messaging'; | ||
| import { registerServiceWorker } from '@pages/onBoarding/utils/registerServiceWorker'; | ||
| import { useLocation } from 'react-router-dom'; | ||
| import { AlarmsType } from '@constants/alarms'; | ||
| import { normalizeTime } from '@pages/onBoarding/utils/formatRemindTime'; | ||
| const stepProgress = [{ progress: 33 }, { progress: 66 }, { progress: 100 }]; | ||
| import { | ||
| Step, | ||
| stepOrder, | ||
| StepType, | ||
| storySteps, | ||
| } from '@pages/onBoarding/constants/onboardingSteps'; | ||
|
|
||
| const variants = { | ||
| slideIn: (direction: number) => ({ | ||
| x: direction > 0 ? 200 : -200, | ||
|
|
@@ -27,6 +34,7 @@ const variants = { | |
| opacity: 0, | ||
| }), | ||
| }; | ||
|
|
||
| const CardStyle = cva( | ||
| 'bg-white-bg flex h-[54.8rem] w-[63.2rem] flex-col items-center justify-between rounded-[2.4rem] pt-[3.2rem]', | ||
| { | ||
|
|
@@ -39,36 +47,41 @@ const CardStyle = cva( | |
| defaultVariants: { overflow: false }, | ||
| } | ||
| ); | ||
|
|
||
| const MainCard = () => { | ||
| const [step, setStep] = useState(0); | ||
| const navigate = useNavigate(); | ||
| const location = useLocation(); | ||
| const { mutate: postSignData } = usePostSignUp(); | ||
|
|
||
| const [step, setStep] = useState<StepType>(Step.STORY_0); | ||
| const [direction, setDirection] = useState(0); | ||
| const [alarmSelected, setAlarmSelected] = useState<1 | 2 | 3>(1); | ||
| const [isMac, setIsMac] = useState(false); | ||
| // api 구간 | ||
| const { mutate: postSignData } = usePostSignUp(); | ||
|
|
||
| // 익스텐션에서부터 이메일 받아오는 구간! | ||
| const [userEmail, setUserEmail] = useState(''); | ||
| const location = useLocation(); | ||
| const [remindTime, setRemindTime] = useState('09:00'); | ||
| const [fcmToken, setFcmToken] = useState<string | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| const params = new URLSearchParams(location.search); | ||
| const emailParam = params.get('email'); | ||
| if (emailParam) { | ||
| setUserEmail(emailParam); | ||
| localStorage.setItem('email', emailParam); | ||
| const storedEmail = localStorage.getItem('email'); | ||
| if (storedEmail) { | ||
| setUserEmail(storedEmail); | ||
| } | ||
|
|
||
| const stepParam = params.get('step') as StepType; | ||
| if (stepParam && Object.values(Step).includes(stepParam)) { | ||
| setStep(stepParam); | ||
| } | ||
| }, [location.search]); | ||
|
|
||
| // FCM 구간 | ||
| const [fcmToken, setFcmToken] = useState<string | null>(null); | ||
| const app = initializeApp(firebaseConfig); | ||
| const messaging = getMessaging(app); | ||
|
|
||
|
Comment on lines
77
to
79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Short answer — prevent duplicate initialization by checking for an existing App before calling initializeApp(). Two common patterns for v9 (modular):
if (!getApps().length) {
let app Both avoid the "Firebase App named '[DEFAULT]' already exists (app/duplicate-app)" error. See Firebase docs and examples. [1][2] Sources: 🌐 Web query:
💡 Result: Short answer: initialize Firebase once in module scope (a single firebase.js / firebase.ts file), export the app and needed service instances, and import those where needed — or provide them via React Context. This avoids duplicate apps, keeps tree-shaking with the modular SDK, and makes testing/config clearer. [1][2][3][4] Recommended pattern (modular SDK v9+):
Minimal example (TypeScript/ESM, cite docs): const firebaseConfig = { /* your config */ }; const app = !getApps().length ? initializeApp(firebaseConfig) : getApp(); Why this is best:
If you want, I can show a React Context/provider wrapper and a small test-mocking pattern. Firebase
Recommended fix:
// firebase.ts or similar, at file top level
import { initializeApp, getApps, getApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging';
const firebaseConfig = { /* ... */ };
// Guard against duplicate initialization
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
export const messaging = getMessaging(app);
import { messaging } from '@/firebase'; // or wherever you define it
// Inside MainCard, remove these lines:
// const app = initializeApp(firebaseConfig);
// const messaging = getMessaging(app);
// Use the imported messaging directly:
const requestFCMToken = async (): Promise<string | null> => {
const forFcmtoken = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});
// ...
};This ensures Firebase initializes only once, regardless of how many times 🤖 Prompt for AI Agents |
||
| const requestFCMToken = async (): Promise<string | null> => { | ||
| try { | ||
| const permission = await Notification.requestPermission(); | ||
| registerServiceWorker(); | ||
|
|
||
| if (permission !== 'granted') { | ||
| alert('알림 권한 허용이 필요합니다!'); | ||
| return null; | ||
|
|
@@ -107,80 +120,92 @@ const MainCard = () => { | |
| } | ||
| })(); | ||
| }, []); | ||
|
|
||
| const renderStep = () => { | ||
| switch (step) { | ||
| case 0: | ||
| case 1: | ||
| case 2: | ||
| return <StoryStep step={step as 0 | 1 | 2} />; | ||
| case 3: | ||
| case Step.STORY_0: | ||
| case Step.STORY_1: | ||
| case Step.STORY_2: | ||
| return ( | ||
| <StoryStep step={Number(step.replace('STORY_', '')) as 0 | 1 | 2} /> | ||
| ); | ||
| case Step.SOCIAL_LOGIN: | ||
| return <SocialLoginStep />; | ||
| case Step.ALARM: | ||
| return ( | ||
| <AlarmStep selected={alarmSelected} setSelected={setAlarmSelected} /> | ||
| ); | ||
| case 4: | ||
| if (isMac) return <MacStep />; | ||
| case Step.MAC: | ||
| return <MacStep />; | ||
| case Step.FINAL: | ||
| return <FinalStep />; | ||
| case 5: | ||
| if (isMac) return <FinalStep />; | ||
| return null; | ||
| default: | ||
| return <FinalStep />; | ||
| } | ||
| }; | ||
|
|
||
| const [remindTime, setRemindTime] = useState('09:00'); | ||
| const nextStep = async () => { | ||
| if (step === 3) { | ||
| if (alarmSelected == 1) { | ||
| setRemindTime('09:00'); | ||
| } else if (alarmSelected == 2) { | ||
| setRemindTime('20:00'); | ||
| } else { | ||
| const idx = stepOrder.indexOf(step); | ||
| const next = stepOrder[idx + 1]; | ||
| const isAlarmStep = step === Step.ALARM; | ||
| const isFinalStep = step === Step.FINAL; | ||
| const isMacStep = next === Step.MAC; | ||
| const shouldSkipMacStep = isMacStep && !isMac; | ||
|
|
||
| if (isAlarmStep) { | ||
| if (alarmSelected === 1) setRemindTime('09:00'); | ||
| else if (alarmSelected === 2) setRemindTime('20:00'); | ||
| else { | ||
| const raw = AlarmsType[alarmSelected - 1].time; | ||
| setRemindTime(normalizeTime(raw)); | ||
| } | ||
| } | ||
|
|
||
| if (shouldSkipMacStep) { | ||
| setDirection(1); | ||
| setStep((prev) => prev + 1); | ||
| setStep(Step.FINAL); | ||
| navigate(`/onboarding?step=${Step.FINAL}`); | ||
| return; | ||
| } | ||
| if ((isMac && step < 5) || (!isMac && step < 4)) { | ||
| setDirection(1); | ||
| setStep((prev) => prev + 1); | ||
| } else if ((isMac && step === 5) || (!isMac && step == 4)) { | ||
|
|
||
| if (isFinalStep) { | ||
| postSignData( | ||
| { email: userEmail, remindDefault: remindTime, fcmToken }, | ||
| { | ||
| email: userEmail, | ||
| remindDefault: remindTime, | ||
| fcmToken: fcmToken, | ||
| }, | ||
| { | ||
| onSuccess: () => { | ||
| window.location.href = '/'; | ||
| }, | ||
| onSuccess: () => (window.location.href = '/'), | ||
| onError: () => { | ||
| const savedEmail = localStorage.getItem('email'); | ||
| if (savedEmail) { | ||
| window.location.href = '/'; | ||
| } | ||
| if (savedEmail) window.location.href = '/'; | ||
| }, | ||
| } | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| setDirection(1); | ||
| setStep(next); | ||
| navigate(`/onboarding?step=${next}`); | ||
| }; | ||
|
|
||
| const prevStep = () => { | ||
| if (step > 0) { | ||
| const idx = stepOrder.indexOf(step); | ||
| if (idx > 0) { | ||
| const previous = stepOrder[idx - 1]; | ||
| setDirection(-1); | ||
| setStep((prev) => prev - 1); | ||
| setStep(previous); | ||
| navigate(`/onboarding?step=${previous}`); | ||
| } | ||
| }; | ||
|
Comment on lines
190
to
198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 비Mac 사용자가 뒤로가기 시 MAC 단계에 도달할 수 있습니다.
const prevStep = () => {
const idx = stepOrder.indexOf(step);
if (idx > 0) {
- const previous = stepOrder[idx - 1];
+ let previous = stepOrder[idx - 1];
+ // 비Mac 사용자가 MAC 단계를 건너뛰도록 처리
+ if (previous === Step.MAC && !isMac) {
+ previous = Step.ALARM;
+ }
setDirection(-1);
setStep(previous);
navigate(`/onboarding?step=${previous}`);
}
};🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className={CardStyle({ overflow: step === 3 && alarmSelected === 3 })}> | ||
| {step < 3 && ( | ||
| <div | ||
| className={CardStyle({ | ||
| overflow: step === Step.ALARM && alarmSelected === 3, | ||
| })} | ||
| > | ||
| {storySteps.includes(step) && ( | ||
| <Progress | ||
| value={stepProgress[step].progress} | ||
| value={stepProgress[storySteps.indexOf(step)].progress} | ||
| variant="profile" | ||
| className="w-[15.6rem]" | ||
| /> | ||
|
|
@@ -204,26 +229,27 @@ const MainCard = () => { | |
| </div> | ||
|
|
||
| <div className="mb-[4.8rem] mt-[1.2rem] flex w-full justify-between px-[3.2rem]"> | ||
| {step < 4 && step > 0 && ( | ||
| {!([Step.STORY_0, Step.SOCIAL_LOGIN] as StepType[]).includes(step) && ( | ||
| <Button | ||
| variant="secondary" | ||
| size="medium" | ||
| isDisabled={step === 0} | ||
| className="w-[4.8rem]" | ||
| onClick={prevStep} | ||
| > | ||
| 뒤로 | ||
| </Button> | ||
| )} | ||
| <Button | ||
| variant="primary" | ||
| size="medium" | ||
| isDisabled={step === 6} | ||
| className="ml-auto w-[4.8rem]" | ||
| onClick={nextStep} | ||
| > | ||
| 다음 | ||
| </Button> | ||
|
|
||
| {step !== Step.SOCIAL_LOGIN && ( | ||
| <Button | ||
| variant="primary" | ||
| size="medium" | ||
| className="ml-auto w-[4.8rem]" | ||
| onClick={nextStep} | ||
| > | ||
| 다음 | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import Chippi from '@assets/chippi_extension_popup.svg'; | ||
| import GoogleLogo from '/assets/onBoarding/icons/googleLogo.svg'; | ||
|
|
||
| const SocialLoginStep = () => { | ||
| const handleGoogleLogin = () => { | ||
| const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; | ||
| const redirectUri = import.meta.env.VITE_GOOGLE_REDIRECT_URI; | ||
|
|
||
| if (!clientId || !redirectUri) { | ||
| alert('Google OAuth 설정이 누락되었습니다.'); | ||
| return; | ||
| } | ||
|
|
||
| const googleAuthUrl = | ||
| `https://accounts.google.com/o/oauth2/v2/auth?` + | ||
| `client_id=${clientId}` + | ||
| `&redirect_uri=${redirectUri}` + | ||
| `&response_type=code` + | ||
| `&scope=email profile`; | ||
|
|
||
| window.location.href = googleAuthUrl; | ||
| }; | ||
|
Comment on lines
+4
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainGoogle OAuth URL 구성 시 보안(state) 및 인코딩 개선 필요 현재
예시는 아래처럼 구현할 수 있습니다. - const googleAuthUrl =
- `https://accounts.google.com/o/oauth2/v2/auth?` +
- `client_id=${clientId}` +
- `&redirect_uri=${redirectUri}` +
- `&response_type=code` +
- `&scope=email profile`;
-
- window.location.href = googleAuthUrl;
+ const state = crypto.randomUUID();
+ sessionStorage.setItem('google_oauth_state', state);
+
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: 'email profile',
+ state,
+ });
+
+ window.location.href =
+ `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;(콜백 쪽에서 Also applies to: 40-50 🌐 Web query: 💡 Result: 요약 — 목적과 권장 패턴
참고(주요 출처)
원하시면 샘플 구현 절차(요청 예시, state/PKCE 생성·검증 코드 스니펫, 안전한 토큰 저장 방안)를 한국어 코드 예제로 바로 드리겠습니다. 웹 검색 결과를 검토했습니다. Google OAuth 공식 문서와 IETF 권고에서 확인된 내용: ✓ state 파라미터는 필수 — CSRF 공격 방지 및 요청-응답 매칭 검증용 원본 리뷰의 보안 지적이 모두 검증되었습니다. 최종 리뷰 코멘트를 생성하겠습니다. Google OAuth URL 구성 시 CSRF 방지(state) 및 보안 개선 필수 현재
구현 예시: - const googleAuthUrl =
- `https://accounts.google.com/o/oauth2/v2/auth?` +
- `client_id=${clientId}` +
- `&redirect_uri=${redirectUri}` +
- `&response_type=code` +
- `&scope=email profile`;
-
- window.location.href = googleAuthUrl;
+ const state = crypto.randomUUID();
+ sessionStorage.setItem('google_oauth_state', state);
+
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: 'email profile',
+ state,
+ });
+
+ window.location.href =
+ `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;콜백 컴포넌트에서도 (Also applies to: lines 40-50) 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="flex flex-col items-center justify-center pt-6"> | ||
| <img | ||
| src={Chippi} | ||
| alt="치삐 이미지" | ||
| className="h-[19.4rem] w-[19.4rem] object-contain" | ||
| /> | ||
|
|
||
| <h1 className="head2 text-font-black-1 mb-[0.8rem] text-center"> | ||
| 치삐를 만나려면 로그인이 필요해요! | ||
| </h1> | ||
|
|
||
| <p className="body2-m text-font-gray-3 mb-[3.5rem] text-center"> | ||
| 로그인하고 북마크한 정보를 리마인드를 받아보세요. | ||
| </p> | ||
|
|
||
| <button | ||
| onClick={handleGoogleLogin} | ||
| className="sub2-sb flex h-[5.2rem] w-[22.7rem] items-center justify-between gap-3 rounded-full border border-gray-100 bg-white px-[2rem]" | ||
| > | ||
| <img | ||
| src={GoogleLogo} | ||
| alt="구글 로고" | ||
| className="h-[2.435rem] w-[2.435rem]" | ||
| /> | ||
| 구글 계정으로 로그인 | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default SocialLoginStep; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx | head -40Repository: Pinback-Team/pinback-client
Length of output: 1931
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 53
🏁 Script executed:
rg "stepProgress" apps/client/src/pages/onBoarding/components/funnel/MainCard.tsx -B 5 -A 2Repository: Pinback-Team/pinback-client
Length of output: 746
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 337
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 539
🏁 Script executed:
rg "import/order|sort-imports" --type json --type yamlRepository: Pinback-Team/pinback-client
Length of output: 1148
🏁 Script executed:
cat apps/client/eslint.config.js | head -100Repository: Pinback-Team/pinback-client
Length of output: 288
🏁 Script executed:
rg "import-order|no-mixed-require|imports-on-top" apps/client/eslint.config.jsRepository: Pinback-Team/pinback-client
Length of output: 53
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 733
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 453
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 157
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 181
🏁 Script executed:
Repository: Pinback-Team/pinback-client
Length of output: 431
Move all
importstatements before theconst stepProgressdeclarationES modules require all imports to come before other module-level code. Currently,
const stepProgresson line 18 breaks this rule by appearing between import statements (lines 16–17 and 19–24), which causes a parse error.Reorganize to:
🤖 Prompt for AI Agents