From 52adb07291710ddce1505d4b0dc3c437eb5c1ecb Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:30:57 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat=20:=20API=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/api/auth.ts | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 frontend/src/features/auth/api/auth.ts diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts new file mode 100644 index 00000000..88426b65 --- /dev/null +++ b/frontend/src/features/auth/api/auth.ts @@ -0,0 +1,39 @@ +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 }, + withCredentials: true, + }, + ) + 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', { + withCredentials: true, + }) +} From a8162403d51b99a8a3a70aec3519116a48a4f14b Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:32:14 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat(auth):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20=EB=8F=8C=EC=95=84=EC=98=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=B3=B5=EC=9B=90=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EB=AA=A8=EB=93=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/lib/return-to.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/src/features/auth/lib/return-to.ts 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 +} From 239a191f192a30d5a7bcbaf541e0dc25eabde885 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:33:13 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(auth):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EB=AA=A8=EB=93=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/model/types.ts | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/src/features/auth/model/types.ts 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 +} From d8d596abe6c64944c08a40f9f68b177d3aadef1a Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:34:05 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(auth):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/model/AuthContext.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frontend/src/features/auth/model/AuthContext.ts diff --git a/frontend/src/features/auth/model/AuthContext.ts b/frontend/src/features/auth/model/AuthContext.ts new file mode 100644 index 00000000..25dfa042 --- /dev/null +++ b/frontend/src/features/auth/model/AuthContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react' +import type { AuthUser, LoginResponse } from './types' + +export type AuthStatus = + | 'idle' + | 'loading' + | 'authenticated' + | 'unauthenticated' + +export type AuthContextValue = { + status: AuthStatus + user: AuthUser | null + applyLogin: (response: LoginResponse) => void + logout: () => Promise + refreshUser: () => Promise +} + +export const AuthContext = createContext(null) From 1cd16c968ccb86347fb450c69e2812eed1cf9936 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:34:39 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(auth):=20useAuth=20=ED=9B=85=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/model/useAuth.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 frontend/src/features/auth/model/useAuth.ts 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 +} From 622e0fb0b13fde0801296d1d13c93acdce10fd2d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:35:10 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(auth):=20AuthProvider=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=AA=A8=EB=93=88=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/auth/model/AuthProvider.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 frontend/src/features/auth/model/AuthProvider.tsx 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} +} From 27600d254eb5471c397bbfaa02ad25313dd7665d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:35:56 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat(auth):=20RequireAuth=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20UI=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/ui/RequireAuth.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frontend/src/features/auth/ui/RequireAuth.tsx diff --git a/frontend/src/features/auth/ui/RequireAuth.tsx b/frontend/src/features/auth/ui/RequireAuth.tsx new file mode 100644 index 00000000..b439319a --- /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' || status === 'idle') { + return fallback ?? null + } + + if (status === 'unauthenticated') { + const returnTo = `${location.pathname}${location.search}${location.hash}` + return + } + + return <>{children} +} From 51ad7cdcdc6da9dfb3c085793539198a10ba6aa6 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:36:23 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat(auth):=20GitHub=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20UI=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/auth/ui/GithubLoginButton.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 frontend/src/features/auth/ui/GithubLoginButton.tsx 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 ( + + + + + ) +} From 429cc28bb393810fdf77367fc056ee8b3cc2b2fc Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 01:36:57 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat(auth):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20export=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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' From a4cda1a47cc101c8e21d2420a8dcecf74eca5c06 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 20:47:09 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor(auth):=20=EC=9D=B8=EC=A6=9D=20AP?= =?UTF-8?q?I=EC=97=90=EC=84=9C=20withCredentials=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/api/auth.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 88426b65..c8f79e8e 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -21,7 +21,6 @@ export async function completeGithubLogin( '/api/auth/github/callback', { params: { code, state }, - withCredentials: true, }, ) return response.data @@ -33,7 +32,5 @@ export async function fetchCurrentUser(): Promise { } export async function logout(): Promise { - await apiClient.delete('/api/auth/logout', { - withCredentials: true, - }) + await apiClient.delete('/api/auth/logout') } From cd6bc265df754045d05b826cb34f911e2764e94c Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 20:47:29 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor(auth):=20AuthStatus=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=97=90=EC=84=9C=20idle=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/model/AuthContext.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/features/auth/model/AuthContext.ts b/frontend/src/features/auth/model/AuthContext.ts index 25dfa042..7385ff05 100644 --- a/frontend/src/features/auth/model/AuthContext.ts +++ b/frontend/src/features/auth/model/AuthContext.ts @@ -1,11 +1,7 @@ import { createContext } from 'react' import type { AuthUser, LoginResponse } from './types' -export type AuthStatus = - | 'idle' - | 'loading' - | 'authenticated' - | 'unauthenticated' +export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' export type AuthContextValue = { status: AuthStatus From f1b8489af3c04e02ab3156dcd69ba5f1782dd073 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 20:47:45 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor(auth):=20RequireAuth=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=20idle=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/ui/RequireAuth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/auth/ui/RequireAuth.tsx b/frontend/src/features/auth/ui/RequireAuth.tsx index b439319a..cfc25378 100644 --- a/frontend/src/features/auth/ui/RequireAuth.tsx +++ b/frontend/src/features/auth/ui/RequireAuth.tsx @@ -11,7 +11,7 @@ export function RequireAuth({ children, fallback }: RequireAuthProps) { const { status } = useAuth() const location = useLocation() - if (status === 'loading' || status === 'idle') { + if (status === 'loading') { return fallback ?? null }