diff --git a/apps/web/messages/en_US.json b/apps/web/messages/en_US.json index bf7c3d54..310b2494 100644 --- a/apps/web/messages/en_US.json +++ b/apps/web/messages/en_US.json @@ -279,5 +279,23 @@ "report-error-button": "Report Error", "ranking-error-title": "Unable to load ranking information", "ranking-error-description": "Please try again later" + }, + "Auth": { + "sessionExpiredTitle": "Your session has expired", + "sessionExpiredDescription": "Please sign in again for security.\nYou'll return to your previous page after signing in.", + "sessionExpiredAction": "Sign in again", + "desktopTitle": "Connect desktop app", + "desktopDescription": "The GitAnimals desktop app is requesting GitHub authentication.\nYou'll return to the desktop app once you sign in.", + "desktopContinueButton": "Continue with GitHub", + "desktopAuthenticatedMessage": "Returning to the desktop app…", + "desktopLoadingMessage": "Please wait a moment…", + "desktopErrorBanner": "Something went wrong while signing in. Please try again.", + "errorTitle": "Sign in failed", + "errorDescriptionDefault": "You can try again or go back to the home page.", + "errorDescriptionAccessDenied": "Access was denied. Please try again.", + "errorDescriptionCredentials": "Account verification failed. Please try again in a moment.", + "errorRetryDesktop": "Retry desktop connection", + "errorRetryDefault": "Sign in again", + "errorGoHome": "Back to home" } } diff --git a/apps/web/messages/ko_KR.json b/apps/web/messages/ko_KR.json index 8d6d8ac5..d222c394 100644 --- a/apps/web/messages/ko_KR.json +++ b/apps/web/messages/ko_KR.json @@ -280,5 +280,23 @@ "report-error-button": "오류 보고하기", "ranking-error-title": "랭킹 정보를 불러올 수 없습니다", "ranking-error-description": "잠시 후 다시 시도해주세요" + }, + "Auth": { + "sessionExpiredTitle": "세션이 만료되었어요", + "sessionExpiredDescription": "보안을 위해 다시 로그인이 필요해요.\n로그인 후 이전 페이지로 자동 이동합니다.", + "sessionExpiredAction": "다시 로그인", + "desktopTitle": "데스크톱 앱 연결", + "desktopDescription": "GitAnimals 데스크톱 앱이 GitHub 계정 인증을 요청했어요.\n로그인하면 데스크톱 앱으로 자동 복귀해요.", + "desktopContinueButton": "GitHub으로 계속하기", + "desktopAuthenticatedMessage": "데스크톱 앱으로 이동하고 있어요…", + "desktopLoadingMessage": "잠시만 기다려주세요…", + "desktopErrorBanner": "로그인 중 문제가 발생했어요. 다시 시도해주세요.", + "errorTitle": "로그인에 실패했어요", + "errorDescriptionDefault": "다시 시도하거나 처음으로 돌아갈 수 있어요.", + "errorDescriptionAccessDenied": "접근이 거부되었어요. 다시 시도해주세요.", + "errorDescriptionCredentials": "계정 인증에 실패했어요. 잠시 후 다시 시도해주세요.", + "errorRetryDesktop": "데스크톱 연결 다시 시도", + "errorRetryDefault": "다시 로그인", + "errorGoHome": "처음으로" } } diff --git a/apps/web/src/apis/interceptor.ts b/apps/web/src/apis/interceptor.ts index 61fbb427..4e713f42 100644 --- a/apps/web/src/apis/interceptor.ts +++ b/apps/web/src/apis/interceptor.ts @@ -1,9 +1,10 @@ -import { getSession, signOut } from 'next-auth/react'; +import { getSession } from 'next-auth/react'; import { CustomException } from '@gitanimals/exception'; import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { getServerAuth } from '@/auth'; import type { ApiErrorScheme } from '@/exceptions/type'; +import { triggerSessionExpired } from '@/utils/sessionExpired'; interface CachedSession { accessToken: string; @@ -14,6 +15,11 @@ let cachedSession: CachedSession | null = null; let sessionPromise: Promise | null = null; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +export const clearSessionCache = () => { + cachedSession = null; + sessionPromise = null; +}; + const getSessionWithCache = async (): Promise => { if (cachedSession && Date.now() < cachedSession.expiresAt) { return cachedSession; @@ -69,9 +75,10 @@ export const interceptorResponseFulfilled = (res: AxiosResponse) => { export const interceptorResponseRejected = async (error: AxiosError) => { if (error?.response?.status === 401) { if (typeof window !== 'undefined') { - signOut(); + clearSessionCache(); + triggerSessionExpired(window.location.pathname + window.location.search); } - throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success'); + throw new CustomException('TOKEN_EXPIRED', 'token expired'); } // TODO: 403 처리 diff --git a/apps/web/src/app/[locale]/auth/desktop/page.tsx b/apps/web/src/app/[locale]/auth/desktop/page.tsx index 0f04be89..be0e4f1a 100644 --- a/apps/web/src/app/[locale]/auth/desktop/page.tsx +++ b/apps/web/src/app/[locale]/auth/desktop/page.tsx @@ -2,7 +2,9 @@ import { useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { css } from '_panda/css'; +import { Button } from '@gitanimals/ui-panda'; import { login } from '@/components/AuthButton'; import { buildDesktopCallbackUrl, isValidDesktopRedirect } from '@/constants/desktopAuth'; @@ -12,6 +14,8 @@ export default function DesktopAuthPage() { const params = useSearchParams(); const redirectUri = params.get('redirect_uri'); const state = params.get('state'); + const errorCode = params.get('error'); + const t = useTranslations('Auth'); const { status, data } = useClientSession(); @@ -19,13 +23,8 @@ export default function DesktopAuthPage() { useEffect(() => { if (!isValid) return; - if (status === 'authenticated' && data?.user?.accessToken) { window.location.replace(buildDesktopCallbackUrl(redirectUri!, data.user.accessToken, state!)); - } else if (status === 'unauthenticated') { - login( - `/auth/desktop?redirect_uri=${encodeURIComponent(redirectUri!)}&state=${encodeURIComponent(state!)}`, - ); } }, [status, isValid, redirectUri, state, data?.user?.accessToken]); @@ -34,21 +33,35 @@ export default function DesktopAuthPage() {

잘못된 요청

-

- redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다. -

+

redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.

); } - const message = - status === 'authenticated' ? '데스크톱 앱으로 이동합니다…' : '로그인으로 이동합니다…'; + const handleLogin = () => { + login(`/auth/desktop?redirect_uri=${encodeURIComponent(redirectUri!)}&state=${encodeURIComponent(state!)}`); + }; return (
-

{message}

+

{t('desktopTitle')}

+ + {errorCode &&
{t('desktopErrorBanner')}
} + + {status === 'loading' &&

{t('desktopLoadingMessage')}

} + + {status === 'authenticated' &&

{t('desktopAuthenticatedMessage')}

} + + {status === 'unauthenticated' && ( + <> +

{t('desktopDescription')}

+ + + )}
); @@ -102,4 +115,15 @@ const descCss = css({ textStyle: 'glyph16.regular', color: 'white.white_80', textAlign: 'center', + whiteSpace: 'pre-line', +}); + +const errorBannerCss = css({ + width: '100%', + padding: '12px 16px', + borderRadius: '8px', + background: 'rgba(255, 75, 75, 0.15)', + color: 'white', + textStyle: 'glyph14.regular', + textAlign: 'center', }); diff --git a/apps/web/src/app/[locale]/auth/error/page.tsx b/apps/web/src/app/[locale]/auth/error/page.tsx new file mode 100644 index 00000000..78828ffd --- /dev/null +++ b/apps/web/src/app/[locale]/auth/error/page.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { css } from '_panda/css'; +import { Button } from '@gitanimals/ui-panda'; + +import { login } from '@/components/AuthButton'; +import { LOCAL_STORAGE_KEY } from '@/constants/storage'; + +const DESKTOP_CALLBACK_HINTS = ['/auth/desktop', 'redirect_uri=']; + +const isDesktopCallback = (value: string | null): value is string => { + if (!value) return false; + return DESKTOP_CALLBACK_HINTS.some((hint) => value.includes(hint)); +}; + +export default function AuthErrorPage() { + const params = useSearchParams(); + const router = useRouter(); + const t = useTranslations('Auth'); + + const errorCode = params.get('error'); + const [savedCallbackUrl, setSavedCallbackUrl] = useState(null); + + useEffect(() => { + setSavedCallbackUrl(localStorage.getItem(LOCAL_STORAGE_KEY.callbackUrl)); + }, []); + + const isDesktopFlow = isDesktopCallback(savedCallbackUrl); + + const description = (() => { + switch (errorCode) { + case 'AccessDenied': + return t('errorDescriptionAccessDenied'); + case 'CredentialsSignin': + return t('errorDescriptionCredentials'); + default: + return t('errorDescriptionDefault'); + } + })(); + + const handleRetry = () => { + if (isDesktopFlow && savedCallbackUrl) { + router.replace(savedCallbackUrl); + return; + } + login('/mypage'); + }; + + const handleGoHome = () => { + router.replace('/'); + }; + + return ( +
+
+

{t('errorTitle')}

+

{description}

+ {errorCode &&

code: {errorCode}

} +
+ + +
+
+
+ ); +} + +const pageRootCss = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + padding: '24px', + bg: 'linear-gradient(180deg, #000 0%, #004875 38.51%, #005B93 52.46%, #006FB3 73.8%, #0187DB 100%)', + color: 'white', + _mobile: { padding: '16px' }, +}); + +const cardCss = css({ + borderRadius: '16px', + background: 'rgba(255, 255, 255, 0.1)', + backdropFilter: 'blur(7px)', + padding: '40px', + width: 'fit-content', + minWidth: '520px', + maxWidth: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '16px', + _mobile: { + minWidth: '100%', + padding: '24px 16px', + background: 'rgba(255, 255, 255, 0.08)', + }, +}); + +const titleCss = css({ + textStyle: 'glyph28.bold', + color: 'white', + _mobile: { textStyle: 'glyph24.bold' }, +}); + +const descCss = css({ + textStyle: 'glyph16.regular', + color: 'white.white_80', + textAlign: 'center', + whiteSpace: 'pre-line', +}); + +const errorCodeCss = css({ + textStyle: 'glyph14.regular', + color: 'white.white_60', +}); + +const buttonRowCss = css({ + display: 'flex', + flexWrap: 'wrap', + gap: '12px', + marginTop: '8px', + justifyContent: 'center', +}); diff --git a/apps/web/src/components/Global/GlobalComponent.tsx b/apps/web/src/components/Global/GlobalComponent.tsx index b1504cb4..d4a0d51b 100644 --- a/apps/web/src/components/Global/GlobalComponent.tsx +++ b/apps/web/src/components/Global/GlobalComponent.tsx @@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'; import { Toaster } from 'sonner'; import FeedBack from './FeedbackForm'; +import { SessionExpiredDialog } from './SessionExpiredDialog'; +import { SessionExpiredQueryWatcher } from './SessionExpiredQueryWatcher'; import { DialogComponent } from './useDialog'; function GlobalComponent() { @@ -11,6 +13,8 @@ function GlobalComponent() { <> + + { + login(callbackUrl ?? '/mypage'); + }; + + return ( + + e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {t('sessionExpiredTitle')} + {t('sessionExpiredDescription')} + + + + + + ); +} + +const titleStyle = css({ + textStyle: 'glyph20.regular', + textAlign: 'left', +}); + +const descriptionStyle = css({ + textStyle: 'glyph16.regular', + textAlign: 'left', + color: 'white.white_75', + width: '100%', +}); diff --git a/apps/web/src/components/Global/SessionExpiredQueryWatcher.tsx b/apps/web/src/components/Global/SessionExpiredQueryWatcher.tsx new file mode 100644 index 00000000..21ba8cf7 --- /dev/null +++ b/apps/web/src/components/Global/SessionExpiredQueryWatcher.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; + +import { triggerSessionExpired } from '@/utils/sessionExpired'; + +export function SessionExpiredQueryWatcher() { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('session') !== 'expired') return; + + const callbackUrl = params.get('callbackUrl'); + triggerSessionExpired(callbackUrl); + + params.delete('session'); + params.delete('callbackUrl'); + const query = params.toString(); + const nextUrl = window.location.pathname + (query ? `?${query}` : ''); + window.history.replaceState(null, '', nextUrl); + }, []); + + return null; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 7aaa8ab2..7895cf95 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,5 +1,5 @@ -import { NextRequest } from 'next/server'; -import withAuth from 'next-auth/middleware'; +import { NextRequest, NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; import createMiddleware from 'next-intl/middleware'; import { routing } from './i18n/routing'; @@ -10,14 +10,12 @@ const intlMiddleware = createMiddleware({ ...routing, localeDetection: false, }); -const authMiddleware = withAuth((req) => intlMiddleware(req), { - callbacks: { - authorized: ({ token }) => token != null, - }, - pages: { - signIn: '/', - }, -}); + +const extractLocale = (pathname: string): string | null => { + const segments = pathname.split('/').filter(Boolean); + if (segments.length === 0) return null; + return (routing.locales as readonly string[]).includes(segments[0]) ? segments[0] : null; +}; export default async function middleware(req: NextRequest) { const publicPathnameRegex = RegExp( @@ -25,7 +23,9 @@ export default async function middleware(req: NextRequest) { 'i', ); - const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname); + const authPrefixRegex = RegExp(`^(/(${routing.locales.join('|')}))?/auth(/|$)`, 'i'); + + const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname) || authPrefixRegex.test(req.nextUrl.pathname); // URL 정보를 헤더에 추가한 새로운 요청 생성 const requestHeaders = new Headers(req.headers); @@ -36,12 +36,20 @@ export default async function middleware(req: NextRequest) { }); if (isPublicPage) { - const response = intlMiddleware(modifiedRequest); - return response; - } else { - const response = (authMiddleware as any)(modifiedRequest); - return response; + return intlMiddleware(modifiedRequest); } + + const token = await getToken({ req }); + if (!token) { + const locale = extractLocale(req.nextUrl.pathname) ?? routing.defaultLocale; + const callbackUrl = req.nextUrl.pathname + req.nextUrl.search; + const redirectUrl = new URL(`/${locale}`, req.url); + redirectUrl.searchParams.set('session', 'expired'); + redirectUrl.searchParams.set('callbackUrl', callbackUrl); + return NextResponse.redirect(redirectUrl); + } + + return intlMiddleware(modifiedRequest); } export const config = { diff --git a/apps/web/src/utils/sessionExpired.ts b/apps/web/src/utils/sessionExpired.ts new file mode 100644 index 00000000..599eaed6 --- /dev/null +++ b/apps/web/src/utils/sessionExpired.ts @@ -0,0 +1,33 @@ +import { atom, getDefaultStore } from 'jotai'; + +export interface SessionExpiredState { + open: boolean; + callbackUrl: string | null; +} + +export const sessionExpiredAtom = atom({ + open: false, + callbackUrl: null, +}); + +const isAuthPath = (pathname: string) => pathname.startsWith('/auth') || /^\/[^/]+\/auth(\/|$)/.test(pathname); + +export const triggerSessionExpired = (callbackUrl?: string | null) => { + if (typeof window === 'undefined') return; + + if (isAuthPath(window.location.pathname)) return; + + const store = getDefaultStore(); + const current = store.get(sessionExpiredAtom); + if (current.open) return; + + store.set(sessionExpiredAtom, { + open: true, + callbackUrl: callbackUrl ?? window.location.pathname + window.location.search, + }); +}; + +export const resetSessionExpired = () => { + const store = getDefaultStore(); + store.set(sessionExpiredAtom, { open: false, callbackUrl: null }); +};