-
Notifications
You must be signed in to change notification settings - Fork 14
feat: 로그인 끊김/실패 시 복구 UX 개선 #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,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]); | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 유효성 실패 분기가 한국어 하드코딩이라 영어 로케일에서 안내가 깨집니다.
수정 예시- <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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| </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> | ||||||||||||||||
| ); | ||||||||||||||||
|
|
@@ -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', | ||||||||||||||||
| }); | ||||||||||||||||
| 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', | ||
| }); |
| 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%', | ||
| }); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 쿼리 감시가 최초 1회만 실행되어 이후 현재 구현은 마운트 시점 이후의 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
서버에서 난 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
🤖 Prompt for AI Agents