Skip to content

Commit 4fe170e

Browse files
sumi-0011claude
andauthored
feat: 로그인 끊김/실패 시 복구 UX 개선 (#387)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be1886c commit 4fe170e

11 files changed

Lines changed: 303 additions & 49 deletions

File tree

apps/web/messages/en-US.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,5 +296,25 @@
296296
"report-error-button": "Report Error",
297297
"ranking-error-title": "Unable to load ranking information",
298298
"ranking-error-description": "Please try again later"
299+
},
300+
"Auth": {
301+
"sessionExpiredTitle": "Your session has expired",
302+
"sessionExpiredDescription": "Please sign in again for security.\nYou'll return to your previous page after signing in.",
303+
"sessionExpiredAction": "Sign in again",
304+
"desktopTitle": "Connect desktop app",
305+
"desktopDescription": "The GitAnimals desktop app is requesting GitHub authentication.\nYou'll return to the desktop app once you sign in.",
306+
"desktopContinueButton": "Continue with GitHub",
307+
"desktopAuthenticatedMessage": "Returning to the desktop app…",
308+
"desktopLoadingMessage": "Please wait a moment…",
309+
"desktopErrorBanner": "Something went wrong while signing in. Please try again.",
310+
"desktopInvalidTitle": "Invalid request",
311+
"desktopInvalidDescription": "The redirect_uri is out of the allowed range or a required parameter is missing.",
312+
"errorTitle": "Sign in failed",
313+
"errorDescriptionDefault": "You can try again or go back to the home page.",
314+
"errorDescriptionAccessDenied": "Access was denied. Please try again.",
315+
"errorDescriptionCredentials": "Account verification failed. Please try again in a moment.",
316+
"errorRetryDesktop": "Retry desktop connection",
317+
"errorRetryDefault": "Sign in again",
318+
"errorGoHome": "Back to home"
299319
}
300320
}

apps/web/messages/ko-KR.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,5 +297,25 @@
297297
"report-error-button": "오류 보고하기",
298298
"ranking-error-title": "랭킹 정보를 불러올 수 없습니다",
299299
"ranking-error-description": "잠시 후 다시 시도해주세요"
300+
},
301+
"Auth": {
302+
"sessionExpiredTitle": "세션이 만료되었어요",
303+
"sessionExpiredDescription": "보안을 위해 다시 로그인이 필요해요.\n로그인 후 이전 페이지로 자동 이동합니다.",
304+
"sessionExpiredAction": "다시 로그인",
305+
"desktopTitle": "데스크톱 앱 연결",
306+
"desktopDescription": "GitAnimals 데스크톱 앱이 GitHub 계정 인증을 요청했어요.\n로그인하면 데스크톱 앱으로 자동 복귀해요.",
307+
"desktopContinueButton": "GitHub으로 계속하기",
308+
"desktopAuthenticatedMessage": "데스크톱 앱으로 이동하고 있어요…",
309+
"desktopLoadingMessage": "잠시만 기다려주세요…",
310+
"desktopErrorBanner": "로그인 중 문제가 발생했어요. 다시 시도해주세요.",
311+
"desktopInvalidTitle": "잘못된 요청",
312+
"desktopInvalidDescription": "redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.",
313+
"errorTitle": "로그인에 실패했어요",
314+
"errorDescriptionDefault": "다시 시도하거나 처음으로 돌아갈 수 있어요.",
315+
"errorDescriptionAccessDenied": "접근이 거부되었어요. 다시 시도해주세요.",
316+
"errorDescriptionCredentials": "계정 인증에 실패했어요. 잠시 후 다시 시도해주세요.",
317+
"errorRetryDesktop": "데스크톱 연결 다시 시도",
318+
"errorRetryDefault": "다시 로그인",
319+
"errorGoHome": "처음으로"
300320
}
301321
}

apps/web/src/apis/interceptor.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cache } from 'react';
2-
import { getSession, signOut } from 'next-auth/react';
2+
import { getSession } from 'next-auth/react';
33
import {
44
setRenderRequestInterceptor,
55
setRenderResponseInterceptor,
@@ -11,6 +11,7 @@ import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConf
1111

1212
import { getServerAuth } from '@/auth';
1313
import type { ApiErrorScheme } from '@/exceptions/type';
14+
import { triggerSessionExpired } from '@/utils/sessionExpired';
1415

1516
// Server path: request-scoped memoization via React cache(). Deduped within a
1617
// single render (one JWT decode instead of one per outbound request), and never
@@ -32,6 +33,11 @@ let cachedSession: CachedSession | null = null;
3233
let sessionPromise: Promise<string | null> | null = null;
3334
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
3435

36+
export const clearSessionCache = () => {
37+
cachedSession = null;
38+
sessionPromise = null;
39+
};
40+
3541
const getAccessToken = async (): Promise<string | null> => {
3642
if (typeof window === 'undefined') {
3743
return getServerAccessToken();
@@ -83,27 +89,15 @@ export const interceptorResponseFulfilled = (res: AxiosResponse) => {
8389
};
8490

8591
// Response interceptor
86-
// Latch so a burst of concurrent 401s triggers at most one signOut (which then
87-
// redirects/reloads and resets this module).
88-
let isSigningOut = false;
89-
9092
export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
9193
if (error?.response?.status === 401) {
92-
if (typeof window === 'undefined') {
93-
// Server: surface as a domain exception (unchanged).
94-
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
95-
}
96-
97-
// Client: only sign out an actually-authenticated session whose backend
98-
// token expired. A 401 while logged out (e.g. a background query to an
99-
// authed endpoint like /inboxes) must NOT trigger signOut — that loops:
100-
// signOut → session refetch → re-render → re-query → 401 → signOut → …
101-
const session = await getSession();
102-
if (session?.user?.accessToken && !isSigningOut) {
103-
isSigningOut = true;
104-
signOut();
105-
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
94+
// 캐시 무효화는 환경과 무관하게 먼저 수행 — 만료된 토큰 재사용 방지.
95+
clearSessionCache();
96+
// 복구 UX(세션 만료 다이얼로그)는 클라이언트에서만 띄운다.
97+
if (typeof window !== 'undefined') {
98+
triggerSessionExpired(window.location.pathname + window.location.search);
10699
}
100+
throw new CustomException('TOKEN_EXPIRED', 'token expired');
107101
}
108102

109103
// TODO: 403 처리

apps/web/src/app/[locale]/auth/desktop/page.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { useEffect } from 'react';
44
import { useSearchParams } from 'next/navigation';
5+
import { useTranslations } from 'next-intl';
6+
import { Button } from '@gitanimals/ui-tailwind';
57

68
import { login } from '@/components/AuthButton';
79
import { buildDesktopCallbackUrl, isValidDesktopRedirect } from '@/constants/desktopAuth';
@@ -16,43 +18,60 @@ export default function DesktopAuthPage() {
1618
const params = useSearchParams();
1719
const redirectUri = params.get('redirect_uri');
1820
const state = params.get('state');
21+
const errorCode = params.get('error');
22+
const t = useTranslations('Auth');
1923

2024
const { status, data } = useClientSession();
2125

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

2428
useEffect(() => {
2529
if (!isValid) return;
26-
2730
if (status === 'authenticated' && data?.user?.accessToken) {
2831
window.location.replace(buildDesktopCallbackUrl(redirectUri!, data.user.accessToken, state!));
29-
} else if (status === 'unauthenticated') {
30-
login(
31-
`/auth/desktop?redirect_uri=${encodeURIComponent(redirectUri!)}&state=${encodeURIComponent(state!)}`,
32-
);
3332
}
3433
}, [status, isValid, redirectUri, state, data?.user?.accessToken]);
3534

3635
if (!isValid) {
3736
return (
3837
<div className={pageRootClass}>
3938
<div className={cardClass}>
40-
<h1 className="glyph28-bold text-white mobile:glyph24-bold">잘못된 요청</h1>
41-
<p className="glyph16-regular text-center text-white">
42-
redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.
43-
</p>
39+
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('desktopInvalidTitle')}</h1>
40+
<p className="glyph16-regular text-center text-white-75">{t('desktopInvalidDescription')}</p>
4441
</div>
4542
</div>
4643
);
4744
}
4845

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

5250
return (
5351
<div className={pageRootClass}>
5452
<div className={cardClass}>
55-
<p className="glyph20-regular text-white">{message}</p>
53+
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('desktopTitle')}</h1>
54+
55+
{errorCode && (
56+
<div className="w-full rounded-[8px] bg-[rgba(255,75,75,0.15)] px-[16px] py-[12px] text-center glyph14-regular text-white">
57+
{t('desktopErrorBanner')}
58+
</div>
59+
)}
60+
61+
{status === 'loading' && <p className="glyph20-regular text-white-75">{t('desktopLoadingMessage')}</p>}
62+
63+
{status === 'authenticated' && (
64+
<p className="glyph20-regular text-white-75">{t('desktopAuthenticatedMessage')}</p>
65+
)}
66+
67+
{status === 'unauthenticated' && (
68+
<>
69+
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{t('desktopDescription')}</p>
70+
<Button variant="primary" size="m" onClick={handleLogin}>
71+
{t('desktopContinueButton')}
72+
</Button>
73+
</>
74+
)}
5675
</div>
5776
</div>
5877
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { useRouter, useSearchParams } from 'next/navigation';
5+
import { useTranslations } from 'next-intl';
6+
import { Button } from '@gitanimals/ui-tailwind';
7+
8+
import { login } from '@/components/AuthButton';
9+
import { LOCAL_STORAGE_KEY } from '@/constants/storage';
10+
11+
const DESKTOP_CALLBACK_HINTS = ['/auth/desktop', 'redirect_uri='];
12+
13+
const isDesktopCallback = (value: string | null): value is string => {
14+
if (!value) return false;
15+
return DESKTOP_CALLBACK_HINTS.some((hint) => value.includes(hint));
16+
};
17+
18+
const pageRootClass =
19+
'flex min-h-screen flex-col items-center justify-center p-[24px] text-white bg-[linear-gradient(180deg,#000_0%,#004875_38.51%,#005B93_52.46%,#006FB3_73.8%,#0187DB_100%)] mobile:p-[16px]';
20+
const cardClass =
21+
'flex w-fit min-w-[520px] max-w-full flex-col items-center gap-[16px] rounded-[16px] bg-white-10 p-[40px] backdrop-blur-[7px] mobile:min-w-full mobile:bg-[rgba(255,255,255,0.08)] mobile:px-[16px] mobile:py-[24px]';
22+
23+
export default function AuthErrorPage() {
24+
const params = useSearchParams();
25+
const router = useRouter();
26+
const t = useTranslations('Auth');
27+
28+
const errorCode = params.get('error');
29+
const [savedCallbackUrl, setSavedCallbackUrl] = useState<string | null>(null);
30+
31+
useEffect(() => {
32+
setSavedCallbackUrl(localStorage.getItem(LOCAL_STORAGE_KEY.callbackUrl));
33+
}, []);
34+
35+
const isDesktopFlow = isDesktopCallback(savedCallbackUrl);
36+
37+
const description = (() => {
38+
switch (errorCode) {
39+
case 'AccessDenied':
40+
return t('errorDescriptionAccessDenied');
41+
case 'CredentialsSignin':
42+
return t('errorDescriptionCredentials');
43+
default:
44+
return t('errorDescriptionDefault');
45+
}
46+
})();
47+
48+
const handleRetry = () => {
49+
if (isDesktopFlow && savedCallbackUrl) {
50+
router.replace(savedCallbackUrl);
51+
return;
52+
}
53+
login('/mypage');
54+
};
55+
56+
const handleGoHome = () => {
57+
router.replace('/');
58+
};
59+
60+
return (
61+
<main className={pageRootClass}>
62+
<section className={cardClass}>
63+
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('errorTitle')}</h1>
64+
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{description}</p>
65+
{errorCode && <p className="glyph14-regular text-white-50">code: {errorCode}</p>}
66+
<div className="mt-[8px] flex flex-wrap justify-center gap-[12px]">
67+
<Button variant="primary" size="m" onClick={handleRetry}>
68+
{isDesktopFlow ? t('errorRetryDesktop') : t('errorRetryDefault')}
69+
</Button>
70+
<Button variant="secondary" size="m" onClick={handleGoHome}>
71+
{t('errorGoHome')}
72+
</Button>
73+
</div>
74+
</section>
75+
</main>
76+
);
77+
}

apps/web/src/components/AuthButton.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import { COOKIE_KEY, LOCAL_STORAGE_KEY } from '@/constants/storage';
1111
/**
1212
* client용 로그인 함수
1313
*/
14-
export const login = (callbackUrl: string = '/mypage') => {
15-
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, callbackUrl);
14+
export const login = (callbackUrl?: string) => {
15+
// 인자가 없으면(예: 헤더 로그인 버튼) 미들웨어가 심어둔 딥링크 복귀 경로를 우선 사용한다.
16+
const target = callbackUrl ?? localStorage.getItem(LOCAL_STORAGE_KEY.callbackUrl) ?? '/mypage';
17+
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, target);
1618

1719
// cookie set (client)
1820
const currentLocale = window.location.pathname.split('/')[1];

apps/web/src/components/Global/GlobalComponent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import { createPortal } from 'react-dom';
44
import { Toaster } from 'sonner';
55

66
import FeedBack from './FeedbackForm';
7+
import { LoginCallbackWatcher } from './LoginCallbackWatcher';
8+
import { SessionExpiredDialog } from './SessionExpiredDialog';
79
import { DialogComponent } from './useDialog';
810

911
function GlobalComponent() {
1012
return createPortal(
1113
<>
1214
<FeedBack />
1315
<DialogComponent />
16+
<SessionExpiredDialog />
17+
<LoginCallbackWatcher />
1418
<Toaster
1519
position="top-center"
1620
toastOptions={{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { useSearchParams } from 'next/navigation';
5+
6+
import { LOCAL_STORAGE_KEY } from '@/constants/storage';
7+
8+
// 보호 라우트에서 미들웨어가 홈으로 되돌릴 때 실어 보낸 `?callbackUrl` 을 캡처해
9+
// localStorage 에 저장한다. 사용자가 로그인하면 login()/LoginButton 이 이 값을 읽어
10+
// 원래 목적지로 자동 복귀시킨다. (useSearchParams 구독으로 클라 네비게이션 유입도 감지)
11+
export function LoginCallbackWatcher() {
12+
const searchParams = useSearchParams();
13+
14+
useEffect(() => {
15+
const callbackUrl = searchParams.get('callbackUrl');
16+
if (!callbackUrl) return;
17+
18+
// open-redirect 방지: 외부 절대 URL(`//`, `https://…`)은 무시하고 앱 내부 경로만 허용.
19+
const isInternalPath = callbackUrl.startsWith('/') && !callbackUrl.startsWith('//');
20+
if (isInternalPath) {
21+
localStorage.setItem(LOCAL_STORAGE_KEY.callbackUrl, callbackUrl);
22+
}
23+
24+
const params = new URLSearchParams(searchParams.toString());
25+
params.delete('callbackUrl');
26+
const query = params.toString();
27+
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
28+
}, [searchParams]);
29+
30+
return null;
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
'use client';
2+
3+
import { useTranslations } from 'next-intl';
4+
import { Button, Dialog } from '@gitanimals/ui-tailwind';
5+
import { useAtomValue } from 'jotai';
6+
7+
import { login } from '@/components/AuthButton';
8+
import { sessionExpiredAtom } from '@/utils/sessionExpired';
9+
10+
export function SessionExpiredDialog() {
11+
const { open, callbackUrl } = useAtomValue(sessionExpiredAtom);
12+
const t = useTranslations('Auth');
13+
14+
const handleLogin = () => {
15+
login(callbackUrl ?? '/mypage');
16+
};
17+
18+
return (
19+
<Dialog open={open}>
20+
<Dialog.Content
21+
isShowClose={false}
22+
onEscapeKeyDown={(e) => e.preventDefault()}
23+
onPointerDownOutside={(e) => e.preventDefault()}
24+
onInteractOutside={(e) => e.preventDefault()}
25+
>
26+
<Dialog.Title className="text-left glyph20-regular">{t('sessionExpiredTitle')}</Dialog.Title>
27+
<Dialog.Description className="w-full text-left glyph16-regular text-white-75">
28+
{t('sessionExpiredDescription')}
29+
</Dialog.Description>
30+
<div className="flex w-full justify-end gap-[8px]">
31+
<Button onClick={handleLogin} variant="primary" size="m">
32+
{t('sessionExpiredAction')}
33+
</Button>
34+
</div>
35+
</Dialog.Content>
36+
</Dialog>
37+
);
38+
}

0 commit comments

Comments
 (0)