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
14 changes: 13 additions & 1 deletion frontend/src/features/auth/model/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
type ReactNode,
} from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { isApiError, setAuthSideEffects, tokenStore } from '@/shared/api'
import {
ensureAccessToken,
isApiError,
setAuthSideEffects,
tokenStore,
} from '@/shared/api'
import { fetchCurrentUser, logout as logoutApi } from '../api/auth'
import {
AuthContext,
Expand Down Expand Up @@ -83,6 +88,13 @@ export function AuthProvider({ children }: AuthProviderProps) {

void (async () => {
try {
// 토큰을 refresh 로 먼저 확보한 뒤 사용자 정보를 부른다.
// (토큰 없이 /users/me 를 쏴 401 → refresh → 재시도하던 낭비 제거)
const token = await ensureAccessToken()
if (!token) {
clearAuth()
return
}
await refreshUser()
} catch {
clearAuth()
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/shared/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ function refreshOnce(): Promise<string> {
return refreshing
}

// 앱 부트스트랩용: 토큰이 없으면 refresh 로 먼저 확보한다.
// 이렇게 해야 첫 인증 요청(/api/users/me)이 토큰 없이 나가 401 을 유발하고
// 재시도되는 낭비가 사라진다. 세션이 없으면(refresh 401) null 을 돌려준다.
export async function ensureAccessToken(): Promise<string | null> {
const existing = tokenStore.get()
if (existing) return existing
try {
return await refreshOnce()
} catch (err) {
// 일시적 장애(SYS_DEPENDENCY_DOWN)는 그대로 던져 상위에서 구분 처리.
if (err instanceof ApiError && err.code === 'SYS_DEPENDENCY_DOWN') throw err
// 그 외(세션 없음 등)는 비로그인으로 취급.
return null
}
}

apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiErrorBody>) => {
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/shared/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { apiClient, setAuthSideEffects, type ApiResponse } from './client'
export {
apiClient,
setAuthSideEffects,
ensureAccessToken,
type ApiResponse,
} from './client'
export { tokenStore } from './token-store'
export {
ApiError,
Expand Down
99 changes: 97 additions & 2 deletions frontend/src/widgets/site-nav/ui/SiteNav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth, useLogout } from '@/features/auth'
import { ColorModeToggle } from '@/shared/ui'
Expand All @@ -9,8 +9,38 @@ const items = [
{ to: '/#faq', label: 'FAQ' },
]

function MenuIcon({ open }: { open: boolean }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
>
{open ? (
<>
<path d="M6 6l12 12" />
<path d="M18 6L6 18" />
</>
) : (
<>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</>
)}
</svg>
)
}

export function SiteNav() {
const [scrolled, setScrolled] = useState(false)
const [open, setOpen] = useState(false)
const menuId = useId()
const { status, user } = useAuth()
const { logout, loggingOut } = useLogout()

Expand All @@ -21,11 +51,21 @@ export function SiteNav() {
return () => window.removeEventListener('scroll', onScroll)
}, [])

// Esc 로 닫기.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open])

return (
<header
className={[
'sticky top-0 w-full transition-colors duration-normal ease-standard',
scrolled
scrolled || open
? 'border-b border-border bg-surface-raised/85 backdrop-blur-md'
: 'border-b border-transparent bg-transparent',
].join(' ')}
Expand Down Expand Up @@ -90,8 +130,63 @@ export function SiteNav() {
</Link>
</>
)}

{/* 모바일 메뉴 토글 — 데스크톱 네비/보조 링크가 숨겨지는 구간을 보완 */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={menuId}
aria-label={open ? '메뉴 닫기' : '메뉴 열기'}
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-fg-muted transition-colors duration-fast hover:text-fg-strong md:hidden"
>
<MenuIcon open={open} />
</button>
</div>
</div>

{/* 모바일 드롭다운 메뉴 */}
{open ? (
<nav
id={menuId}
aria-label="Mobile"
className="border-t border-border bg-surface-raised/95 backdrop-blur-md md:hidden"
>
<div className="mx-auto flex max-w-content flex-col gap-1 px-6 py-3">
{items.map((it) => (
<Link
key={it.to}
to={it.to}
onClick={() => setOpen(false)}
className="rounded-md px-3 py-2.5 text-button text-fg-muted transition-colors duration-fast hover:bg-surface hover:text-fg-strong"
>
{it.label}
</Link>
))}
<div className="my-1 h-px bg-border" />
{status === 'authenticated' ? (
<Link
to="/workspace"
onClick={() => setOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2.5 text-button text-fg-strong transition-colors duration-fast hover:bg-surface"
>
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt="" aria-hidden className="h-6 w-6 rounded-full" />
) : null}
<span>{user?.displayName ?? '워크스페이스'}</span>
</Link>
) : (
<Link
to="/login"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-2.5 text-button text-fg-strong transition-colors duration-fast hover:bg-surface"
>
로그인
</Link>
)}
</div>
</nav>
) : null}
</header>
)
}
Loading