diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts new file mode 100644 index 00000000..c8f79e8e --- /dev/null +++ b/frontend/src/features/auth/api/auth.ts @@ -0,0 +1,36 @@ +import { apiClient } from '@/shared/api' +import type { + AuthUser, + GithubAuthorizeResponse, + LoginResponse, +} from '../model/types' + +export async function startGithubLogin(): Promise { + const response = await apiClient.post( + '/api/auth/github', + {}, + ) + return response.data +} + +export async function completeGithubLogin( + code: string, + state: string, +): Promise { + const response = await apiClient.get( + '/api/auth/github/callback', + { + params: { code, state }, + }, + ) + return response.data +} + +export async function fetchCurrentUser(): Promise { + const response = await apiClient.get('/api/users/me') + return response.data +} + +export async function logout(): Promise { + await apiClient.delete('/api/auth/logout') +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index e69de29b..50456a9f 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -0,0 +1,17 @@ +export { AuthProvider } from './model/AuthProvider' +export { useAuth } from './model/useAuth' +export type { AuthContextValue, AuthStatus } from './model/AuthContext' +export { GithubLoginButton } from './ui/GithubLoginButton' +export { RequireAuth } from './ui/RequireAuth' +export { + startGithubLogin, + completeGithubLogin, + fetchCurrentUser, + logout, +} from './api/auth' +export type { + AuthUser, + GithubAuthorizeResponse, + LoginResponse, + RefreshResponse, +} from './model/types' diff --git a/frontend/src/features/auth/lib/return-to.ts b/frontend/src/features/auth/lib/return-to.ts new file mode 100644 index 00000000..54378c29 --- /dev/null +++ b/frontend/src/features/auth/lib/return-to.ts @@ -0,0 +1,22 @@ +const KEY = 'stackup.auth.returnTo' + +function isSafePath(value: string | null | undefined): value is string { + if (!value) return false + return value.startsWith('/') && !value.startsWith('//') +} + +export function rememberReturnTo(path: string | null | undefined): void { + if (typeof window === 'undefined') return + if (!isSafePath(path)) { + window.sessionStorage.removeItem(KEY) + return + } + window.sessionStorage.setItem(KEY, path) +} + +export function consumeReturnTo(): string | null { + if (typeof window === 'undefined') return null + const value = window.sessionStorage.getItem(KEY) + window.sessionStorage.removeItem(KEY) + return isSafePath(value) ? value : null +} diff --git a/frontend/src/features/auth/model/AuthContext.ts b/frontend/src/features/auth/model/AuthContext.ts new file mode 100644 index 00000000..7385ff05 --- /dev/null +++ b/frontend/src/features/auth/model/AuthContext.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' +import type { AuthUser, LoginResponse } from './types' + +export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' + +export type AuthContextValue = { + status: AuthStatus + user: AuthUser | null + applyLogin: (response: LoginResponse) => void + logout: () => Promise + refreshUser: () => Promise +} + +export const AuthContext = createContext(null) diff --git a/frontend/src/features/auth/model/AuthProvider.tsx b/frontend/src/features/auth/model/AuthProvider.tsx new file mode 100644 index 00000000..024970e9 --- /dev/null +++ b/frontend/src/features/auth/model/AuthProvider.tsx @@ -0,0 +1,93 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { isApiError, setAuthSideEffects, tokenStore } from '@/shared/api' +import { fetchCurrentUser, logout as logoutApi } from '../api/auth' +import { + AuthContext, + type AuthContextValue, + type AuthStatus, +} from './AuthContext' +import type { AuthUser, LoginResponse } from './types' + +type AuthProviderProps = { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + const bootstrappedRef = useRef(false) + + const applyLogin = useCallback((response: LoginResponse) => { + tokenStore.set(response.accessToken) + setUser(response.user) + setStatus('authenticated') + }, []) + + const clearAuth = useCallback(() => { + tokenStore.clear() + setUser(null) + setStatus('unauthenticated') + }, []) + + const refreshUser = useCallback(async () => { + try { + const me = await fetchCurrentUser() + setUser(me) + setStatus('authenticated') + } catch (error) { + if (isApiError(error) && error.status === 401) { + clearAuth() + return + } + throw error + } + }, [clearAuth]) + + const logout = useCallback(async () => { + try { + await logoutApi() + } finally { + clearAuth() + } + }, [clearAuth]) + + useEffect(() => { + setAuthSideEffects({ + onUnauthorized: () => { + tokenStore.clear() + setUser(null) + setStatus('unauthenticated') + }, + onTokenRefreshed: (token) => { + tokenStore.set(token) + }, + }) + }, []) + + useEffect(() => { + if (bootstrappedRef.current) return + bootstrappedRef.current = true + + void (async () => { + try { + await refreshUser() + } catch { + clearAuth() + } + })() + }, [refreshUser, clearAuth]) + + const value = useMemo( + () => ({ status, user, applyLogin, logout, refreshUser }), + [status, user, applyLogin, logout, refreshUser], + ) + + return {children} +} diff --git a/frontend/src/features/auth/model/types.ts b/frontend/src/features/auth/model/types.ts new file mode 100644 index 00000000..f27db454 --- /dev/null +++ b/frontend/src/features/auth/model/types.ts @@ -0,0 +1,26 @@ +export type AuthUser = { + id: number + githubId: number + githubUsername: string + email: string | null + avatarUrl: string | null +} + +export type GithubAuthorizeResponse = { + authorizationUrl: string + state: string +} + +export type LoginResponse = { + accessToken: string + tokenType: 'Bearer' + expiresIn: number + user: AuthUser + isNewUser: boolean +} + +export type RefreshResponse = { + accessToken: string + tokenType: 'Bearer' + expiresIn: number +} diff --git a/frontend/src/features/auth/model/useAuth.ts b/frontend/src/features/auth/model/useAuth.ts new file mode 100644 index 00000000..50091431 --- /dev/null +++ b/frontend/src/features/auth/model/useAuth.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { AuthContext, type AuthContextValue } from './AuthContext' + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuth must be used inside ') + } + return ctx +} diff --git a/frontend/src/features/auth/ui/GithubLoginButton.tsx b/frontend/src/features/auth/ui/GithubLoginButton.tsx new file mode 100644 index 00000000..b8fb8bf3 --- /dev/null +++ b/frontend/src/features/auth/ui/GithubLoginButton.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { startGithubLogin } from '../api/auth' + +type GithubLoginButtonProps = { + className?: string + label?: string + onError?: (error: unknown) => void +} + +const BASE_CLASS = + 'inline-flex items-center justify-center gap-3 h-12 px-6 rounded-xl bg-sage-900 hover:bg-sage-800 active:bg-sage-700 text-white text-button transition-colors duration-fast disabled:opacity-60 disabled:cursor-not-allowed' + +export function GithubLoginButton({ + className, + label = 'GitHub으로 계속하기', + onError, +}: GithubLoginButtonProps) { + const [loading, setLoading] = useState(false) + + const handleClick = async () => { + if (loading) return + setLoading(true) + try { + const { authorizationUrl } = await startGithubLogin() + window.location.href = authorizationUrl + } catch (error) { + setLoading(false) + onError?.(error) + } + } + + return ( + + ) +} + +function GithubMark() { + return ( + + + + ) +} + +function Spinner() { + return ( + + + + + ) +} diff --git a/frontend/src/features/auth/ui/RequireAuth.tsx b/frontend/src/features/auth/ui/RequireAuth.tsx new file mode 100644 index 00000000..cfc25378 --- /dev/null +++ b/frontend/src/features/auth/ui/RequireAuth.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../model/useAuth' + +type RequireAuthProps = { + children: ReactNode + fallback?: ReactNode +} + +export function RequireAuth({ children, fallback }: RequireAuthProps) { + const { status } = useAuth() + const location = useLocation() + + if (status === 'loading') { + return fallback ?? null + } + + if (status === 'unauthenticated') { + const returnTo = `${location.pathname}${location.search}${location.hash}` + return + } + + return <>{children} +}