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
36 changes: 36 additions & 0 deletions frontend/src/features/auth/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { apiClient } from '@/shared/api'
import type {
AuthUser,
GithubAuthorizeResponse,
LoginResponse,
} from '../model/types'

export async function startGithubLogin(): Promise<GithubAuthorizeResponse> {
const response = await apiClient.post<GithubAuthorizeResponse>(
'/api/auth/github',
{},
)
return response.data
}

export async function completeGithubLogin(
code: string,
state: string,
): Promise<LoginResponse> {
const response = await apiClient.get<LoginResponse>(
'/api/auth/github/callback',
{
params: { code, state },
},
)
return response.data
}

export async function fetchCurrentUser(): Promise<AuthUser> {
const response = await apiClient.get<AuthUser>('/api/users/me')
return response.data
}

export async function logout(): Promise<void> {
await apiClient.delete('/api/auth/logout')
}
17 changes: 17 additions & 0 deletions frontend/src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'
22 changes: 22 additions & 0 deletions frontend/src/features/auth/lib/return-to.ts
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions frontend/src/features/auth/model/AuthContext.ts
Original file line number Diff line number Diff line change
@@ -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<void>
refreshUser: () => Promise<void>
}

export const AuthContext = createContext<AuthContextValue | null>(null)
93 changes: 93 additions & 0 deletions frontend/src/features/auth/model/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthStatus>('loading')
const [user, setUser] = useState<AuthUser | null>(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<AuthContextValue>(
() => ({ status, user, applyLogin, logout, refreshUser }),
[status, user, applyLogin, logout, refreshUser],
)

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
26 changes: 26 additions & 0 deletions frontend/src/features/auth/model/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions frontend/src/features/auth/model/useAuth.ts
Original file line number Diff line number Diff line change
@@ -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 <AuthProvider>')
}
return ctx
}
76 changes: 76 additions & 0 deletions frontend/src/features/auth/ui/GithubLoginButton.tsx
Original file line number Diff line number Diff line change
@@ -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 =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

비슷한 버튼이 늘어난다면 shared 로 뺴는 것도 괜찮아보입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

동의합니다. 두번까지는 각각 사용해도 문제가 없다고 생각하고 3번정도 겹칠 때 분리를 고려하면 좋을 것 같아요.

'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 (
<button
type="button"
className={[BASE_CLASS, className].filter(Boolean).join(' ')}
onClick={handleClick}
disabled={loading}
aria-busy={loading}
>
{loading ? <Spinner /> : <GithubMark />}
<span>{loading ? '이동 중…' : label}</span>
</button>
)
}

function GithubMark() {
return (
<svg
aria-hidden
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.111.82-.261.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.93 0-1.31.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.553 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.371.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .319.218.694.825.576C20.565 22.092 24 17.594 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}

function Spinner() {
return (
<svg
aria-hidden
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="animate-spin"
>
<circle cx="12" cy="12" r="9" strokeOpacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" strokeLinecap="round" />
</svg>
)
}
24 changes: 24 additions & 0 deletions frontend/src/features/auth/ui/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/login" replace state={{ returnTo }} />
}

return <>{children}</>
}