Skip to content

Commit c48cc83

Browse files
implements login module
1 parent 0004672 commit c48cc83

12 files changed

Lines changed: 216 additions & 16 deletions

File tree

.env

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
VITE_API_URL=http://localhost:3333
2-
# JWT com payload { sub: string }, mesmo secret do backend (ver README do repo estoquei-backend em bruno/)
3-
VITE_API_JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.yZXPPy3CA5RRZQaykE6HJK-M8-lMtqtiyqdKIHYULdk
2+
3+
# Credenciais do único usuário do sistema
4+
VITE_LOGIN_USERNAME=admin
5+
VITE_LOGIN_PASSWORD=senha123
6+
7+
# Secret para assinar o JWT (deve ser igual ao secret do backend)
8+
VITE_JWT_SECRET=change-me-in-production

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"class-variance-authority": "^0.7.1",
1919
"clsx": "^2.1.1",
2020
"focus-trap-react": "^12.0.0",
21+
"jose": "^6.2.2",
2122
"lucide-react": "^0.577.0",
2223
"react": "^18.3.1",
2324
"react-dom": "^18.3.1",

src/app/App.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ vi.mock('@/shared/api/products-api', () => ({
2222
}))
2323

2424
describe('App', () => {
25+
beforeEach(() => {
26+
// ProtectedRoute exige token no localStorage para não redirecionar ao /login
27+
localStorage.setItem('estoquei_token', 'test-token')
28+
})
29+
30+
afterEach(() => {
31+
localStorage.clear()
32+
})
33+
2534
it('renders home route content', async () => {
2635
render(
2736
<AppProviders>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Navigate, Outlet } from 'react-router-dom'
2+
3+
import { isAuthenticated } from '@/shared/utils/auth'
4+
5+
export function ProtectedRoute() {
6+
if (!isAuthenticated()) {
7+
return <Navigate to="/login" replace />
8+
}
9+
return <Outlet />
10+
}

src/app/layouts/AppLayout/index.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
1-
import { PackagePlus } from 'lucide-react'
1+
import { LogOut, PackagePlus } from 'lucide-react'
22
import { useCallback, useMemo } from 'react'
3-
import { Outlet } from 'react-router'
3+
import { Outlet, useNavigate } from 'react-router'
44

55
import { useProductModal } from '@/features/produtos'
66
import MobileFooter from '@/shared/components/layout/MobileFooter'
77
import SideBar from '@/shared/components/layout/SideBar'
8+
import { clearToken } from '@/shared/utils/auth'
89

910
import type { IAppLayoutProps } from './types'
1011

1112
export function AppLayout({ headerTitle: _headerTitle }: IAppLayoutProps) {
1213
const { open: openProductModal } = useProductModal()
14+
const navigate = useNavigate()
1315

1416
const handleNewProductModalOpen = useCallback(() => {
1517
openProductModal()
1618
}, [openProductModal])
1719

20+
const handleLogout = useCallback(() => {
21+
clearToken()
22+
navigate('/login', { replace: true })
23+
}, [navigate])
24+
1825
const MENU_ITEMS = useMemo(
1926
() => [
2027
{
2128
label: 'Novo Produto',
2229
icon: PackagePlus,
2330
onClick: handleNewProductModalOpen,
2431
},
32+
{
33+
label: 'Sair',
34+
icon: LogOut,
35+
onClick: handleLogout,
36+
},
2537
],
26-
[handleNewProductModalOpen]
38+
[handleNewProductModalOpen, handleLogout]
2739
)
2840

2941
return (

src/app/router/index.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import { Routes, Route } from 'react-router-dom'
33

44
import ProductCatalogPage from '@/app/pages/ProductCatalogPage'
5+
import { LoginPage } from '@/features/auth'
56

7+
import { ProtectedRoute } from '../components/ProtectedRoute'
68
import { AppLayout } from '../layouts/AppLayout'
79

810
function NotFoundPage() {
@@ -20,11 +22,14 @@ const ROUTES = [
2022
export function AppRouter() {
2123
return (
2224
<Routes>
23-
{ROUTES.map((route) => (
24-
<Route key={route.path} path={route.path} element={<AppLayout headerTitle={route.headerTitle} />}>
25-
<Route index element={route.element} />
26-
</Route>
27-
))}
25+
<Route path="/login" element={<LoginPage />} />
26+
<Route element={<ProtectedRoute />}>
27+
{ROUTES.map((route) => (
28+
<Route key={route.path} path={route.path} element={<AppLayout headerTitle={route.headerTitle} />}>
29+
<Route index element={route.element} />
30+
</Route>
31+
))}
32+
</Route>
2833
<Route path="*" element={<NotFoundPage />} />
2934
</Routes>
3035
)

src/features/auth/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Public exports for the auth feature boundary (consumers: app).
2+
export { default as LoginPage } from './pages/LoginPage'
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Package } from 'lucide-react'
2+
import { useState } from 'react'
3+
import { useNavigate } from 'react-router-dom'
4+
5+
import { Button } from '@/shared/components/ui/Button'
6+
import { Input } from '@/shared/components/ui/Input'
7+
import { setToken } from '@/shared/utils/auth'
8+
import { generateToken } from '@/shared/utils/jwt'
9+
10+
const VALID_USERNAME = import.meta.env.VITE_LOGIN_USERNAME ?? ''
11+
const VALID_PASSWORD = import.meta.env.VITE_LOGIN_PASSWORD ?? ''
12+
13+
export default function LoginPage() {
14+
const navigate = useNavigate()
15+
const [username, setUsername] = useState('')
16+
const [password, setPassword] = useState('')
17+
const [error, setError] = useState<string | null>(null)
18+
const [loading, setLoading] = useState(false)
19+
20+
const handleSubmit = async (e: React.FormEvent) => {
21+
e.preventDefault()
22+
setError(null)
23+
24+
if (username !== VALID_USERNAME || password !== VALID_PASSWORD) {
25+
setError('Usuário ou senha inválidos.')
26+
return
27+
}
28+
29+
setLoading(true)
30+
try {
31+
const token = await generateToken()
32+
setToken(token)
33+
navigate('/', { replace: true })
34+
} catch {
35+
setError('Erro ao gerar sessão. Tente novamente.')
36+
} finally {
37+
setLoading(false)
38+
}
39+
}
40+
41+
return (
42+
<div className="min-h-screen bg-estoquei-bg flex items-center justify-center p-4">
43+
<div className="w-full max-w-sm">
44+
<div className="flex flex-col items-center gap-2 mb-8">
45+
<div className="w-10 h-10 bg-estoquei-accent rounded-lg flex items-center justify-center">
46+
<Package size={22} aria-hidden />
47+
</div>
48+
<h1 className="text-estoquei-text text-xl font-semibold">estoquei</h1>
49+
<p className="text-estoquei-text3 text-sm">Faça login para continuar</p>
50+
</div>
51+
52+
<form
53+
onSubmit={handleSubmit}
54+
className="bg-estoquei-bg2 border border-estoquei-border rounded-lg p-6 flex flex-col gap-4"
55+
>
56+
{error && (
57+
<p role="alert" className="text-estoquei-danger text-sm">
58+
{error}
59+
</p>
60+
)}
61+
62+
<div className="flex flex-col gap-1">
63+
<label htmlFor="username" className="text-estoquei-text3 text-xs font-medium uppercase tracking-[.08em]">
64+
Usuário
65+
</label>
66+
<Input
67+
id="username"
68+
type="text"
69+
placeholder="Digite seu usuário"
70+
autoComplete="username"
71+
value={username}
72+
onChange={(e) => setUsername(e.target.value)}
73+
/>
74+
</div>
75+
76+
<div className="flex flex-col gap-1">
77+
<label htmlFor="password" className="text-estoquei-text3 text-xs font-medium uppercase tracking-[.08em]">
78+
Senha
79+
</label>
80+
<Input
81+
id="password"
82+
type="password"
83+
placeholder="Digite sua senha"
84+
autoComplete="current-password"
85+
value={password}
86+
onChange={(e) => setPassword(e.target.value)}
87+
/>
88+
</div>
89+
90+
<Button
91+
type="submit"
92+
variant="accent"
93+
className="w-full mt-2"
94+
loading={loading}
95+
>
96+
Entrar
97+
</Button>
98+
</form>
99+
</div>
100+
</div>
101+
)
102+
}

src/shared/api/client.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import axios from 'axios'
22

3+
import { clearToken, getToken } from '@/shared/utils/auth'
4+
35
const baseURL = import.meta.env.VITE_API_URL ?? ''
4-
const token = import.meta.env.VITE_API_JWT ?? ''
56

6-
export const api = axios.create({
7-
baseURL,
8-
headers: {
9-
...(token ? { Authorization: `Bearer ${token}` } : {}),
10-
},
7+
export const api = axios.create({ baseURL })
8+
9+
api.interceptors.request.use((config) => {
10+
const token = getToken()
11+
if (token) {
12+
config.headers.Authorization = `Bearer ${token}`
13+
}
14+
return config
1115
})
16+
17+
api.interceptors.response.use(
18+
(response) => response,
19+
(error) => {
20+
if (error.response?.status === 401) {
21+
clearToken()
22+
window.location.href = '/login'
23+
}
24+
return Promise.reject(error)
25+
}
26+
)

0 commit comments

Comments
 (0)