diff --git a/frontend/index.html b/frontend/index.html
index e8231331..aebe5dbf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,16 +1,22 @@
-
+
+
- StackUp
+
+ StackUp — IT Interview Solution
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index cd9f4def..00000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { lazy, Suspense } from 'react'
-import HomePage from '@/pages/Home'
-
-const DesignSystemDemo = lazy(() => import('@/pages/DesignSystem'))
-
-export default function App() {
- const path =
- typeof window !== 'undefined' ? window.location.pathname : '/'
-
- if (path.startsWith('/design-system')) {
- return (
-
-
-
- )
- }
-
- return
-}
diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx
new file mode 100644
index 00000000..6714fbf8
--- /dev/null
+++ b/frontend/src/app/providers/AppProviders.tsx
@@ -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 (
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/app/providers/index.ts b/frontend/src/app/providers/index.ts
index e69de29b..a7f6a1ad 100644
--- a/frontend/src/app/providers/index.ts
+++ b/frontend/src/app/providers/index.ts
@@ -0,0 +1 @@
+export { AppProviders } from './AppProviders'
diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx
new file mode 100644
index 00000000..ee278444
--- /dev/null
+++ b/frontend/src/app/router/index.tsx
@@ -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: },
+ { path: '/login', element: },
+ { path: '/auth/callback', element: },
+ {
+ path: '/workspace',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/design-system/*',
+ lazy: async () => {
+ const mod = await import('@/pages/DesignSystem')
+ return { Component: mod.default }
+ },
+ },
+])
diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts
index 50456a9f..e9302a3f 100644
--- a/frontend/src/features/auth/index.ts
+++ b/frontend/src/features/auth/index.ts
@@ -1,5 +1,6 @@
export { AuthProvider } from './model/AuthProvider'
export { useAuth } from './model/useAuth'
+export { useLogout } from './model/useLogout'
export type { AuthContextValue, AuthStatus } from './model/AuthContext'
export { GithubLoginButton } from './ui/GithubLoginButton'
export { RequireAuth } from './ui/RequireAuth'
diff --git a/frontend/src/features/auth/model/useLogout.ts b/frontend/src/features/auth/model/useLogout.ts
new file mode 100644
index 00000000..098cae0e
--- /dev/null
+++ b/frontend/src/features/auth/model/useLogout.ts
@@ -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 }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 037249fa..93987cda 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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(
-
+
,
)
diff --git a/frontend/src/pages/AuthCallback/index.ts b/frontend/src/pages/AuthCallback/index.ts
new file mode 100644
index 00000000..9a9cdb1d
--- /dev/null
+++ b/frontend/src/pages/AuthCallback/index.ts
@@ -0,0 +1 @@
+export { default } from './ui/AuthCallbackPage'
diff --git a/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx b/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx
new file mode 100644
index 00000000..ea8acaf0
--- /dev/null
+++ b/frontend/src/pages/AuthCallback/ui/AuthCallbackPage.tsx
@@ -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()
+
+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(
+ 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 (
+
+
+ {error ? (
+ <>
+
+ Login failed
+
+
+ 로그인을 완료하지 못했어요
+
+
{error}
+
+ 로그인 페이지로 돌아가기
+
+ >
+ ) : (
+ <>
+
+
+ Authenticating
+
+
+ GitHub 로그인 처리 중…
+
+
+ 잠시만 기다려주세요. 곧 이동합니다.
+
+ >
+ )}
+
+
+ )
+}
+
+function Spinner() {
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/Login/index.ts b/frontend/src/pages/Login/index.ts
index e69de29b..befc7bf0 100644
--- a/frontend/src/pages/Login/index.ts
+++ b/frontend/src/pages/Login/index.ts
@@ -0,0 +1 @@
+export { default } from './ui/LoginPage'
diff --git a/frontend/src/pages/Login/ui/LoginPage.tsx b/frontend/src/pages/Login/ui/LoginPage.tsx
new file mode 100644
index 00000000..dd1e43d5
--- /dev/null
+++ b/frontend/src/pages/Login/ui/LoginPage.tsx
@@ -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(null)
+ const location = useLocation()
+ const { status } = useAuth()
+
+ const returnTo = (location.state as LocationState)?.returnTo ?? null
+
+ useEffect(() => {
+ rememberReturnTo(returnTo)
+ }, [returnTo])
+
+ if (status === 'authenticated') {
+ return
+ }
+
+ return (
+
+
+
+
+
+
+
+ Sign in
+
+
+ 모의 면접을
시작해볼까요?
+
+
+ GitHub 계정으로 로그인하면
+ 당신의 레포지토리 기반 맞춤 면접이 준비됩니다.
+
+
+
+
+
{
+ if (isApiError(err)) {
+ setError(err.message)
+ } else {
+ setError('로그인을 시작할 수 없습니다. 잠시 후 다시 시도해주세요.')
+ }
+ }}
+ />
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ 계속 진행하면 StackUp의{' '}
+ 이용약관과{' '}
+ 개인정보 처리방침에
+ 동의하는 것으로 간주됩니다.
+
+
+
+
+ {features.map((f) => (
+ -
+
+ {f.icon}
+
+
+ {f.title}
+
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+const features = [
+ { icon: '01', title: 'Repo 분석' },
+ { icon: '02', title: 'AI 면접' },
+ { icon: '03', title: '피드백' },
+]
diff --git a/frontend/src/pages/Workspace/index.ts b/frontend/src/pages/Workspace/index.ts
index e69de29b..2f98a28d 100644
--- a/frontend/src/pages/Workspace/index.ts
+++ b/frontend/src/pages/Workspace/index.ts
@@ -0,0 +1 @@
+export { default } from './ui/WorkspacePage'
diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx
new file mode 100644
index 00000000..adb7dd89
--- /dev/null
+++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx
@@ -0,0 +1,51 @@
+import { useAuth, useLogout } from '@/features/auth'
+
+export default function WorkspacePage() {
+ const { user } = useAuth()
+ const { logout, loggingOut } = useLogout()
+
+ return (
+
+ Workspace
+ 이력서·레포 관리 화면이 들어갈 자리입니다.
+
+ {user ? (
+
+ {user.avatarUrl ? (
+
+ ) : null}
+
+
+ @{user.githubUsername}
+
+
+ {user.email ?? 'email 없음'}
+
+
+
+ ) : null}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/widgets/site-nav/ui/SiteNav.tsx b/frontend/src/widgets/site-nav/ui/SiteNav.tsx
index fcc1a775..4e7a357b 100644
--- a/frontend/src/widgets/site-nav/ui/SiteNav.tsx
+++ b/frontend/src/widgets/site-nav/ui/SiteNav.tsx
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useAuth, useLogout } from '@/features/auth'
const items = [
{ href: '#services', label: 'Services' },
@@ -8,6 +10,8 @@ const items = [
export function SiteNav() {
const [scrolled, setScrolled] = useState(false)
+ const { status, user } = useAuth()
+ const { logout, loggingOut } = useLogout()
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8)
@@ -46,19 +50,55 @@ export function SiteNav() {
))}
-
-
- Get Started
-
- →
-
-
+
+ {status === 'authenticated' ? (
+ <>
+
+ {user?.avatarUrl ? (
+

+ ) : null}
+
{user?.githubUsername ?? 'Workspace'}
+
+
+ >
+ ) : (
+ <>
+
+ Login
+
+
+ Get Started
+
+ →
+
+
+ >
+ )}