diff --git a/apps/client/package.json b/apps/client/package.json index ba5b8693..6055c74d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@pinback/contracts": "workspace:*", "@tanstack/react-query": "^5.85.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", diff --git a/apps/client/src/layout/Layout.tsx b/apps/client/src/layout/Layout.tsx index bab24695..cb509250 100644 --- a/apps/client/src/layout/Layout.tsx +++ b/apps/client/src/layout/Layout.tsx @@ -2,6 +2,7 @@ import { ROUTES_CONFIG } from '@routes/routesConfig'; import { useGetHasJob } from '@shared/apis/queries'; import JobSelectionFunnel from '@shared/components/jobSelectionFunnel/JobSelectionFunnel'; import { Sidebar } from '@shared/components/sidebar/Sidebar'; +import { authStorage } from '@shared/utils/authStorage'; import { useQueryClient } from '@tanstack/react-query'; import { Outlet, useLocation } from 'react-router-dom'; @@ -19,7 +20,7 @@ const Layout = () => { location.pathname.startsWith(ROUTES_CONFIG.onboardingCallback.path); const isSidebarHidden = isAuthPage || isPolicyPage; - const isLoggedIn = !!localStorage.getItem('token'); + const isLoggedIn = authStorage.hasAccessToken(); const { data: hasJobData, isLoading: isHasJobLoading } = useGetHasJob( isLoggedIn && !isAuthPage diff --git a/apps/client/src/pages/onBoarding/GoogleCallback.tsx b/apps/client/src/pages/onBoarding/GoogleCallback.tsx index 0a872de4..a742e7fe 100644 --- a/apps/client/src/pages/onBoarding/GoogleCallback.tsx +++ b/apps/client/src/pages/onBoarding/GoogleCallback.tsx @@ -1,18 +1,10 @@ import apiRequest from '@shared/apis/setting/axiosInstance'; import LoadingChippi from '@shared/components/loadingChippi/LoadingChippi'; +import { authStorage } from '@shared/utils/authStorage'; +import { extensionBridge } from '@shared/utils/extensionBridge'; import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -const sendTokenToExtension = (token: string) => { - window.postMessage( - { - type: 'SET_TOKEN', - token, - }, - window.location.origin - ); -}; - const GoogleCallback = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -37,16 +29,16 @@ const GoogleCallback = () => { ) => { if (isUser) { if (accessToken) { - localStorage.setItem('token', accessToken); - sendTokenToExtension(accessToken); + authStorage.setAccessToken(accessToken); + extensionBridge.syncToken(accessToken); } if (refreshToken) { - localStorage.setItem('refreshToken', refreshToken); + authStorage.setRefreshToken(refreshToken); } if (typeof hasJob === 'boolean') { - localStorage.setItem('hasJob', String(hasJob)); + authStorage.setHasJob(hasJob); } navigate('/'); } else { @@ -74,8 +66,7 @@ const GoogleCallback = () => { const { isUser, userId, email, accessToken, refreshToken, hasJob } = res.data.data; - localStorage.setItem('email', email); - localStorage.setItem('userId', userId); + authStorage.setUserIdentity(email, userId); handleUserLogin(isUser, accessToken, refreshToken, hasJob); } catch (error) { diff --git a/apps/client/src/shared/apis/queries.ts b/apps/client/src/shared/apis/queries.ts index 7e2a37ba..e4e1d2b9 100644 --- a/apps/client/src/shared/apis/queries.ts +++ b/apps/client/src/shared/apis/queries.ts @@ -29,6 +29,8 @@ import { JobsResponse, } from '@shared/types/api'; import { fetchOGData } from '@shared/utils/fetchOgData'; +import { authStorage } from '@shared/utils/authStorage'; +import { extensionBridge } from '@shared/utils/extensionBridge'; import { useMutation, UseMutationResult, @@ -94,17 +96,8 @@ export const usePostSignUp = () => { const newToken = data?.data?.token || data?.token; if (newToken) { - localStorage.setItem('token', newToken); - const sendTokenToExtension = (token: string) => { - window.postMessage( - { - type: 'SET_TOKEN', - token, - }, - window.location.origin - ); - }; - sendTokenToExtension(newToken); + authStorage.setAccessToken(newToken); + extensionBridge.syncToken(newToken); } }, onError: (error) => { diff --git a/apps/client/src/shared/apis/setting/axiosInstance.ts b/apps/client/src/shared/apis/setting/axiosInstance.ts index b79cabb4..1998afce 100644 --- a/apps/client/src/shared/apis/setting/axiosInstance.ts +++ b/apps/client/src/shared/apis/setting/axiosInstance.ts @@ -1,4 +1,34 @@ import axios from 'axios'; +import { authStorage } from '@shared/utils/authStorage'; +import { extensionBridge } from '@shared/utils/extensionBridge'; + +const noAuthNeeded = [ + '/api/v1/auth/token', + '/api/v3/auth/signup', + '/api/v3/auth/google', + '/api/v3/auth/reissue', +]; + +const reissueToken = async () => { + return await axios.post( + `${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`, + {}, + { + withCredentials: true, + } + ); +}; + +const syncAccessToken = (token: string) => { + authStorage.setAccessToken(token); + extensionBridge.syncToken(token); +}; + +const clearAuthSessionAndRedirect = () => { + authStorage.clearSession(); + extensionBridge.logout(); + window.location.href = '/onboarding?step=SOCIAL_LOGIN'; +}; // Axios 인스턴스 const apiRequest = axios.create({ @@ -10,7 +40,7 @@ const apiRequest = axios.create({ // 요청 인터셉터 apiRequest.interceptors.request.use(async (config) => { - const token = localStorage.getItem('token'); + const token = authStorage.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; @@ -25,13 +55,6 @@ apiRequest.interceptors.response.use( async (error) => { const originalRequest = error.config; - const noAuthNeeded = [ - '/api/v1/auth/token', - '/api/v3/auth/signup', - '/api/v3/auth/google', - '/api/v3/auth/reissue', - ]; - const isNoAuth = noAuthNeeded.some((url) => originalRequest.url?.includes(url) ); @@ -48,30 +71,21 @@ apiRequest.interceptors.response.use( originalRequest._retry = true; try { - const res = await axios.post( - `${import.meta.env.VITE_BASE_URL}/api/v3/auth/reissue`, - {}, - { - withCredentials: true, - } - ); - - const newAccessToken = res.data.data.token; - localStorage.setItem('token', newAccessToken); - - window.postMessage( - { type: 'SET_TOKEN', token: newAccessToken }, - window.location.origin - ); + const res = await reissueToken(); + const newAccessToken = res.data?.data?.token; + + if (!newAccessToken) { + throw new Error('토큰 재발급 응답에 access token이 없습니다.'); + } + syncAccessToken(newAccessToken); + originalRequest.headers = originalRequest.headers ?? {}; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return apiRequest(originalRequest); } catch (reissueError) { console.error('토큰 재발급 실패. 다시 로그인해주세요.', reissueError); - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - window.location.href = '/onboarding?step=SOCIAL_LOGIN'; + clearAuthSessionAndRedirect(); return Promise.reject(reissueError); } diff --git a/apps/client/src/shared/components/profilePopup/ProfilePopup.tsx b/apps/client/src/shared/components/profilePopup/ProfilePopup.tsx index 4206d722..7ea92d7d 100644 --- a/apps/client/src/shared/components/profilePopup/ProfilePopup.tsx +++ b/apps/client/src/shared/components/profilePopup/ProfilePopup.tsx @@ -2,6 +2,8 @@ import { Icon } from '@pinback/design-system/icons'; import { Button } from '@pinback/design-system/ui'; import { useQueryClient } from '@tanstack/react-query'; import formatRemindTime from '@shared/utils/formatRemindTime'; +import { authStorage } from '@shared/utils/authStorage'; +import { extensionBridge } from '@shared/utils/extensionBridge'; import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -42,19 +44,9 @@ export default function ProfilePopup({ if (!open) return null; const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('email'); - localStorage.removeItem('userId'); + authStorage.clearSession(); queryClient.clear(); - const sendExtensionLogout = () => { - window.postMessage( - { - type: 'Extension-Logout', - }, - window.location.origin - ); - }; - sendExtensionLogout(); + extensionBridge.logout(); navigate('/login'); }; diff --git a/apps/client/src/shared/utils/authStorage.ts b/apps/client/src/shared/utils/authStorage.ts new file mode 100644 index 00000000..1ffafc66 --- /dev/null +++ b/apps/client/src/shared/utils/authStorage.ts @@ -0,0 +1,27 @@ +const AUTH_STORAGE_KEYS = { + token: 'token', + refreshToken: 'refreshToken', + email: 'email', + userId: 'userId', + hasJob: 'hasJob', +} as const; + +export const authStorage = { + getAccessToken: () => localStorage.getItem(AUTH_STORAGE_KEYS.token), + hasAccessToken: () => !!localStorage.getItem(AUTH_STORAGE_KEYS.token), + setAccessToken: (token: string) => + localStorage.setItem(AUTH_STORAGE_KEYS.token, token), + setRefreshToken: (refreshToken: string) => + localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, refreshToken), + setHasJob: (hasJob: boolean) => + localStorage.setItem(AUTH_STORAGE_KEYS.hasJob, String(hasJob)), + setUserIdentity: (email: string, userId: string) => { + localStorage.setItem(AUTH_STORAGE_KEYS.email, email); + localStorage.setItem(AUTH_STORAGE_KEYS.userId, userId); + }, + clearSession: () => { + Object.values(AUTH_STORAGE_KEYS).forEach((key) => { + localStorage.removeItem(key); + }); + }, +}; diff --git a/apps/client/src/shared/utils/extensionBridge.ts b/apps/client/src/shared/utils/extensionBridge.ts new file mode 100644 index 00000000..fb5f74ab --- /dev/null +++ b/apps/client/src/shared/utils/extensionBridge.ts @@ -0,0 +1,21 @@ +import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages'; + +export const extensionBridge = { + syncToken: (token: string) => { + window.postMessage( + { + type: EXTENSION_MESSAGE_TYPE.setToken, + token, + }, + window.location.origin + ); + }, + logout: () => { + window.postMessage( + { + type: EXTENSION_MESSAGE_TYPE.logout, + }, + window.location.origin + ); + }, +}; diff --git a/apps/extension/package.json b/apps/extension/package.json index 34a444b9..f1f7715f 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -11,6 +11,7 @@ "zip": "vite build && node scripts/zip.mjs" }, "dependencies": { + "@pinback/contracts": "workspace:*", "@tanstack/react-query": "^5.85.5", "axios": "^1.11.0", "react": "^19.1.1", diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 16530b84..42b17fcd 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,3 +1,5 @@ +import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages'; + chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install') { chrome.identity.getProfileUserInfo(function (info) { @@ -14,7 +16,7 @@ chrome.runtime.onInstalled.addListener((details) => { }); chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'SET_TOKEN') { + if (message.type === EXTENSION_MESSAGE_TYPE.setToken) { chrome.storage.local.set({ token: message.token }, () => { console.log('Token saved!'); }); @@ -22,7 +24,7 @@ chrome.runtime.onMessage.addListener((message) => { }); chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'Extension-Logout') { + if (message.type === EXTENSION_MESSAGE_TYPE.logout) { chrome.storage.local.remove('token', () => { console.log('Token removed!'); }); diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts index 490c2e3f..92002d60 100644 --- a/apps/extension/src/content.ts +++ b/apps/extension/src/content.ts @@ -1,8 +1,10 @@ +import { EXTENSION_MESSAGE_TYPE } from '@pinback/contracts/extension-messages'; + window.addEventListener('message', (event) => { if (event.source !== window) return; - if (event.data.type === 'SET_TOKEN') { + if (event.data.type === EXTENSION_MESSAGE_TYPE.setToken) { chrome.runtime.sendMessage({ - type: 'SET_TOKEN', + type: EXTENSION_MESSAGE_TYPE.setToken, token: event.data.token, }); chrome.storage.local.set({ token: event.data.token }, () => { @@ -13,7 +15,7 @@ window.addEventListener('message', (event) => { window.addEventListener('message', (event) => { if (event.source !== window) return; - if (event.data.type === 'Extension-Logout') { + if (event.data.type === EXTENSION_MESSAGE_TYPE.logout) { chrome.storage.local.remove('token', () => { console.log('Token removed!'); }); diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..8f66219d --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,16 @@ +{ + "name": "@pinback/contracts", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./extension-messages": "./src/extension-messages.ts" + }, + "scripts": { + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@pinback/typescript-config": "workspace:*", + "typescript": "5.9.2" + } +} diff --git a/packages/contracts/src/extension-messages.ts b/packages/contracts/src/extension-messages.ts new file mode 100644 index 00000000..ef9ef2a7 --- /dev/null +++ b/packages/contracts/src/extension-messages.ts @@ -0,0 +1,16 @@ +export const EXTENSION_MESSAGE_TYPE = { + setToken: 'SET_TOKEN', + logout: 'Extension-Logout', +} as const; + +export type ExtensionMessageType = + (typeof EXTENSION_MESSAGE_TYPE)[keyof typeof EXTENSION_MESSAGE_TYPE]; + +export type ExtensionMessage = + | { + type: typeof EXTENSION_MESSAGE_TYPE.setToken; + token: string; + } + | { + type: typeof EXTENSION_MESSAGE_TYPE.logout; + }; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 00000000..9fa8bc1f --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../typescript-config/base.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "lib": ["ES2022"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe006cc3..205b3994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: apps/client: dependencies: + '@pinback/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@tanstack/react-query': specifier: ^5.85.3 version: 5.85.5(react@19.1.1) @@ -184,6 +187,9 @@ importers: apps/extension: dependencies: + '@pinback/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@tanstack/react-query': specifier: ^5.85.5 version: 5.85.5(react@19.1.1) @@ -346,6 +352,15 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.2(@types/node@22.15.3)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1)) + packages/contracts: + devDependencies: + '@pinback/typescript-config': + specifier: workspace:* + version: link:../typescript-config + typescript: + specifier: 5.9.2 + version: 5.9.2 + packages/design-system: dependencies: '@ncdai/react-wheel-picker':