Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/messages/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
18 changes: 18 additions & 0 deletions apps/web/messages/ko_KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "처음으로"
}
}
13 changes: 10 additions & 3 deletions apps/web/src/apis/interceptor.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +15,11 @@ let cachedSession: CachedSession | null = null;
let sessionPromise: Promise<CachedSession | null> | null = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

export const clearSessionCache = () => {
cachedSession = null;
sessionPromise = null;
};

const getSessionWithCache = async (): Promise<CachedSession | null> => {
if (cachedSession && Date.now() < cachedSession.expiresAt) {
return cachedSession;
Expand Down Expand Up @@ -69,9 +75,10 @@ export const interceptorResponseFulfilled = (res: AxiosResponse) => {
export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
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');
Comment on lines 76 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

서버에서 난 401은 세션 캐시를 비우지 못합니다.

지금은 clearSessionCache() 가 브라우저 분기 안에만 있어서, 서버 렌더링/서버 호출에서 401이 발생하면 같은 워커가 최대 5분 동안 만료된 access token을 계속 재사용할 수 있습니다. 캐시 무효화는 환경과 무관하게 먼저 수행하고, triggerSessionExpired() 만 클라이언트에서 호출해야 합니다.

🔧 제안 수정
 export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
   if (error?.response?.status === 401) {
-    if (typeof window !== 'undefined') {
-      clearSessionCache();
+    clearSessionCache();
+    if (typeof window !== 'undefined') {
       triggerSessionExpired(window.location.pathname + window.location.search);
     }
     throw new CustomException('TOKEN_EXPIRED', 'token expired');
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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');
if (error?.response?.status === 401) {
clearSessionCache();
if (typeof window !== 'undefined') {
triggerSessionExpired(window.location.pathname + window.location.search);
}
throw new CustomException('TOKEN_EXPIRED', 'token expired');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/apis/interceptor.ts` around lines 76 - 81, The 401 handling
currently only clears session cache inside the browser branch so server-side
401s keep using an expired token; move the call to clearSessionCache() so it
runs unconditionally when error?.response?.status === 401 (before the typeof
window check), keep triggerSessionExpired(window.location.pathname +
window.location.search) only inside the client branch, and still throw the same
CustomException('TOKEN_EXPIRED', 'token expired') afterward; update the block
around the error?.response?.status === 401 check to call clearSessionCache() for
all environments and call triggerSessionExpired() only when typeof window !==
'undefined'.

}

// TODO: 403 처리
Expand Down
46 changes: 35 additions & 11 deletions apps/web/src/app/[locale]/auth/desktop/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,20 +14,17 @@ 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();

const isValid = isValidDesktopRedirect(redirectUri) && !!state;

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]);

Expand All @@ -34,21 +33,35 @@ export default function DesktopAuthPage() {
<div className={pageRootCss}>
<div className={cardCss}>
<h1 className={titleCss}>잘못된 요청</h1>
<p className={descCss}>
redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.
</p>
<p className={descCss}>redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.</p>
Comment on lines 35 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

유효성 실패 분기가 한국어 하드코딩이라 영어 로케일에서 안내가 깨집니다.

useTranslations('Auth')를 이미 쓰고 있으므로 이 분기도 번역 키를 사용해 로케일 일관성을 맞춰주세요.

수정 예시
-          <h1 className={titleCss}>잘못된 요청</h1>
-          <p className={descCss}>redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.</p>
+          <h1 className={titleCss}>{t('desktopInvalidRequestTitle')}</h1>
+          <p className={descCss}>{t('desktopInvalidRequestDescription')}</p>

추가 키 예시:

// apps/web/messages/en_US.json
"Auth": {
+  "desktopInvalidRequestTitle": "Invalid request",
+  "desktopInvalidRequestDescription": "The redirect_uri is out of the allowed range or a required parameter is missing.",
  ...
}
// apps/web/messages/ko_KR.json
"Auth": {
+  "desktopInvalidRequestTitle": "잘못된 요청",
+  "desktopInvalidRequestDescription": "redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.",
  ...
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<h1 className={titleCss}>잘못된 요청</h1>
<p className={descCss}>
redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.
</p>
<p className={descCss}>redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.</p>
<h1 className={titleCss}>{t('desktopInvalidRequestTitle')}</h1>
<p className={descCss}>{t('desktopInvalidRequestDescription')}</p>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/`[locale]/auth/desktop/page.tsx around lines 35 - 36, The
hardcoded Korean strings for the error branch should be replaced with
translation keys via the existing useTranslations('Auth') hook: swap the literal
h1 and p contents with t('invalidRequest.title') and
t('invalidRequest.description') (or similar keys you add) while keeping the
existing className props (titleCss and descCss); ensure those keys are added to
the Auth locale files for both Korean and English so the error UI displays in
the current locale.

</div>
</div>
);
}

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

return (
<div className={pageRootCss}>
<div className={cardCss}>
<p className={loadingTextCss}>{message}</p>
<h1 className={titleCss}>{t('desktopTitle')}</h1>

{errorCode && <div className={errorBannerCss}>{t('desktopErrorBanner')}</div>}

{status === 'loading' && <p className={loadingTextCss}>{t('desktopLoadingMessage')}</p>}

{status === 'authenticated' && <p className={loadingTextCss}>{t('desktopAuthenticatedMessage')}</p>}

{status === 'unauthenticated' && (
<>
<p className={descCss}>{t('desktopDescription')}</p>
<Button variant="primary" size="m" onClick={handleLogin}>
{t('desktopContinueButton')}
</Button>
</>
)}
</div>
</div>
);
Expand Down Expand Up @@ -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',
});
130 changes: 130 additions & 0 deletions apps/web/src/app/[locale]/auth/error/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<main className={pageRootCss}>
<section className={cardCss}>
<h1 className={titleCss}>{t('errorTitle')}</h1>
<p className={descCss}>{description}</p>
{errorCode && <p className={errorCodeCss}>code: {errorCode}</p>}
<div className={buttonRowCss}>
<Button variant="primary" size="m" onClick={handleRetry}>
{isDesktopFlow ? t('errorRetryDesktop') : t('errorRetryDefault')}
</Button>
<Button variant="secondary" size="m" onClick={handleGoHome}>
{t('errorGoHome')}
</Button>
</div>
</section>
</main>
);
}

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',
});
4 changes: 4 additions & 0 deletions apps/web/src/components/Global/GlobalComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ 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() {
return createPortal(
<>
<FeedBack />
<DialogComponent />
<SessionExpiredDialog />
<SessionExpiredQueryWatcher />
<Toaster
position="top-center"
toastOptions={{
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/Global/SessionExpiredDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import { useTranslations } from 'next-intl';
import { css } from '_panda/css';
import { Flex } from '_panda/jsx';
import { Button, Dialog } from '@gitanimals/ui-panda';
import { useAtomValue } from 'jotai';

import { login } from '@/components/AuthButton';
import { sessionExpiredAtom } from '@/utils/sessionExpired';

export function SessionExpiredDialog() {
const { open, callbackUrl } = useAtomValue(sessionExpiredAtom);
const t = useTranslations('Auth');

const handleLogin = () => {
login(callbackUrl ?? '/mypage');
};

return (
<Dialog open={open}>
<Dialog.Content
isShowClose={false}
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Dialog.Title className={titleStyle}>{t('sessionExpiredTitle')}</Dialog.Title>
<Dialog.Description className={descriptionStyle}>{t('sessionExpiredDescription')}</Dialog.Description>
<Flex gap="8px" justifyContent="flex-end" width="100%">
<Button onClick={handleLogin} variant="primary" size="m">
{t('sessionExpiredAction')}
</Button>
</Flex>
</Dialog.Content>
</Dialog>
);
}

const titleStyle = css({
textStyle: 'glyph20.regular',
textAlign: 'left',
});

const descriptionStyle = css({
textStyle: 'glyph16.regular',
textAlign: 'left',
color: 'white.white_75',
width: '100%',
});
23 changes: 23 additions & 0 deletions apps/web/src/components/Global/SessionExpiredQueryWatcher.tsx
Original file line number Diff line number Diff line change
@@ -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);
}, []);
Comment on lines +8 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

쿼리 감시가 최초 1회만 실행되어 이후 session=expired 유입을 놓칠 수 있습니다.

현재 구현은 마운트 시점 이후의 URL 쿼리 변경을 감지하지 못합니다. 전역 watcher라면 쿼리 변경 시에도 재평가되도록 의존성을 연결하는 편이 안전합니다.

수정 예시
 'use client';
 
 import { useEffect } from 'react';
+import { useSearchParams } from 'next/navigation';
 
 import { triggerSessionExpired } from '@/utils/sessionExpired';
 
 export function SessionExpiredQueryWatcher() {
+  const searchParams = useSearchParams();
+
   useEffect(() => {
-    const params = new URLSearchParams(window.location.search);
+    const params = new URLSearchParams(searchParams.toString());
     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}` : '');
+    const nextUrl = window.location.pathname + (query ? `?${query}` : '') + window.location.hash;
     window.history.replaceState(null, '', nextUrl);
-  }, []);
+  }, [searchParams]);
 
   return null;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}, []);
'use client';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { triggerSessionExpired } from '@/utils/sessionExpired';
export function SessionExpiredQueryWatcher() {
const searchParams = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
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.location.hash;
window.history.replaceState(null, '', nextUrl);
}, [searchParams]);
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/Global/SessionExpiredQueryWatcher.tsx` around lines 8
- 20, The effect only runs once on mount so it misses later query changes;
update the watcher so it re-evaluates when the URL query changes by basing the
effect on a stable location search value (e.g. use the router's
useLocation().search or otherwise pass window.location.search as a dependency)
and/or listen for navigation events; modify the useEffect that currently calls
URLSearchParams, triggerSessionExpired, and window.history.replaceState to
depend on that location search value (or add a popstate listener) so
session=expired is handled whenever the query changes.


return null;
}
Loading
Loading