diff --git a/apps/client/index.html b/apps/client/index.html index 923d162..ce1780e 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -2,9 +2,15 @@ - + - JWT-Auth Client + Green Field + + +
diff --git a/apps/client/public/greenfield-3.svg b/apps/client/public/greenfield-3.svg new file mode 100644 index 0000000..4458b0d --- /dev/null +++ b/apps/client/public/greenfield-3.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/client/public/vite.svg b/apps/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/apps/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/client/src/App.jsx b/apps/client/src/App.jsx deleted file mode 100644 index 1c87732..0000000 --- a/apps/client/src/App.jsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react' -import './App.css' -import { ApiError, fetchWithCred, fetchWithRefresh } from './lib/api' - -function Spinner() { - return
-} - -function App() { - - const [isLogin, setIsLogin] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(false) - const [csrfToken, setCsrfToken] = React.useState('') - - const handleLogin = async () => { - try { - setIsLoading(true) - const url = `${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/login` - console.log('AUTH URL:', import.meta.env.VITE_AUTH_SERVICE_URL) - const response = await fetchWithCred(url, { - method: 'POST', - body: JSON.stringify({ - empLoginId: 'djhyoja', - empLoginPassword: '2145' - }) - }) - if (!response.ok) { - const data = await response.json() - throw new ApiError(data.message || 'Login failed', response.status, data) - } - - const token = document.cookie - .split('; ') - .find(row => row.startsWith('csrfToken=')) - ?.split('=')[1] - - setCsrfToken(token) - - const result = await response.json() - console.log('[Client] Login response:', result) - - setIsLogin(true) - } catch(err) { - if (err instanceof ApiError) { - console.log('[Client] API Error:', err.message) - console.log('[Client] Status:', err.statusCode) - console.log('[Client] Details:', err.details) - } else { - console.log('[Client] Unexpected error:', err) - } - } finally { - setIsLoading(false) - } - } - - const handleLogout = async () => { - try { - setIsLoading(true) - const url = `${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/logout` - const response = await fetchWithCred(url, { - method: 'POST', - //...(csrfToken && { headers: { 'x-csrf-token': csrfToken }}) - }) - if (!response.ok) { - const data = await response.json() - throw new ApiError(data.message || 'Failed to logout', response.status, data) - } - console.log('[Client] Logout response:', response) - setIsLogin(false) - } catch(err) { - if (err instanceof ApiError) { - console.log('[Client] API Error:', err.message) - console.log('[Client] Status:', err.statusCode) - console.log('[Client] Details:', err.details) - } else { - console.log('[Client] Unexpected error:', err) - } - } finally { - setIsLoading(false) - } - } - - const handleProducts = async () => { - try { - setIsLoading(true) - const url = `${import.meta.env.VITE_AUTH_SERVICE_URL}/api/products` - const response = await fetchWithRefresh(url,{},{ retries: 5 }, csrfToken) - if (!response.ok) { - const data = await response.json() - throw new ApiError(data.message || 'Failed to get products', response.status, data) - } - const result = await response.json() - console.log('[Client] Get Products response:', result) - } catch(err) { - if (err instanceof ApiError) { - console.log('[Client] API Error:', err.message) - console.log('[Client] Status:', err.statusCode) - console.log('[Client] Details:', err.details) - } else { - console.log('[Client] Unexpected error:', err) - } - } finally { - setIsLoading(false) - } - } - - const handleRefresh = async () => { - try { - setIsLoading(true) - const url = `${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/refresh` - const response = await fetchWithCred(url,{ - method: 'POST', - ...(csrfToken && { headers: { 'x-csrf-token': csrfToken }}) - }) - if (!response.ok) { - const data = await response.json() - throw new ApiError(data.message || 'Failed to refresh', response.status, data) - } - - const token = document.cookie - .split('; ') - .find(row => row.startsWith('csrfToken=')) - ?.split('=')[1] - setCsrfToken(token) - - const result = await response.json() - console.log('[Client] Get Products response:', result) - } catch(err) { - if (err instanceof ApiError) { - console.log('[Client] API Error:', err.message) - console.log('[Client] Status:', err.statusCode) - console.log('[Client] Details:', err.details) - } else { - console.log('[Client] Unexpected error:', err) - } - } finally { - setIsLoading(false) - } - } - - return ( -
-

JWT-Auth Client

-

Open the browser's DevTools and check the Console and Network tabs for authentication and request activity.

- { - isLogin ? ( - - ):( - - ) - } - - -
- ) -} - -export default App diff --git a/apps/client/src/App.css b/apps/client/src/app/App.css similarity index 100% rename from apps/client/src/App.css rename to apps/client/src/app/App.css diff --git a/apps/client/src/app/App.jsx b/apps/client/src/app/App.jsx new file mode 100644 index 0000000..665e6d3 --- /dev/null +++ b/apps/client/src/app/App.jsx @@ -0,0 +1,46 @@ +// src/app/App.jsx +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './providers/AuthProvider' +import MainLayout from '../layouts/MainLayout' +import AuthLayout from '../layouts/AuthLayout' +import Login from '../auth/pages/Login' +import Home from '../auth/pages/Home' +import { RequireAuth } from '../auth/authGuard' + +function AppRoutes() { + const { user } = useAuth() + + return ( + + + : + } + /> + + + + + } + > + } /> + + + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/apps/client/src/app/providers/AuthProvider.jsx b/apps/client/src/app/providers/AuthProvider.jsx new file mode 100644 index 0000000..108b411 --- /dev/null +++ b/apps/client/src/app/providers/AuthProvider.jsx @@ -0,0 +1,34 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { fetchWithRefresh } from '../../lib/api' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchWithRefresh(`${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/me`) + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data?.authenticated) { + setUser(data) + } else { + setUser(null) + } + }) + .catch(() => setUser(null)) + .finally(() => setLoading(false)) +}, []) + + const login = (userData) => setUser(userData) + const logout = () => setUser(null) + + return ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/apps/client/src/assets/react.svg b/apps/client/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/apps/client/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/client/src/auth/authGuard.jsx b/apps/client/src/auth/authGuard.jsx new file mode 100644 index 0000000..60e59da --- /dev/null +++ b/apps/client/src/auth/authGuard.jsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../app/providers/AuthProvider' + +export function RequireAuth({ children }) { + const { user, loading } = useAuth() + + if (loading) return
Loading...
+ if (!user) return + + return children +} diff --git a/apps/client/src/auth/pages/Home.jsx b/apps/client/src/auth/pages/Home.jsx new file mode 100644 index 0000000..98bace7 --- /dev/null +++ b/apps/client/src/auth/pages/Home.jsx @@ -0,0 +1,9 @@ +// src/pages/Home.jsx +export default function Home() { + return ( +
+

Home

+

Welcome to ERP

+
+ ) +} diff --git a/apps/client/src/auth/pages/Login.jsx b/apps/client/src/auth/pages/Login.jsx new file mode 100644 index 0000000..6080e07 --- /dev/null +++ b/apps/client/src/auth/pages/Login.jsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { fetchWithCred } from '../../lib/api' +import { useAuth } from '../../app/providers/AuthProvider' + +export default function Login() { + const { login } = useAuth() + + const [form, setForm] = useState({ + empLoginId: '', + empLoginPassword: '' + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleChange = (e) => { + setForm({ + ...form, + [e.target.name]: e.target.value + }) + } + + const handleLogin = async (e) => { + // enter 키에 페이지 리로드막고 직접 처리 + e.preventDefault() + try { + setLoading(true) + setError(null) + + const res = await fetchWithCred( + `${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/login`, + { + method: 'POST', + body: JSON.stringify(form) + } + ) + + if (!res.ok) { + throw new Error('Invalid ID or password') + } + + const user = await res.json() + login(user) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + return ( +
+

GREEN FIELD

+ + + + + + {error &&
{error}
} + + +
+ ) +} diff --git a/apps/client/src/index.css b/apps/client/src/index.css index 561b5ae..8083666 100644 --- a/apps/client/src/index.css +++ b/apps/client/src/index.css @@ -1,80 +1,122 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { +/* index.css */ +html, body { + height: 100%; margin: 0; + padding: 0; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + color: #1f2937; + background-color: #f9fafb; +} + +.layout { + height: 100vh; display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + flex-direction: column; } -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -h4 { - font-size: 1.2em; - line-height: 1.1; - margin: 0; -} - -ul { - margin: 0; +h1, h2, h3, h4 { + font-weight: 600; + letter-spacing: -0.01em; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; - display: flex; - align-items: center; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + font-weight: 500; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +.topbar { + height: 56px; + background: #4CAF50; + color: white; + display: flex; + align-items: center; + padding: 0 16px; + justify-content: space-between; } + +.body { + flex: 1; + display: flex; +} + +.sidebar { + width: 220px; + background: #f4f6f8; +} + +.content { + flex: 1; + padding: 16px; +} + +.user-info { + display: flex; + align-items: center; + gap: 12px; +} + +.user-name { + font-weight: 500; +} + +.logout-btn { + background: transparent; + border: 1px solid white; + color: white; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; +} + +/* login */ +.login-box { + width: 320px; + padding: 32px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + gap: 14px; +} + +.login-title { + text-align: center; + margin-bottom: 8px; +} + +.login-input { + height: 40px; + padding: 0 12px; + font-size: 14px; + border: 1px solid #dcdfe3; + border-radius: 4px; +} + +.login-input:focus { + outline: none; + border-color: #4CAF50; +} + +.login-button { + height: 40px; + font-size: 14px; + border-radius: 4px; + border: none; + background: #4CAF50; + color: white; + cursor: pointer; +} + +.login-button:disabled { + background: #b5d6b8; + cursor: not-allowed; +} + +.login-error { + font-size: 13px; + color: #d32f2f; + text-align: center; +} + diff --git a/apps/client/src/layouts/AuthLayout.jsx b/apps/client/src/layouts/AuthLayout.jsx new file mode 100644 index 0000000..180687a --- /dev/null +++ b/apps/client/src/layouts/AuthLayout.jsx @@ -0,0 +1,13 @@ +export default function AuthLayout({ children }) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/client/src/layouts/MainLayout.jsx b/apps/client/src/layouts/MainLayout.jsx new file mode 100644 index 0000000..a0c35f1 --- /dev/null +++ b/apps/client/src/layouts/MainLayout.jsx @@ -0,0 +1,24 @@ +// src/layouts/MainLayout.jsx +import { Outlet } from 'react-router-dom' +import TopBar from './TopBar' + +export default function MainLayout() { + return ( +
+ + +
+ + +
+ +
+
+
+ ) +} diff --git a/apps/client/src/layouts/TopBar.jsx b/apps/client/src/layouts/TopBar.jsx new file mode 100644 index 0000000..6e623da --- /dev/null +++ b/apps/client/src/layouts/TopBar.jsx @@ -0,0 +1,45 @@ +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../app/providers/AuthProvider' +import { fetchWithCred } from '../lib/api' + +export default function TopBar() { + const { user, logout } = useAuth() + const navigate = useNavigate() + + const handleLogout = async () => { + try { + await fetchWithCred( + `${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/logout`, + { method: 'POST' } + ) + } catch (e) { + // 서버 에러 나도 프론트는 로그아웃 처리 + console.warn('Logout request failed', e) + } finally { + logout() + navigate('/login', { replace: true }) + } + } + + return ( +
+
GREEN FIELD
+ + + +
+ + {user?.firstName} {user?.lastName} + + +
+
+ ) +} diff --git a/apps/client/src/lib/api.js b/apps/client/src/lib/api.js index 6810805..6585e6b 100644 --- a/apps/client/src/lib/api.js +++ b/apps/client/src/lib/api.js @@ -48,7 +48,7 @@ export const fetchWithRefresh = async (url, options = {}, { retries = 1, timeout if (response.status === 401) { console.log('Access token expired, trying refresh...') - const refreshRes = await fetchWithCred(`${import.meta.env.VITE_AUTH_SERVICE_URL}/api/refresh`, + const refreshRes = await fetchWithCred(`${import.meta.env.VITE_AUTH_SERVICE_URL}/auth/token/refresh`, { method: 'POST', ...(csrfToken && { headers: { 'x-csrf-token': csrfToken }}) diff --git a/apps/client/src/main.jsx b/apps/client/src/main.jsx index b9a1a6d..6e1b90d 100644 --- a/apps/client/src/main.jsx +++ b/apps/client/src/main.jsx @@ -1,10 +1,8 @@ -import { StrictMode } from 'react' +// src/main.jsx import { createRoot } from 'react-dom/client' +import App from './app/App' import './index.css' -import App from './App.jsx' createRoot(document.getElementById('root')).render( - - - , + ) diff --git a/package-lock.json b/package-lock.json index 15fd308..82d5826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "apps/*", "shared" ], + "dependencies": { + "react-router-dom": "^7.13.0" + }, "devDependencies": { "concurrently": "^9.0.0" } @@ -3697,6 +3700,57 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3886,6 +3940,12 @@ "node": ">= 18" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index 5c4b756..2348617 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,8 @@ "demo" ], "author": "supershaneski", - "license": "MIT" + "license": "MIT", + "dependencies": { + "react-router-dom": "^7.13.0" + } }