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 (
+
+ )
+}
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 (
+
+ )
+}
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"
+ }
}