-
Notifications
You must be signed in to change notification settings - Fork 2
Feature/auth routing #21
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
Changes from all commits
b8fd89b
e0a692d
d5ee3f5
545414a
deeef84
a788c5b
eb2aa3e
b553f3e
3f060c8
1b0f320
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { Suspense } from 'react' | ||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
| import { RouterProvider } from 'react-router-dom' | ||
| import { AuthProvider } from '@/features/auth' | ||
| import { router } from '@/app/router' | ||
|
|
||
| const queryClient = new QueryClient({ | ||
| defaultOptions: { | ||
| queries: { | ||
| retry: 1, | ||
| refetchOnWindowFocus: false, | ||
| staleTime: 30_000, | ||
| }, | ||
| }, | ||
| }) | ||
|
|
||
| export function AppProviders() { | ||
| return ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <AuthProvider> | ||
| <Suspense fallback={null}> | ||
| <RouterProvider router={router} /> | ||
| </Suspense> | ||
| </AuthProvider> | ||
| </QueryClientProvider> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { AppProviders } from './AppProviders' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { createBrowserRouter } from 'react-router-dom' | ||
| import { RequireAuth } from '@/features/auth' | ||
| import HomePage from '@/pages/Home' | ||
| import LoginPage from '@/pages/Login' | ||
| import AuthCallbackPage from '@/pages/AuthCallback' | ||
| import WorkspacePage from '@/pages/Workspace' | ||
|
|
||
| export const router = createBrowserRouter([ | ||
| { path: '/', element: <HomePage /> }, | ||
| { path: '/login', element: <LoginPage /> }, | ||
| { path: '/auth/callback', element: <AuthCallbackPage /> }, | ||
| { | ||
| path: '/workspace', | ||
| element: ( | ||
| <RequireAuth> | ||
| <WorkspacePage /> | ||
| </RequireAuth> | ||
| ), | ||
| }, | ||
| { | ||
| path: '/design-system/*', | ||
| lazy: async () => { | ||
| const mod = await import('@/pages/DesignSystem') | ||
| return { Component: mod.default } | ||
| }, | ||
| }, | ||
| ]) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { useCallback, useState } from 'react' | ||
| import { useAuth } from './useAuth' | ||
|
|
||
| export function useLogout() { | ||
| const { logout: logoutFromAuth } = useAuth() | ||
| const [loggingOut, setLoggingOut] = useState(false) | ||
|
|
||
| const logout = useCallback(async () => { | ||
| if (loggingOut) return | ||
| setLoggingOut(true) | ||
| try { | ||
| await logoutFromAuth() | ||
| } finally { | ||
| setLoggingOut(false) | ||
| } | ||
| }, [loggingOut, logoutFromAuth]) | ||
|
|
||
| return { logout, loggingOut } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,10 @@ | ||
| import { StrictMode } from 'react' | ||
| import { createRoot } from 'react-dom/client' | ||
| import '@/app/styles' | ||
| import App from './App.tsx' | ||
| import { AppProviders } from '@/app/providers' | ||
|
|
||
| createRoot(document.getElementById('root')!).render( | ||
| <StrictMode> | ||
| <App /> | ||
| <AppProviders /> | ||
| </StrictMode>, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './ui/AuthCallbackPage' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { useEffect, useState } from 'react' | ||
| import { Link, useNavigate, useSearchParams } from 'react-router-dom' | ||
| import { completeGithubLogin, useAuth } from '@/features/auth' | ||
| import { consumeReturnTo } from '@/features/auth/lib/return-to' | ||
| import { isApiError } from '@/shared/api' | ||
|
|
||
| const consumedCodes = new Set<string>() | ||
|
|
||
| export default function AuthCallbackPage() { | ||
| const [params] = useSearchParams() | ||
| const navigate = useNavigate() | ||
| const { applyLogin } = useAuth() | ||
|
|
||
| const code = params.get('code') | ||
| const stateParam = params.get('state') | ||
| const missingParams = !code || !stateParam | ||
|
|
||
| const [error, setError] = useState<string | null>( | ||
| missingParams ? 'GitHub 응답에 code 또는 state가 없습니다.' : null, | ||
| ) | ||
|
|
||
| useEffect(() => { | ||
| if (missingParams) return | ||
| const key = `${code}:${stateParam}` | ||
| if (consumedCodes.has(key)) return | ||
| consumedCodes.add(key) | ||
|
|
||
| void (async () => { | ||
| try { | ||
| const response = await completeGithubLogin(code!, stateParam!) | ||
| applyLogin(response) | ||
| const dest = consumeReturnTo() ?? '/' | ||
| navigate(dest, { replace: true }) | ||
| } catch (err) { | ||
| if (isApiError(err)) { | ||
| setError(err.message) | ||
| } else { | ||
| setError('로그인 처리 중 오류가 발생했습니다.') | ||
| } | ||
| } | ||
| })() | ||
| }, [missingParams, code, stateParam, applyLogin, navigate]) | ||
|
|
||
| return ( | ||
| <div className="min-h-svh bg-bg text-fg flex items-center justify-center px-6 py-16"> | ||
| <div className="w-full max-w-md text-center"> | ||
| {error ? ( | ||
| <> | ||
| <p className="text-caption font-mono uppercase tracking-[0.22em] text-danger-700"> | ||
| Login failed | ||
| </p> | ||
| <h1 className="mt-3 font-heading font-extrabold text-sage-900 text-h4"> | ||
| 로그인을 완료하지 못했어요 | ||
| </h1> | ||
| <p className="mt-3 text-fg-strong/80 leading-relaxed">{error}</p> | ||
| <Link | ||
| to="/login" | ||
| replace | ||
| className="mt-8 inline-flex items-center justify-center gap-2 h-11 px-6 rounded-pill bg-sage-900 hover:bg-sage-800 text-white text-button transition-colors duration-fast" | ||
| > | ||
| 로그인 페이지로 돌아가기 | ||
| </Link> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <Spinner /> | ||
| <p className="mt-6 text-caption font-mono uppercase tracking-[0.22em] text-sage-500"> | ||
| Authenticating | ||
| </p> | ||
| <h1 className="mt-2 font-heading font-extrabold text-sage-900 text-h4"> | ||
| GitHub 로그인 처리 중… | ||
| </h1> | ||
| <p className="mt-3 text-fg-strong/80"> | ||
| 잠시만 기다려주세요. 곧 이동합니다. | ||
| </p> | ||
| </> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| function Spinner() { | ||
| return ( | ||
| <svg | ||
| aria-hidden | ||
| width="40" | ||
| height="40" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth="2.25" | ||
| className="animate-spin mx-auto text-sage-500" | ||
| > | ||
| <circle cx="12" cy="12" r="9" strokeOpacity="0.2" /> | ||
| <path d="M21 12a9 9 0 0 0-9-9" strokeLinecap="round" /> | ||
| </svg> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './ui/LoginPage' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { useEffect, useState } from 'react' | ||
| import { Link, Navigate, useLocation } from 'react-router-dom' | ||
| import { GithubLoginButton, useAuth } from '@/features/auth' | ||
| import { rememberReturnTo } from '@/features/auth/lib/return-to' | ||
| import { isApiError } from '@/shared/api' | ||
|
|
||
| type LocationState = { returnTo?: string } | null | ||
|
|
||
| export default function LoginPage() { | ||
| const [error, setError] = useState<string | null>(null) | ||
| const location = useLocation() | ||
| const { status } = useAuth() | ||
|
|
||
| const returnTo = (location.state as LocationState)?.returnTo ?? null | ||
|
|
||
| useEffect(() => { | ||
| rememberReturnTo(returnTo) | ||
| }, [returnTo]) | ||
|
|
||
| if (status === 'authenticated') { | ||
| return <Navigate to={returnTo ?? '/'} replace /> | ||
| } | ||
|
|
||
| return ( | ||
| <div className="min-h-svh bg-bg text-fg flex flex-col"> | ||
| <header className="sticky top-0 z-10 bg-bg/85 backdrop-blur-md border-b border-border/60"> | ||
| <div className="mx-auto max-w-content px-6 lg:px-12 h-16 flex items-center justify-between"> | ||
| <Link | ||
| to="/" | ||
| className="font-heading font-extrabold tracking-[0.04em] text-sage-900 text-[15px] uppercase" | ||
| > | ||
| Stack Up | ||
| </Link> | ||
| <Link | ||
| to="/" | ||
| className="text-button text-fg-strong/80 hover:text-fg-strong transition-colors duration-fast" | ||
| > | ||
| ← 홈으로 | ||
| </Link> | ||
| </div> | ||
| </header> | ||
|
|
||
| <main className="flex-1 flex items-center justify-center px-6 py-16"> | ||
| <div className="w-full max-w-md"> | ||
| <div className="text-center"> | ||
| <p className="text-caption font-mono uppercase tracking-[0.22em] text-sage-500"> | ||
| Sign in | ||
| </p> | ||
| <h1 | ||
| className="mt-4 font-heading font-extrabold text-sage-900 leading-[1.05]" | ||
| style={{ fontSize: 'clamp(36px, 5vw, 52px)', letterSpacing: '-0.5px' }} | ||
| > | ||
| 모의 면접을<br />시작해볼까요? | ||
| </h1> | ||
| <p className="mt-5 text-fg-strong/80 leading-relaxed"> | ||
| GitHub 계정으로 로그인하면<br /> | ||
| 당신의 레포지토리 기반 맞춤 면접이 준비됩니다. | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="mt-10 bg-surface-raised rounded-2xl border border-border shadow-md p-8"> | ||
| <GithubLoginButton | ||
| className="w-full" | ||
| onError={(err) => { | ||
| if (isApiError(err)) { | ||
| setError(err.message) | ||
|
Member
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. 우선 백엔드에서 던진 메세지를 그대로 전달하는군요
Contributor
Author
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. MVP구현단계에선 빠른 출시,개발이 중요해서 콘솔로그나 서비스가 터지지않는 에러처리를 하고 있지만, 여유가 생긴다면 logger를 도입해서 로깅시스템과 대시보드 등을 구축하면 좋을 것 같습니다. |
||
| } else { | ||
| setError('로그인을 시작할 수 없습니다. 잠시 후 다시 시도해주세요.') | ||
| } | ||
| }} | ||
| /> | ||
|
|
||
| {error ? ( | ||
| <div | ||
| role="alert" | ||
| className="mt-4 rounded-md border border-danger-500/30 bg-danger-50 px-3 py-2 text-caption text-danger-700" | ||
| > | ||
| {error} | ||
| </div> | ||
| ) : null} | ||
|
|
||
| <p className="mt-6 text-caption text-fg-muted text-center leading-relaxed"> | ||
| 계속 진행하면 StackUp의{' '} | ||
| <a href="#" className="underline hover:text-fg-strong">이용약관</a>과{' '} | ||
| <a href="#" className="underline hover:text-fg-strong">개인정보 처리방침</a>에 | ||
| 동의하는 것으로 간주됩니다. | ||
| </p> | ||
| </div> | ||
|
|
||
| <ul className="mt-10 grid grid-cols-3 gap-4 text-center"> | ||
| {features.map((f) => ( | ||
| <li key={f.title}> | ||
| <div className="text-h6 font-heading font-extrabold text-sage-700"> | ||
| {f.icon} | ||
| </div> | ||
| <div className="mt-2 text-caption font-mono uppercase tracking-[0.18em] text-sage-500"> | ||
| {f.title} | ||
| </div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| </main> | ||
|
|
||
| <footer className="border-t border-border"> | ||
| <div className="mx-auto max-w-content px-6 lg:px-12 h-14 flex items-center justify-between text-caption font-mono text-fg-muted"> | ||
| <span>© 2026 StackUp · CNU 종합설계</span> | ||
| <Link to="/" className="hover:text-fg-strong transition-colors duration-fast"> | ||
| ← Back to home | ||
| </Link> | ||
| </div> | ||
| </footer> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const features = [ | ||
| { icon: '01', title: 'Repo 분석' }, | ||
| { icon: '02', title: 'AI 면접' }, | ||
| { icon: '03', title: '피드백' }, | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './ui/WorkspacePage' |
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.
이 부분은 tailwind 대신 인라인 스타일을 쓰셨군요
임시라면 상관없겠지만 하나로 통일하는게 좋아보입니다.
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.
이 부분은 애매하긴 하네요. 사실 taliwind의 한계때문에 이렇게 써서 tailwind로 사용하려면 토큰화 시켜야하긴하는데 이부분은 좀 더 생각해보겠습니다.