Skip to content
Merged
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
26 changes: 25 additions & 1 deletion apps/web/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@
"gotcha-in-progress": "Drawing in progress. Please wait a moment.",
"click-card-to-flip": "Click the card to flip!",
"not-enough-points": "You don't have enough points to draw a pet.",
"click-to-close": "Touch the screen to close."
"click-to-close": "Touch the screen to close.",
"pet-gotcha-title": "Pet Gotcha",
"pull-lever": "Tap the egg to hatch your pet!",
"open-pack": "Tap the pack to open it!",
"tap-to-skip": "Tap the screen to skip"
},
"Shop": {
"no-mobile-support": "Currently, mobile is not supported. Please connect to the desktop.",
Expand Down Expand Up @@ -292,5 +296,25 @@
"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.",
"desktopInvalidTitle": "Invalid request",
"desktopInvalidDescription": "The redirect_uri is out of the allowed range or a required parameter is missing.",
"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"
}
}
26 changes: 25 additions & 1 deletion apps/web/messages/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@
"gotcha-in-progress": "뽑기를 진행중입니다. 잠시만 기다려주세요.",
"click-card-to-flip": "카드를 클릭해서 뒤집어주세요!",
"not-enough-points": "포인트가 부족합니다.",
"click-to-close": "결과를 확인하셨다면 화면을 터치해주세요"
"click-to-close": "결과를 확인하셨다면 화면을 터치해주세요",
"pet-gotcha-title": "펫 뽑기",
"pull-lever": "알을 눌러 펫을 부화시켜보세요!",
"open-pack": "카드팩을 눌러 개봉하세요!",
"tap-to-skip": "화면을 탭하면 건너뛰기"
},
"Shop": {
"no-mobile-support": "현재 모바일은 지원하지 않습니다. 데스크탑으로 접속해주세요.",
Expand Down Expand Up @@ -293,5 +297,25 @@
"report-error-button": "오류 보고하기",
"ranking-error-title": "랭킹 정보를 불러올 수 없습니다",
"ranking-error-description": "잠시 후 다시 시도해주세요"
},
"Auth": {
"sessionExpiredTitle": "세션이 만료되었어요",
"sessionExpiredDescription": "보안을 위해 다시 로그인이 필요해요.\n로그인 후 이전 페이지로 자동 이동합니다.",
"sessionExpiredAction": "다시 로그인",
"desktopTitle": "데스크톱 앱 연결",
"desktopDescription": "GitAnimals 데스크톱 앱이 GitHub 계정 인증을 요청했어요.\n로그인하면 데스크톱 앱으로 자동 복귀해요.",
"desktopContinueButton": "GitHub으로 계속하기",
"desktopAuthenticatedMessage": "데스크톱 앱으로 이동하고 있어요…",
"desktopLoadingMessage": "잠시만 기다려주세요…",
"desktopErrorBanner": "로그인 중 문제가 발생했어요. 다시 시도해주세요.",
"desktopInvalidTitle": "잘못된 요청",
"desktopInvalidDescription": "redirect_uri가 허용 범위를 벗어났거나 필수 파라미터가 누락되었습니다.",
"errorTitle": "로그인에 실패했어요",
"errorDescriptionDefault": "다시 시도하거나 처음으로 돌아갈 수 있어요.",
"errorDescriptionAccessDenied": "접근이 거부되었어요. 다시 시도해주세요.",
"errorDescriptionCredentials": "계정 인증에 실패했어요. 잠시 후 다시 시도해주세요.",
"errorRetryDesktop": "데스크톱 연결 다시 시도",
"errorRetryDefault": "다시 로그인",
"errorGoHome": "처음으로"
}
}
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@tanstack/react-query": "*",
"@vercel/analytics": "^1.5.0",
"axios": "^1.6.8",
"canvas-confetti": "^1.9.4",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.1.7",
Expand Down Expand Up @@ -72,6 +73,7 @@
"@storybook/test": "^8.0.9",
"@tanstack/eslint-plugin-query": "^5.28.11",
"@tanstack/react-query-devtools": "^5.32.0",
"@types/canvas-confetti": "^1.9.0",
"@types/google-spreadsheet": "^4.0.0",
"@types/gtag.js": "^0.0.19",
"@types/js-cookie": "^3.0.6",
Expand Down
Binary file added apps/web/public/shop/egg-hatch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 13 additions & 19 deletions apps/web/src/apis/interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cache } from 'react';
import { getSession, signOut } from 'next-auth/react';
import { getSession } from 'next-auth/react';
import {
setRenderRequestInterceptor,
setRenderResponseInterceptor,
Expand All @@ -11,6 +11,7 @@ import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConf

import { getServerAuth } from '@/auth';
import type { ApiErrorScheme } from '@/exceptions/type';
import { triggerSessionExpired } from '@/utils/sessionExpired';

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

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

const getAccessToken = async (): Promise<string | null> => {
if (typeof window === 'undefined') {
return getServerAccessToken();
Expand Down Expand Up @@ -83,27 +89,15 @@ export const interceptorResponseFulfilled = (res: AxiosResponse) => {
};

// Response interceptor
// Latch so a burst of concurrent 401s triggers at most one signOut (which then
// redirects/reloads and resets this module).
let isSigningOut = false;

export const interceptorResponseRejected = async (error: AxiosError<ApiErrorScheme>) => {
if (error?.response?.status === 401) {
if (typeof window === 'undefined') {
// Server: surface as a domain exception (unchanged).
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
}

// Client: only sign out an actually-authenticated session whose backend
// token expired. A 401 while logged out (e.g. a background query to an
// authed endpoint like /inboxes) must NOT trigger signOut — that loops:
// signOut → session refetch → re-render → re-query → 401 → signOut → …
const session = await getSession();
if (session?.user?.accessToken && !isSigningOut) {
isSigningOut = true;
signOut();
throw new CustomException('TOKEN_EXPIRED', 'token expired and sign out success');
// 캐시 무효화는 환경과 무관하게 먼저 수행 — 만료된 토큰 재사용 방지.
clearSessionCache();
// 복구 UX(세션 만료 다이얼로그)는 클라이언트에서만 띄운다.
if (typeof window !== 'undefined') {
triggerSessionExpired(window.location.pathname + window.location.search);
}
throw new CustomException('TOKEN_EXPIRED', 'token expired');
}

// TODO: 403 처리
Expand Down
43 changes: 31 additions & 12 deletions apps/web/src/app/[locale]/auth/desktop/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@gitanimals/ui-tailwind';

import { login } from '@/components/AuthButton';
import { buildDesktopCallbackUrl, isValidDesktopRedirect } from '@/constants/desktopAuth';
Expand All @@ -16,43 +18,60 @@ 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]);

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

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

return (
<div className={pageRootClass}>
<div className={cardClass}>
<p className="glyph20-regular text-white">{message}</p>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('desktopTitle')}</h1>

{errorCode && (
<div className="w-full rounded-[8px] bg-[rgba(255,75,75,0.15)] px-[16px] py-[12px] text-center glyph14-regular text-white">
{t('desktopErrorBanner')}
</div>
)}

{status === 'loading' && <p className="glyph20-regular text-white-75">{t('desktopLoadingMessage')}</p>}

{status === 'authenticated' && (
<p className="glyph20-regular text-white-75">{t('desktopAuthenticatedMessage')}</p>
)}

{status === 'unauthenticated' && (
<>
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{t('desktopDescription')}</p>
<Button variant="primary" size="m" onClick={handleLogin}>
{t('desktopContinueButton')}
</Button>
</>
)}
</div>
</div>
);
Expand Down
77 changes: 77 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,77 @@
'use client';

import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@gitanimals/ui-tailwind';

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

const pageRootClass =
'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]';
const cardClass =
'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]';

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={pageRootClass}>
<section className={cardClass}>
<h1 className="glyph28-bold text-white mobile:glyph24-bold">{t('errorTitle')}</h1>
<p className="whitespace-pre-line text-center glyph16-regular text-white-75">{description}</p>
{errorCode && <p className="glyph14-regular text-white-50">code: {errorCode}</p>}
<div className="mt-[8px] flex flex-wrap justify-center gap-[12px]">
<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>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';

import { useTranslations } from 'next-intl';
import { Dialog } from '@gitanimals/ui-tailwind';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# ui-panda 패키지 내 Dialog 및 Button 컴포넌트 존재 여부 확인
fd -i -t f "dialog|button" packages/ui/panda

Repository: git-goods/git-animal-client

Length of output: 440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant files and the ui-panda package entrypoints/exports.
printf '\n== file list ==\n'
git ls-files \
  'apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx' \
  'apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx' \
  'apps/web/src/components/Global/useDialog.tsx' \
  'packages/ui/panda/src/components/Dialog/Dialog.tsx' \
  'packages/ui/panda/src/components/Button/Button.tsx' \
  'packages/ui/panda/src/index.ts' \
  'packages/ui/panda/src/components/index.ts' \
  'packages/ui/panda/package.json' || true

printf '\n== package.json ==\n'
sed -n '1,220p' packages/ui/panda/package.json

printf '\n== index files ==\n'
for f in packages/ui/panda/src/index.ts packages/ui/panda/src/components/index.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,220p' "$f"
  fi
done

printf '\n== Dialog/Button implementations (heads) ==\n'
for f in packages/ui/panda/src/components/Dialog/Dialog.tsx packages/ui/panda/src/components/Button/Button.tsx; do
  echo "--- $f ---"
  sed -n '1,220p' "$f"
done

printf '\n== relevant app files (selected ranges) ==\n'
for f in \
  apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx \
  apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx \
  apps/web/src/components/Global/useDialog.tsx
do
  echo "--- $f ---"
  nl -ba "$f" | sed -n '1,220p'
done

Repository: git-goods/git-animal-client

Length of output: 7342


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the ui-panda component export surface to see whether Dialog/Button are re-exported.
for f in packages/ui/panda/src/index.ts packages/ui/panda/src/components/index.ts; do
  [ -f "$f" ] || continue
  echo "--- outline $f ---"
  ast-grep outline "$f" --view expanded || true
done

Repository: git-goods/git-animal-client

Length of output: 950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the app code imports from ui-tailwind and whether ui-panda is already used nearby.
rg -n "`@gitanimals/ui-tailwind`|`@gitanimals/ui-panda`" \
  apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx \
  apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx \
  apps/web/src/components/Global/useDialog.tsx

Repository: git-goods/git-animal-client

Length of output: 529


UI 컴포넌트 임포트를 @gitanimals/ui-panda로 바꾸세요

  • apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx
  • apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx
  • apps/web/src/components/Global/useDialog.tsx

DialogButton@gitanimals/ui-tailwind가 아니라 @gitanimals/ui-panda에서 가져오도록 맞추세요.

📍 Affects 3 files
  • apps/web/src/app/[locale]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx#L4-L4 (this comment)
  • apps/web/src/app/[locale]/game/quiz/create/_components/QuizCreateForm.tsx#L6-L6
  • apps/web/src/components/Global/useDialog.tsx#L5-L5
🤖 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]/game/quiz/_components/CreateOrSolve/SelectQuizType.tsx
at line 4, Update imports in SelectQuizType.tsx (line 4), QuizCreateForm.tsx
(line 6), and useDialog.tsx (line 5) so Dialog and Button are sourced from
`@gitanimals/ui-panda` instead of `@gitanimals/ui-tailwind`; no other behavior
changes are needed.

Source: Coding guidelines

import { wrap } from '@suspensive/react';
import { overlay } from 'overlay-kit';

import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ROUTE } from '@/constants/route';
import { useRouter } from '@/i18n/routing';

Expand All @@ -24,33 +24,38 @@ const SelectQuizType = wrap
const router = useRouter();
const { isSolved, quizSolveCard } = useTodayQuizData();
const t = useTranslations('Quiz');
const tCommon = useTranslations('Common');

const handleSolveQuiz = () => {
overlay.open(({ isOpen, close }) => (
<ConfirmDialog
isOpen={isOpen}
onClose={close}
<Dialog.Confirm
open={isOpen}
onOpenChange={(open) => !open && close()}
onConfirm={() => {
handleCheckLanguage();
close();
}}
title={t('solve-todays-quiz')}
description={t('solve-todays-quiz-description')}
confirmText={tCommon('confirm')}
cancelText={tCommon('close')}
/>
));
};

const handleCheckLanguage = () => {
overlay.open(({ isOpen, close }) => (
<ConfirmDialog
isOpen={isOpen}
onClose={close}
<Dialog.Confirm
open={isOpen}
onOpenChange={(open) => !open && close()}
onConfirm={() => {
router.push(ROUTE.GAME.QUIZ.SOLVE());
close();
}}
title={t('check-language-for-quiz-dialog-title')}
description={t('check-language-for-quiz-dialog-description')}
confirmText={tCommon('confirm')}
cancelText={tCommon('close')}
/>
));
};
Expand Down
Loading
Loading