diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 356f1868..101cf712 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -34,6 +34,7 @@ import SitePolicyPage from './pages/SitePolicyPage/SitePolicyPage'; import GiftRegisterPage from './pages/GiftRegisterPage'; import GiftIntroPage from './pages/GiftIntroPage'; import { useAuthAtom } from './atoms/useAuthAtom'; +import GlobalErrorBoundary from './components/ErrorBoundary/GlobalErrorBoundary'; setDefaultOptions({ locale: ko }); @@ -138,6 +139,7 @@ const routes: RouteObject[] = [ ), + errorElement: , children: [...publicRoutes, ...privateRoutes], }, ]; diff --git a/apps/admin/src/atoms/useAuthAtom.ts b/apps/admin/src/atoms/useAuthAtom.ts index 090f958d..2506c029 100644 --- a/apps/admin/src/atoms/useAuthAtom.ts +++ b/apps/admin/src/atoms/useAuthAtom.ts @@ -1,6 +1,5 @@ import { LOCAL_STORAGE } from '@boolti/api'; -import { useAtom } from 'jotai'; -import { atomWithStorage, RESET } from 'jotai/utils'; +import { atom, useAtom } from 'jotai'; const storageMethod = { getItem: (key: string, initialValue: string | null) => { @@ -13,15 +12,11 @@ const storageMethod = { window.localStorage.removeItem(key); }, }; -const accessTokenAtom = atomWithStorage( - LOCAL_STORAGE.ACCESS_TOKEN, - window.localStorage.getItem(LOCAL_STORAGE.ACCESS_TOKEN), - storageMethod, +const accessTokenAtom = atom( + storageMethod.getItem(LOCAL_STORAGE.ACCESS_TOKEN, null), ); -const refreshTokenAtom = atomWithStorage( - LOCAL_STORAGE.REFRESH_TOKEN, - window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN), - storageMethod, +const refreshTokenAtom = atom( + storageMethod.getItem(LOCAL_STORAGE.REFRESH_TOKEN, null), ); export const useAuthAtom = () => { @@ -29,13 +24,17 @@ export const useAuthAtom = () => { const [refreshToken, setRefreshToken] = useAtom(refreshTokenAtom); const setToken = (accessToken: string, refreshToken: string) => { + storageMethod.setItem(LOCAL_STORAGE.ACCESS_TOKEN, accessToken); + storageMethod.setItem(LOCAL_STORAGE.REFRESH_TOKEN, refreshToken); setAccessToken(accessToken); setRefreshToken(refreshToken); }; const removeToken = () => { - setAccessToken(RESET); - setRefreshToken(RESET); + storageMethod.removeItem(LOCAL_STORAGE.ACCESS_TOKEN); + storageMethod.removeItem(LOCAL_STORAGE.REFRESH_TOKEN); + setAccessToken(null); + setRefreshToken(null); }; const isLogin = () => !!accessToken && !!refreshToken; diff --git a/apps/admin/src/components/AccountDeleteForm/index.tsx b/apps/admin/src/components/AccountDeleteForm/index.tsx index 4d726c08..29377602 100644 --- a/apps/admin/src/components/AccountDeleteForm/index.tsx +++ b/apps/admin/src/components/AccountDeleteForm/index.tsx @@ -1,7 +1,7 @@ import { Button } from '@boolti/ui'; import Styled from './AccountDeleteForm.styles'; import { useForm } from 'react-hook-form'; -import { useDeleteMe, useLogout } from '@boolti/api'; +import { queryKeys, useDeleteMe, useLogout, useQueryClient } from '@boolti/api'; import { useAuthAtom } from '~/atoms/useAuthAtom'; import { useNavigate } from 'react-router-dom'; import { PATH } from '~/constants/routes'; @@ -20,11 +20,8 @@ const AccountDeleteForm = ({ oauthType, onClose }: AccountDeleteFormProps) => { const deleteMeMutation = useDeleteMe(); const { removeToken } = useAuthAtom(); - const logoutMutation = useLogout({ - onSuccess: () => { - removeToken(); - }, - }); + const queryClient = useQueryClient(); + const logoutMutation = useLogout(); const { register, handleSubmit, @@ -41,12 +38,16 @@ const AccountDeleteForm = ({ oauthType, onClose }: AccountDeleteFormProps) => { appleIdAuthorizationCode = appleAuthData?.authorization.code; } - await deleteMeMutation.mutateAsync({ + deleteMeMutation.mutate({ reason: data.reason, appleIdAuthorizationCode, }); + await logoutMutation.mutateAsync(); + removeToken(); + queryClient.removeQueries({ ...queryKeys.user.summary }); + onClose(); navigate(PATH.INDEX); }; diff --git a/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx b/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx new file mode 100644 index 00000000..9bce1ccc --- /dev/null +++ b/apps/admin/src/components/ErrorBoundary/GlobalErrorBoundary.tsx @@ -0,0 +1,23 @@ +import { isBooltiHTTPError } from '@boolti/api/src/BooltiHTTPError'; +import { useEffect } from 'react'; +import { Navigate, useRouteError } from 'react-router-dom'; +import { PATH } from '~/constants/routes'; + +const GlobalErrorBoundary = () => { + const error = useRouteError(); + + useEffect(() => { + if (error instanceof Error && isBooltiHTTPError(error)) { + const errorMessage = '[BooltiHTTPError] errorTraceId:' + error.errorTraceId + '\n'; + '[BooltiHTTPError] type' + error.type + '\n'; + '[BooltiHTTPError] detail' + error.detail; + console.error(errorMessage); + return; + } + console.error(error); + }); + + return ; +}; + +export default GlobalErrorBoundary; diff --git a/apps/admin/src/components/ProfileDropdown/ProfileDropdown.styles.ts b/apps/admin/src/components/ProfileDropdown/ProfileDropdown.styles.ts index 66dbb6b5..80ae945e 100644 --- a/apps/admin/src/components/ProfileDropdown/ProfileDropdown.styles.ts +++ b/apps/admin/src/components/ProfileDropdown/ProfileDropdown.styles.ts @@ -21,6 +21,12 @@ const UserProfileImageWrapper = styled.div` } `; +const UserProfileIconWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + const UserProfileImage = styled.img` border-radius: 50%; width: 100%; @@ -63,6 +69,7 @@ const DropdownMenuItemButton = styled.button` `; export default { + UserProfileIconWrapper, DropdownContainer, UserProfileImageWrapper, UserProfileImage, diff --git a/apps/admin/src/components/ProfileDropdown/index.tsx b/apps/admin/src/components/ProfileDropdown/index.tsx index 7eed03bf..71aa0675 100644 --- a/apps/admin/src/components/ProfileDropdown/index.tsx +++ b/apps/admin/src/components/ProfileDropdown/index.tsx @@ -1,4 +1,4 @@ -import { useLogout } from '@boolti/api'; +import { queryKeys, useLogout, useQueryClient } from '@boolti/api'; import { ChevronDownIcon, ChevronUpIcon, LogoutIcon, SettingIcon } from '@boolti/icon'; import { useConfirm, useDialog, useDropdown } from '@boolti/ui/src/hooks'; import { useNavigate } from 'react-router-dom'; @@ -39,11 +39,8 @@ const ProfileSVG = () => ( const ProfileDropdown = ({ image, open, disabledDropdown, onClick }: ProfileDropdownProps) => { const { isOpen, toggleDropdown } = useDropdown(); const { removeToken } = useAuthAtom(); - const logoutMutation = useLogout({ - onSuccess: () => { - removeToken(); - }, - }); + const queryClient = useQueryClient(); + const logoutMutation = useLogout(); const navigate = useNavigate(); const confirm = useConfirm(); const settingDialog = useDialog(); @@ -63,7 +60,9 @@ const ProfileDropdown = ({ image, open, disabledDropdown, onClick }: ProfileDrop {image ? : } - {dropdownOpen ? : } + + {dropdownOpen ? : } + {dropdownOpen && !disabledDropdown && ( { const { removeToken } = useAuthAtom(); - const logoutMutation = useLogout({ - onSuccess: () => { - removeToken(); - }, - }); + const logoutMutation = useLogout(); + const queryClient = useQueryClient(); const settingDialog = useDialog(); const confirm = useConfirm(); @@ -98,6 +102,10 @@ const HomePage = () => { }); if (result) { await logoutMutation.mutateAsync(); + + removeToken(); + queryClient.removeQueries({ ...queryKeys.user.summary }); + navigate(PATH.INDEX, { replace: true }); } }} diff --git a/apps/admin/src/pages/Landing/components/Header/Header.styles.ts b/apps/admin/src/pages/Landing/components/Header/Header.styles.ts index 745dd0db..57492b8b 100644 --- a/apps/admin/src/pages/Landing/components/Header/Header.styles.ts +++ b/apps/admin/src/pages/Landing/components/Header/Header.styles.ts @@ -106,7 +106,7 @@ const InternalLink = styled(Link)` const DropDownContainer = styled.div` margin-left: auto; - svg { + .icon-wrapper > svg { color: ${({ theme }) => theme.palette.grey.w}; } `; diff --git a/apps/admin/src/pages/Landing/components/Header/index.tsx b/apps/admin/src/pages/Landing/components/Header/index.tsx index 1a0d66d3..9b6bf285 100644 --- a/apps/admin/src/pages/Landing/components/Header/index.tsx +++ b/apps/admin/src/pages/Landing/components/Header/index.tsx @@ -1,4 +1,4 @@ -import { useLogout, useUserSummary } from '@boolti/api'; +import { queryKeys, useLogout, useQueryClient, useUserSummary } from '@boolti/api'; import { BooltiDark, CloseIcon, MenuIcon } from '@boolti/icon'; import { useTheme } from '@emotion/react'; import { useAtomValue } from 'jotai'; @@ -26,12 +26,9 @@ const Header = () => { const theme = useTheme(); const isMobile = deviceWidth < parseInt(theme.breakpoint.mobile, 10); - const logout = useLogout({ - onSuccess: () => { - removeToken(); - }, - }); + const logoutMutation = useLogout(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [isExpanded, setIsExpanded] = useState(false); const { data } = useUserSummary({ enabled: isLogin() }); @@ -39,7 +36,11 @@ const Header = () => { const onClickAuthButton = async () => { if (isLogin()) { - await logout.mutateAsync(); + await logoutMutation.mutateAsync(); + + removeToken(); + queryClient.removeQueries({ ...queryKeys.user.summary }); + return; } diff --git a/packages/api/src/QueryClientProvider.tsx b/packages/api/src/QueryClientProvider.tsx index efa664e0..cb4a6c5a 100644 --- a/packages/api/src/QueryClientProvider.tsx +++ b/packages/api/src/QueryClientProvider.tsx @@ -11,6 +11,7 @@ export function QueryClientProvider({ children }: React.PropsWithChildren) { queries: { refetchOnWindowFocus: false, retry: false, + staleTime: 5000, useErrorBoundary: (error) => { // 인증 관련 에러일 때만 ErrorBoundary를 사용한다. return ( diff --git a/packages/api/src/fetcher.ts b/packages/api/src/fetcher.ts index d6deffa0..dcc8d01c 100644 --- a/packages/api/src/fetcher.ts +++ b/packages/api/src/fetcher.ts @@ -43,7 +43,7 @@ export const instance = ky.create({ afterResponse: [ async (request, options, response) => { // access token이 만료되었을 때, refresh token으로 새로운 access token을 발급받는다. - if (!response.ok && response.status === 401) { + if (!response.ok && response.status === 401 && !request.url.includes('logout')) { try { const { accessToken, refreshToken } = await postRefreshToken(); diff --git a/packages/api/src/mutations/useKakaoToken.ts b/packages/api/src/mutations/useKakaoToken.ts index 18cd95fc..e6eda7c0 100644 --- a/packages/api/src/mutations/useKakaoToken.ts +++ b/packages/api/src/mutations/useKakaoToken.ts @@ -35,6 +35,8 @@ const postKakaoToken = ({ code }: PostKakaoTokenRequest) => { }; const useKakaoToken = () => - useMutation(({ code }: PostKakaoTokenRequest) => postKakaoToken({ code }), { retry: false }); + useMutation(({ code }: PostKakaoTokenRequest) => postKakaoToken({ code }), { + retry: false, + }); export default useKakaoToken; diff --git a/packages/api/src/mutations/useLogout.ts b/packages/api/src/mutations/useLogout.ts index a2fc6e27..8543248d 100644 --- a/packages/api/src/mutations/useLogout.ts +++ b/packages/api/src/mutations/useLogout.ts @@ -1,22 +1,11 @@ -import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; -import { LOCAL_STORAGE } from '../constants'; import { fetcher } from '../fetcher'; const postLogout = () => fetcher.post('web/v1/logout'); -const useLogout = (options?: UseMutationOptions) => - useMutation(postLogout, { - ...options, - onSuccess: (data, variables, context) => { - if (options?.onSuccess) { - options.onSuccess(data, variables, context); - } else { - window.localStorage.removeItem(LOCAL_STORAGE.ACCESS_TOKEN); - window.localStorage.removeItem(LOCAL_STORAGE.REFRESH_TOKEN); - } - }, - }); +const useLogout = () => { + return useMutation({ mutationFn: postLogout }); +}; export default useLogout; diff --git a/packages/api/src/queries/useUserSummary.ts b/packages/api/src/queries/useUserSummary.ts index 53263029..d80bf1d7 100644 --- a/packages/api/src/queries/useUserSummary.ts +++ b/packages/api/src/queries/useUserSummary.ts @@ -3,6 +3,6 @@ import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '../queryKey'; const useUserSummary = ({ enabled }: { enabled?: boolean } = {}) => - useQuery({ enabled, ...queryKeys.user.summary }); + useQuery({ ...queryKeys.user.summary, enabled }); export default useUserSummary;