Compare commits

..

4 Commits

Author SHA1 Message Date
Artur Oliveira 53f64dd15f chore(docs): alinhar integração front/backend e CORS 2025-12-16 10:50:14 -03:00
Artur Oliveira 116261e7ff feat(auth): implementar funcionalidade de login de usuário
- Configura base URL da API através de variável de ambiente (.env.example).
- Cria instância do Axios para comunicação com a API e gerenciamento de token de autenticação.
- Adiciona lógica de submissão do formulário de login, incluindo chamadas à API.
- Gerencia estados de carregamento e exibe mensagens de erro/sucesso.
- Integra react-hot-toast para notificações de sistema.
- Redireciona a rota raiz (/) para a página de login.
2025-12-16 10:41:26 -03:00
Artur Oliveira 41059bdfc3 feat(auth): implementar autenticação e autorização JWT
- Adiciona dependências do Spring Security e JWT (API, Impl, Jackson).
- Configura o pipeline de segurança com autenticação stateless e filtros JWT.
- Implementa serviços para geração e validação de tokens de acesso e refresh.
- Cria endpoints para login (/api/auth/login) e refresh de token (/api/auth/refresh).
- Move o endpoint de criação de usuário para /api/auth/signup e o protege.
- Criptografa senhas dos usuários utilizando BCrypt antes de salvar.
- Atualiza Postman Collection com requisições de autenticação e variáveis de ambiente.
- Estende a interface UserDetails para o modelo Users.
- Ajusta tamanho da coluna 'password' na tabela de usuários para hashes.
- Adiciona um usuário padrão inicial com senha hash.
2025-12-16 10:23:28 -03:00
Artur Oliveira 81499374b6 feat(login): implementar alternância de visibilidade de senha
- Implementa funcionalidade de mostrar/esconder senha na página de login
- Integra ícones Eye e EyeOff para o controle de visibilidade
- Adiciona animação 'fade-up' à página de login
- Define novas classes de estilo para o campo de senha e botão de toggle
- Configura a regra '@typescript-eslint/semi' no ESLint
- Adiciona keyframes e animação 'fade-up' ao Tailwind CSS
2025-12-16 09:30:35 -03:00
30 changed files with 1021 additions and 191 deletions

View File

@ -1,38 +1,36 @@
# Hit Server Manager # Servers Manager
Plataforma interna da Hit Communications para catalogar os servidores corporativos e expor essas informações de forma rápida ao time de Análise de Sistemas e Desenvolvimento. O backend centraliza cadastros, contexto técnico (tipo, aplicação e banco) e credenciais operacionais, facilitando consultas padronizadas e mantendo histórico de alterações. Plataforma interna para catalogar servidores corporativos e facilitar consultas rápidas pelo time de desenvolvimento/analistas. Inclui backend em Spring Boot (JWT + refresh token em cookies) e frontend React/Vite consumindo os endpoints.
## Principais recursos ## Estrutura
- CRUD completo de usuários internos e servidores - `backend/`: API em Spring Boot 4 (Java 21), JWT stateless, cookies para auth.
- Validação de domínio corporativo para criação de usuários - `frontned/`: Front em React + Vite (TS), login integrado via cookies.
- Mapeamentos DTO ↔ entidades com MapStruct - `postman_collection.json`: Coleção para testar autenticação e CRUDs de usuários/servidores.
- API REST documentada via coleção Postman (`postman_collection.json`)
- Persistência em H2 (memória/disco) com Spring Data JPA e geração automática de IDs personalizados para servidores
## Stack ## Requisitos
- Java 21 + Spring Boot 4.0 - JDK 21
- Spring Web MVC, Validation, Data JPA - Node 20+ (Yarn ou npm)
- H2 Database
- MapStruct 1.6
- Lombok
- Spring Security (JWT em breve)
- Docker Compose para orquestração local
- ReactJS
## Como rodar ## Rodando local
1. **Pré-requisitos**: JDK 21 e acesso ao Gradle Wrapper (`./gradlew`). 1. **Backend**
2. **Instalar dependências e rodar testes**:
```bash
./gradlew clean test
```
3. **Subir o backend**:
```bash ```bash
cd backend
./gradlew bootRun ./gradlew bootRun
``` ```
4. A API ficará disponível em `http://localhost:8080`. Utilize a coleção Postman incluída para exercitar os endpoints de `Users` e `Servers` (`{{base_url}}` já configurado com esse endereço). Variáveis úteis: `JWT_SECRET` (>=32 chars), `DB_*` (Postgres). Por padrão usa `localhost:8080`.
## Próximos passos 2. **Frontend**
- [ ] Substituir H2 por PostgreSQL para persistência ```bash
- [ ] Proteger os endpoints com autenticação (Spring Security com JWT) cd frontned
- [ ] Criar o frontend React integrado ao backend cp .env.example .env # ajuste VITE_BACKEND_URL se necessário
- [ ] Containerizar backend + frontend com Docker Compose yarn install # ou npm install
yarn dev # ou npm run dev
```
Acesse `http://localhost:5173`.
3. **Testar via Postman**
Importe `postman_collection.json`. Rode "Auth / Login" para setar cookies e seguir para os demais endpoints.
## Documentação específica
- Backend: `backend/README.md`
- Frontend: `frontned/README.md`

41
backend/README.md 100644
View File

@ -0,0 +1,41 @@
# Backend - Servers Manager
API REST em Spring Boot 4 (Java 21) com autenticação JWT stateless e refresh token em cookie HttpOnly.
## Stack
- Spring Boot 4.0 (Web MVC, Validation, Data JPA)
- Spring Security (JWT, stateless)
- MapStruct 1.6, Lombok
- PostgreSQL (padrão) ou H2
- JJWT 0.12 para geração/validação de tokens
## Configuração
Variáveis principais:
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWD` — Postgres
- `JWT_SECRET` — chave HMAC >= 32 chars
- `security.jwt.access-token-expiration` — padrão 30m
- `security.jwt.refresh-token-expiration` — padrão 30d
Arquivo `src/main/resources/application.yaml` já traz defaults; adicione um `.env` ou exporte variáveis conforme o ambiente.
Seed: `src/main/resources/data.sql` cria usuário padrão `default@hittelco.com` (senha `senha123`, bcrypt).
## Endpoints chave
- `POST /api/auth/login` — autentica e devolve `access_token` (cookie) + `refresh_token` (HttpOnly).
- `POST /api/auth/refresh` — reemite cookies a partir do refresh.
- `POST /api/auth/signup` — cria usuário (requer auth).
- CRUD `/api/users` e `/api/servers` protegidos.
## Rodar local
```bash
./gradlew bootRun
```
API em `http://localhost:8080` com CORS liberado para `http://localhost:5173`.
## Testes
```bash
./gradlew test
```
## Postman
Use `postman_collection.json` na raiz; o login grava cookies e variáveis `access_token`/`refresh_token`.

View File

@ -30,11 +30,15 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'org.mapstruct:mapstruct:1.6.3' implementation 'org.mapstruct:mapstruct:1.6.3'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2' runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jdbc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jdbc-test'

View File

@ -6,10 +6,10 @@
}, },
"item": [ "item": [
{ {
"name": "Users", "name": "Auth",
"item": [ "item": [
{ {
"name": "Create User", "name": "Login",
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
@ -18,82 +18,114 @@
"value": "application/json" "value": "application/json"
} }
], ],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"{{login_username}}\",\n \"password\": \"{{login_password}}\"\n}"
},
"url": {
"raw": "{{base_url}}/api/auth/login",
"host": [
"{{base_url}}"
],
"path": [
"api",
"auth",
"login"
]
}
},
"response": [],
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.environment.set(\"access_token\", pm.cookies.get('access_token') || \"\");",
"pm.environment.set(\"refresh_token\", pm.cookies.get('refresh_token') || \"\");"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Refresh Token",
"request": {
"method": "POST",
"header": [],
"url": {
"raw": "{{base_url}}/api/auth/refresh",
"host": [
"{{base_url}}"
],
"path": [
"api",
"auth",
"refresh"
]
}
},
"response": [],
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.environment.set(\"access_token\", pm.cookies.get('access_token') || \"\");",
"pm.environment.set(\"refresh_token\", pm.cookies.get('refresh_token') || \"\");"
],
"type": "text/javascript"
}
}
]
},
{
"name": "Signup (autenticado)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"firstName\": \"João\",\n \"lastName\": \"Silva\",\n \"email\": \"joao.silva@hittelco.com\",\n \"password\": \"senha123\"\n}" "raw": "{\n \"firstName\": \"João\",\n \"lastName\": \"Silva\",\n \"email\": \"joao.silva@hittelco.com\",\n \"password\": \"senha123\"\n}"
}, },
"url": { "url": {
"raw": "{{base_url}}/api/users", "raw": "{{base_url}}/api/auth/signup",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
"path": [ "path": [
"api", "api",
"users" "auth",
"signup"
] ]
} }
}, },
"response": [] "response": []
},
{
"name": "Create User - Test 2",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
} }
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"Maria\",\n \"lastName\": \"Santos\",\n \"email\": \"maria.santos@accesscommunications.com\",\n \"password\": \"senha456\"\n}"
},
"url": {
"raw": "{{base_url}}/api/users",
"host": [
"{{base_url}}"
],
"path": [
"api",
"users"
] ]
}
},
"response": []
}, },
{ {
"name": "Create User - Invalid Domain", "name": "Users",
"request": { "item": [
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\": \"Carlos\",\n \"lastName\": \"Oliveira\",\n \"email\": \"carlos@gmail.com\",\n \"password\": \"senha789\"\n}"
},
"url": {
"raw": "{{base_url}}/api/users",
"host": [
"{{base_url}}"
],
"path": [
"api",
"users"
]
}
},
"response": []
},
{ {
"name": "Get All Users", "name": "Get All Users",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/users", "raw": "{{base_url}}/api/users",
"host": [ "host": [
@ -111,7 +143,12 @@
"name": "Get User by ID", "name": "Get User by ID",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/users/{{user_id}}", "raw": "{{base_url}}/api/users/{{user_id}}",
"host": [ "host": [
@ -130,9 +167,14 @@
"name": "Get User by Username", "name": "Get User by Username",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/users/username/joao.silva@hittelco.com", "raw": "{{base_url}}/api/users/username/{{username}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@ -140,7 +182,7 @@
"api", "api",
"users", "users",
"username", "username",
"joao.silva@hittelco.com" "{{username}}"
] ]
} }
}, },
@ -150,9 +192,14 @@
"name": "Get User by Email", "name": "Get User by Email",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/users/email/maria.santos@accesscommunications.com", "raw": "{{base_url}}/api/users/email/{{email}}",
"host": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],
@ -160,7 +207,7 @@
"api", "api",
"users", "users",
"email", "email",
"maria.santos@accesscommunications.com" "{{email}}"
] ]
} }
}, },
@ -174,6 +221,10 @@
{ {
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
} }
], ],
"body": { "body": {
@ -198,7 +249,12 @@
"name": "Delete User", "name": "Delete User",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/users/{{user_id}}", "raw": "{{base_url}}/api/users/{{user_id}}",
"host": [ "host": [
@ -226,6 +282,10 @@
{ {
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
} }
], ],
"body": { "body": {
@ -253,6 +313,10 @@
{ {
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
} }
], ],
"body": { "body": {
@ -276,7 +340,12 @@
"name": "Get All Servers", "name": "Get All Servers",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/servers", "raw": "{{base_url}}/api/servers",
"host": [ "host": [
@ -294,7 +363,12 @@
"name": "Get Server by ID", "name": "Get Server by ID",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/servers/{{server_id}}", "raw": "{{base_url}}/api/servers/{{server_id}}",
"host": [ "host": [
@ -313,7 +387,12 @@
"name": "Get Server by Name", "name": "Get Server by Name",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/servers/name/Production DB Server", "raw": "{{base_url}}/api/servers/name/Production DB Server",
"host": [ "host": [
@ -333,7 +412,12 @@
"name": "Get Servers by Type", "name": "Get Servers by Type",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/servers/type/PRODUCTION", "raw": "{{base_url}}/api/servers/type/PRODUCTION",
"host": [ "host": [
@ -377,6 +461,10 @@
{ {
"key": "Content-Type", "key": "Content-Type",
"value": "application/json" "value": "application/json"
},
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
} }
], ],
"body": { "body": {
@ -401,7 +489,12 @@
"name": "Delete Server", "name": "Delete Server",
"request": { "request": {
"method": "DELETE", "method": "DELETE",
"header": [], "header": [
{
"key": "Authorization",
"value": "Bearer {{access_token}}"
}
],
"url": { "url": {
"raw": "{{base_url}}/api/servers/{{server_id}}", "raw": "{{base_url}}/api/servers/{{server_id}}",
"host": [ "host": [
@ -425,11 +518,41 @@
"value": "http://localhost:8080", "value": "http://localhost:8080",
"type": "string" "type": "string"
}, },
{
"key": "login_username",
"value": "joao.silva@hittelco.com",
"type": "string"
},
{
"key": "login_password",
"value": "senha123",
"type": "string"
},
{
"key": "username",
"value": "",
"type": "string"
},
{
"key": "email",
"value": "",
"type": "string"
},
{ {
"key": "user_id", "key": "user_id",
"value": "", "value": "",
"type": "string" "type": "string"
}, },
{
"key": "access_token",
"value": "",
"type": "string"
},
{
"key": "refresh_token",
"value": "",
"type": "string"
},
{ {
"key": "server_id", "key": "server_id",
"value": "", "value": "",
@ -437,4 +560,3 @@
} }
] ]
} }

View File

@ -0,0 +1,23 @@
package com.hitcommunications.servermanager.config.security;
import com.hitcommunications.servermanager.repositories.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UsersRepository usersRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return usersRepository.findByUsername(username)
.or(() -> usersRepository.findByEmail(username))
.map(UserPrincipal::fromUser)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}

View File

@ -0,0 +1,76 @@
package com.hitcommunications.servermanager.config.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String ACCESS_TOKEN_COOKIE = "access_token";
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String jwt = resolveToken(request);
try {
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null
&& JwtService.TokenType.ACCESS == jwtService.extractTokenType(jwt)) {
String username = jwtService.extractUsername(jwt);
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails, JwtService.TokenType.ACCESS)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception ignored) {
// Let the security chain handle unauthorized requests
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> ACCESS_TOKEN_COOKIE.equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}
}

View File

@ -0,0 +1,25 @@
package com.hitcommunications.servermanager.config.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@Data
@ConfigurationProperties(prefix = "security.jwt")
public class JwtProperties {
/**
* Secret key used to sign the JWTs.
*/
private String secret;
/**
* Access token validity window.
*/
private Duration accessTokenExpiration;
/**
* Refresh token validity window.
*/
private Duration refreshTokenExpiration;
}

View File

@ -0,0 +1,105 @@
package com.hitcommunications.servermanager.config.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import java.util.function.Function;
@Service
@RequiredArgsConstructor
public class JwtService {
public static final String TOKEN_TYPE_CLAIM = "token_type";
private final JwtProperties properties;
public String generateAccessToken(UserDetails userDetails) {
return buildToken(Collections.singletonMap(TOKEN_TYPE_CLAIM, TokenType.ACCESS.name()), userDetails, properties.getAccessTokenExpiration());
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Collections.singletonMap(TOKEN_TYPE_CLAIM, TokenType.REFRESH.name()), userDetails, properties.getRefreshTokenExpiration());
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public TokenType extractTokenType(String token) {
String tokenType = extractClaim(token, claims -> claims.get(TOKEN_TYPE_CLAIM, String.class));
try {
return tokenType == null ? null : TokenType.valueOf(tokenType);
} catch (IllegalArgumentException e) {
return null;
}
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public boolean isTokenValid(String token, UserDetails userDetails, TokenType expectedType) {
final String username = extractUsername(token);
TokenType tokenType = extractTokenType(token);
return username.equals(userDetails.getUsername()) && expectedType == tokenType && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
// parser() is deprecated in docs, but current JJWT version exposes only this entrypoint
return Jwts.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, Duration expiration) {
Instant now = Instant.now();
Instant expiry = now.plus(expiration.toMillis(), ChronoUnit.MILLIS);
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
private SecretKey getSignInKey() {
byte[] keyBytes = properties.getSecret().getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public enum TokenType {
ACCESS,
REFRESH
}
}

View File

@ -0,0 +1,85 @@
package com.hitcommunications.servermanager.config.security;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties.class)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/api/auth/login", "/api/auth/refresh").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:5173",
"http://127.0.0.1:5173"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setExposedHeaders(List.of("Set-Cookie"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@ -0,0 +1,56 @@
package com.hitcommunications.servermanager.config.security;
import com.hitcommunications.servermanager.model.Users;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Getter
public class UserPrincipal implements UserDetails {
private final UUID id;
private final String username;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
private UserPrincipal(UUID id, String username, String password, Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.username = username;
this.password = password;
this.authorities = authorities;
}
public static UserPrincipal fromUser(Users user) {
return new UserPrincipal(
user.getId(),
user.getUsername(),
user.getPassword(),
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

View File

@ -0,0 +1,74 @@
package com.hitcommunications.servermanager.controllers;
import com.hitcommunications.servermanager.config.security.JwtAuthenticationFilter;
import com.hitcommunications.servermanager.config.security.JwtProperties;
import com.hitcommunications.servermanager.model.dtos.AuthResponse;
import com.hitcommunications.servermanager.model.dtos.AuthTokens;
import com.hitcommunications.servermanager.model.dtos.LoginRequest;
import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
import com.hitcommunications.servermanager.model.dtos.UserDTO;
import com.hitcommunications.servermanager.services.AuthService;
import com.hitcommunications.servermanager.utils.CookieUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
private final AuthService authService;
private final JwtProperties jwtProperties;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request, HttpServletRequest httpRequest) {
AuthTokens tokens = authService.login(request);
return buildResponse(tokens, httpRequest);
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request) {
String refreshToken = CookieUtils.getCookieValue(request, REFRESH_TOKEN_COOKIE);
AuthTokens tokens = authService.refresh(refreshToken);
return buildResponse(tokens, request);
}
@PostMapping("/signup")
public ResponseEntity<UserDTO> signup(@RequestBody @Valid NewUserDTO request) throws IllegalAccessException {
UserDTO user = authService.signup(request);
return ResponseEntity.ok(user);
}
private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
boolean secure = request.isSecure();
ResponseCookie accessCookie = CookieUtils.buildCookie(
JwtAuthenticationFilter.ACCESS_TOKEN_COOKIE,
tokens.accessToken(),
jwtProperties.getAccessTokenExpiration(),
false,
secure
);
ResponseCookie refreshCookie = CookieUtils.buildCookie(
REFRESH_TOKEN_COOKIE,
tokens.refreshToken(),
jwtProperties.getRefreshTokenExpiration(),
true,
secure
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessCookie.toString(), refreshCookie.toString())
.body(new AuthResponse(tokens.user()));
}
}

View File

@ -18,11 +18,6 @@ public class UsersController {
private final UsersService usersService; private final UsersService usersService;
@PostMapping
public ResponseEntity<UserDTO> create(@RequestBody @Valid NewUserDTO crateDTO) throws IllegalAccessException {
return ResponseEntity.ok().body(usersService.create(crateDTO));
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<UserDTO> getById(@PathVariable UUID id) { public ResponseEntity<UserDTO> getById(@PathVariable UUID id) {
return ResponseEntity.ok().body(usersService.getById(id)); return ResponseEntity.ok().body(usersService.getById(id));

View File

@ -28,7 +28,7 @@ public class Users {
@Column(nullable = false, unique = true, length = 100) @Column(nullable = false, unique = true, length = 100)
private String email; private String email;
@Column(nullable = false, length = 20) @Column(nullable = false, length = 120)
private String password; private String password;
@Column(nullable = false, length = 30) @Column(nullable = false, length = 30)

View File

@ -0,0 +1,6 @@
package com.hitcommunications.servermanager.model.dtos;
public record AuthResponse(
UserDTO user
) {
}

View File

@ -0,0 +1,8 @@
package com.hitcommunications.servermanager.model.dtos;
public record AuthTokens(
String accessToken,
String refreshToken,
UserDTO user
) {
}

View File

@ -0,0 +1,9 @@
package com.hitcommunications.servermanager.model.dtos;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {
}

View File

@ -0,0 +1,88 @@
package com.hitcommunications.servermanager.services;
import com.hitcommunications.servermanager.config.security.JwtService;
import com.hitcommunications.servermanager.config.security.UserPrincipal;
import com.hitcommunications.servermanager.model.Users;
import com.hitcommunications.servermanager.model.dtos.AuthTokens;
import com.hitcommunications.servermanager.model.dtos.LoginRequest;
import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
import com.hitcommunications.servermanager.model.dtos.UserDTO;
import com.hitcommunications.servermanager.mappers.UsersMapper;
import com.hitcommunications.servermanager.repositories.UsersRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.sql.Timestamp;
import java.time.Instant;
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManager authenticationManager;
private final UsersService usersService;
private final UsersRepository usersRepository;
private final UsersMapper usersMapper;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public AuthTokens login(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
Users user = usersRepository.findById(principal.getId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found after authentication."));
updateLastLogin(user);
return issueTokens(user, principal);
}
public AuthTokens refresh(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is missing.");
}
try {
String username = jwtService.extractUsername(refreshToken);
var userDetails = userDetailsService.loadUserByUsername(username);
if (!jwtService.isTokenValid(refreshToken, userDetails, JwtService.TokenType.REFRESH)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token.");
}
Users user = usersRepository.findByUsername(username)
.or(() -> usersRepository.findByEmail(username))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found for refresh token."));
return issueTokens(user, UserPrincipal.fromUser(user));
} catch (ResponseStatusException ex) {
throw ex;
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token.");
}
}
@Transactional
public UserDTO signup(NewUserDTO createDTO) throws IllegalAccessException {
return usersService.create(createDTO);
}
private AuthTokens issueTokens(Users user, UserPrincipal principal) {
String accessToken = jwtService.generateAccessToken(principal);
String refreshToken = jwtService.generateRefreshToken(principal);
return new AuthTokens(accessToken, refreshToken, usersMapper.toDTO(user));
}
private void updateLastLogin(Users user) {
user.setLastLogin(Timestamp.from(Instant.now()));
usersRepository.save(user);
}
}

View File

@ -7,6 +7,7 @@ import com.hitcommunications.servermanager.model.dtos.UserDTO;
import com.hitcommunications.servermanager.repositories.UsersRepository; import com.hitcommunications.servermanager.repositories.UsersRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@ -17,6 +18,7 @@ import java.util.UUID;
public class UsersService { public class UsersService {
private final UsersMapper mapper; private final UsersMapper mapper;
private final UsersRepository repo; private final UsersRepository repo;
private final PasswordEncoder passwordEncoder;
private final String ALLOWED_DOMAIN = Arrays.asList("hittelco.com", "accesscommunications.com").toString(); private final String ALLOWED_DOMAIN = Arrays.asList("hittelco.com", "accesscommunications.com").toString();
@ -31,6 +33,7 @@ public class UsersService {
}); });
Users entity = mapper.toEntity(createDTO); Users entity = mapper.toEntity(createDTO);
entity.setPassword(passwordEncoder.encode(createDTO.password()));
entity = repo.save(entity); entity = repo.save(entity);
return mapper.toDTO(entity); return mapper.toDTO(entity);
} }
@ -77,6 +80,7 @@ public class UsersService {
}); });
mapper.partialUpdate(updateDTO, entity); mapper.partialUpdate(updateDTO, entity);
entity.setPassword(passwordEncoder.encode(updateDTO.password()));
entity = repo.save(entity); entity = repo.save(entity);
return mapper.toDTO(entity); return mapper.toDTO(entity);
} }

View File

@ -0,0 +1,37 @@
package com.hitcommunications.servermanager.utils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseCookie;
import java.time.Duration;
import java.util.Arrays;
import java.util.Optional;
public final class CookieUtils {
private CookieUtils() {
}
public static String getCookieValue(HttpServletRequest request, String name) {
if (request.getCookies() == null) {
return null;
}
Optional<Cookie> cookie = Arrays.stream(request.getCookies())
.filter(c -> name.equals(c.getName()))
.findFirst();
return cookie.map(Cookie::getValue).orElse(null);
}
public static ResponseCookie buildCookie(String name, String value, Duration maxAge, boolean httpOnly, boolean secure) {
return ResponseCookie.from(name, value)
.httpOnly(httpOnly)
.secure(secure)
.path("/")
.sameSite("Lax")
.maxAge(maxAge)
.build();
}
}

View File

@ -22,3 +22,9 @@ spring:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
default_schema: server-manager default_schema: server-manager
security:
jwt:
secret: ${JWT_SECRET:change-me-change-me-change-me-change-me-change-me}
access-token-expiration: PT30M
refresh-token-expiration: P30D

View File

@ -0,0 +1,20 @@
INSERT INTO "server-manager".tab_users (
id,
username,
email,
password,
first_name,
last_name,
created_at,
updated_at
) VALUES (
'00000000-0000-0000-0000-000000000001',
'default',
'default@hittelco.com',
'$2b$10$4hvMSZp/AC0k9HXqxtdO2e0KUQReRgcnJEcY5gWYTPiDMgu2D4bSK',
'Default',
'User',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,3 @@
# Example env file for Vite
# Copy to .env and set your backend URL
VITE_BACKEND_URL=http://localhost:8080

View File

@ -1,73 +1,36 @@
# React + TypeScript + Vite # Frontend - Servers Manager
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. ## Stack
- React 19, React Router 7
- Axios (withCredentials)
- Tailwind (config in `tailwind.config.js`)
- react-hot-toast para feedback
Currently, two official plugins are available: ## Configuração
Crie `.env` a partir de `.env.example`:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh ```
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh VITE_BACKEND_URL=http://localhost:8080
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ## Rodar
```bash
```js yarn install # ou npm install
// eslint.config.js yarn dev # ou npm run dev
import reactX from 'eslint-plugin-react-x' ```
import reactDom from 'eslint-plugin-react-dom' Acesse `http://localhost:5173`.
export default defineConfig([ ## Fluxo de login
globalIgnores(['dist']), - Página `/login` envia `POST /api/auth/login` com email/senha.
{ - Tokens vêm em cookies (`access_token` e `refresh_token` HttpOnly). Axios usa `withCredentials`.
files: ['**/*.{ts,tsx}'], - Após sucesso, redireciona para `/` (ajuste rota/dashboard conforme evoluir).
extends: [
// Other configs... ## Estrutura
// Enable lint rules for React - `src/Api.ts`: cliente axios com `withCredentials`.
reactX.configs['recommended-typescript'], - `src/pages/Login.tsx`: tela de login e toasts.
// Enable lint rules for React DOM - `src/components/Layout.tsx`: layout base.
reactDom.configs.recommended,
], ## Build/Lint
languageOptions: { ```bash
parserOptions: { yarn build
project: ['./tsconfig.node.json', './tsconfig.app.json'], yarn lint
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```

View File

@ -25,6 +25,7 @@ export default defineConfig([
'react/jsx-indent': ['error', 4], 'react/jsx-indent': ['error', 4],
'react/jsx-indent-props': ['error', 4], 'react/jsx-indent-props': ['error', 4],
'semi': ['error', 'always'], 'semi': ['error', 'always'],
'@typescript-eslint/semi': ['error', 'always'],
}, },
}, },
]) ])

View File

@ -0,0 +1,21 @@
import axios from 'axios'
const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || 'http://localhost:8080';
const api = axios.create({
baseURL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
export const setAuthToken = (token?: string) => {
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete api.defaults.headers.common['Authorization'];
}
};
export default api;

View File

@ -1,12 +1,14 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import './App.css'; import './App.css';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -1,9 +1,13 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import { Toaster } from 'react-hot-toast';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<>
<App /> <App />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</>
</StrictMode>, </StrictMode>,
); );

View File

@ -0,0 +1,3 @@
export const Dashboard = () => {
return <div>Dashboard Page</div>;
};

View File

@ -1,12 +1,20 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import type { LoginProps } from "../types/Login"; import type { LoginProps } from "../types/Login";
import { Eye, EyeOff } from "lucide-icons-react";
import api from "../Api";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
export const Login = () => { export const Login = () => {
const [form, setForm] = useState<LoginProps>({ const [form, setForm] = useState<LoginProps>({
email: "", email: "",
password: "" password: ""
}); });
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ setForm({
@ -15,15 +23,36 @@ export const Login = () => {
}); });
}; };
useEffect(() => { const handleShowPassword = () => {
console.log(form); setShowPassword(!showPassword);
}, [form]); };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await api.post("/api/auth/login", {
username: form.email,
password: form.password
});
navigate("/");
toast.success("Login realizado com sucesso!");
} catch (err: any) {
const message = err?.response?.data?.message || "Falha ao autenticar. Verifique as credenciais.";
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
return ( return (
<Layout className={Styles.layout}> <Layout className={Styles.layout}>
<img src="/logo.webp " alt="Logo" className={Styles.logo} /> <img src="/logo.webp " alt="Logo" className={Styles.logo} />
<div className={Styles.card}> <div className={Styles.card}>
<form> <form onSubmit={handleSubmit}>
<div> <div>
<label htmlFor="email" className={Styles.label}>Email:</label> <label htmlFor="email" className={Styles.label}>Email:</label>
<input <input
@ -35,10 +64,10 @@ export const Login = () => {
className={Styles.input} className={Styles.input}
onChange={handleChange}/> onChange={handleChange}/>
</div> </div>
<div> <div className={Styles.inputWrapper}>
<label htmlFor="password" className={Styles.label}>Password:</label> <label htmlFor="password" className={Styles.label}>Password:</label>
<input <input
type="password" type={showPassword ? "text" : "password"}
id="password" id="password"
name="password" name="password"
placeholder="********" placeholder="********"
@ -46,8 +75,19 @@ export const Login = () => {
className={Styles.input} className={Styles.input}
onChange={handleChange} onChange={handleChange}
/> />
<button
type="button"
aria-label={showPassword ? "Hide password" : "Show password"}
className={Styles.iconButton}
onClick={handleShowPassword}
>
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
</div> </div>
<button type="submit" className={Styles.button}>Login</button> {error && <p className="text-red-500 text-sm mt-2">{error}</p>}
<button type="submit" disabled={loading} className={Styles.button}>
{loading ? "Autenticando..." : "Login"}
</button>
</form> </form>
</div> </div>
</Layout> </Layout>
@ -55,10 +95,12 @@ export const Login = () => {
}; };
const Styles = { const Styles = {
layout: "h-screen flex flex-col gap-4 justify-center", layout: "h-screen flex flex-col gap-4 justify-center animate-fade-up",
logo: "w-36 h-36 mx-auto mb-4", logo: "w-36 h-36 mx-auto mb-4",
card: "w-96 p-8 shadow-lg rounded-lg border border-cardBorder bg-card", card: "w-96 p-8 shadow-lg rounded-lg border border-cardBorder bg-card",
label: "block mb-2 text-md font-medium text-text", label: "block mb-2 text-md font-medium text-text",
inputWrapper: "relative",
input: "bg-gray-50 border border-cardBorder text-text text-md rounded-lg outline-none block w-full p-2.5", input: "bg-gray-50 border border-cardBorder text-text text-md rounded-lg outline-none block w-full p-2.5",
iconButton: "absolute right-3 top-1/2 text-text p-1 focus:outline-none",
button: "w-full bg-accent p-2 rounded-md mt-4 text-white font-bold text-lg hover:bg-hover transition duration-150", button: "w-full bg-accent p-2 rounded-md mt-4 text-white font-bold text-lg hover:bg-hover transition duration-150",
}; };

View File

@ -15,6 +15,15 @@ export default {
hover: '#D04A0F', hover: '#D04A0F',
accent: '#E95A1B', accent: '#E95A1B',
}, },
keyframes: {
'fade-up': {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-up': 'fade-up 300ms ease-out forwards',
},
}, },
}, },
plugins: [], plugins: [],