Compare commits

..

11 Commits

Author SHA1 Message Date
Artur Oliveira c665aa18ea feat(ui): ocultar dados sensíveis na tabela
- adiciona botão para alternar visibilidade de IP, porta, usuário e senha
- mantém placeholders quando oculto e preserva layout paginado
2025-12-16 18:19:14 -03:00
Artur Oliveira 7460577423 feat(pagination): paginar listagem de servidores
- adiciona DTO de página e paginação no endpoint GET /api/servers
- aplica busca paginada no service/repositório com limites seguros
- atualiza dashboard e tabela React com controles e requisições paginadas
2025-12-16 18:16:22 -03:00
Artur Oliveira d4d65ad0f9 feat(frontend): consumir tipos dinâmicos
- remove listas fixas em Dashboard, Header e FilterBar
- busca opções via API e reutiliza nos selects e métricas
- fallback dos labels agora trata tipos desconhecidos
2025-12-16 18:09:54 -03:00
Artur Oliveira a43fc58ff7 feat(servers): permitir tipos vindos do banco
- remove enums fixos de tipo, aplicação e dbType
- atualiza DTOs, controller e repositório para lidar com strings normalizadas
- normaliza e registra tipos durante criação e importação em massa
2025-12-16 18:09:40 -03:00
Artur Oliveira bba78772db feat(servers): exibir senha na listagem
- inclui o campo password no DTO enviado pelo backend
- ajusta tipagens e tabela do dashboard para mostrar a credencial
2025-12-16 17:40:29 -03:00
Artur Oliveira 25579ab7bd feat(applications): ampliar lista suportada
- adiciona CDR, FUNCIONALIDADE e VOICEMAIL ao enum Applications
- reflete as novas opções nos selects de cadastro e filtros no frontend
2025-12-16 17:40:11 -03:00
Artur Oliveira 6d5a64be89 feat(types): tornar tipos configuráveis
- converte entidades de servidores para armazenar type/application/db como texto
- adiciona modelo e API para registrar/listar TypeOptions com normalização
- centraliza schema/data scripts para criar schema e seedar tipos e usuário padrão
2025-12-16 17:39:56 -03:00
Artur Oliveira ed247c423e chore(devops): adicionar suporte a docker
- adiciona dockerfiles e docker-compose para backend, frontend e postgres
- atualiza README com instruções e libera CORS para porta 4173
2025-12-16 17:37:41 -03:00
Artur Oliveira f9b62dcc4e feat(core): padronizar modais e publicar swagger
- extrai componente Modal e atualiza formulários
- adiciona config OpenAPI e libera Swagger publicamente
- aplica helpers de validação e Spotless no backend
2025-12-16 16:19:35 -03:00
Artur Oliveira d08e42732f feat(auth): validar sessão e logout
- Adiciona endpoint de logout no backend e libera na configuração de segurança
- Implementa validação e renovação automática dos tokens no cliente
- Integra botão de sair ao fluxo de logout e redireciona para login
2025-12-16 15:36:02 -03:00
Artur Oliveira 4efdfc9970 feat(servers): habilitar filtros com busca
- Expor GET /api/servers com parametros query, type, application e dbType
- Implementar metodo search com consulta nativa e normalizacao de filtros
- Criar ServersFilterBar e integrar filtros ao Dashboard
- Ajustar entidade Servers e configs JPA para compatibilidade
2025-12-16 15:10:31 -03:00
46 changed files with 1497 additions and 407 deletions

View File

@ -31,6 +31,19 @@ Plataforma interna para catalogar servidores corporativos e facilitar consultas
3. **Testar via Postman** 3. **Testar via Postman**
Importe `postman_collection.json`. Rode "Auth / Login" para setar cookies e seguir para os demais endpoints. Importe `postman_collection.json`. Rode "Auth / Login" para setar cookies e seguir para os demais endpoints.
## Executar com Docker
1. Crie um `.env` na pasta `frontend/` se precisar sobrescrever variáveis (opcional).
2. Construa e suba tudo (Postgres + backend + frontend):
```bash
docker compose up --build
```
3. Endpoints expostos:
- API: `http://localhost:8080`
- Swagger UI: `http://localhost:8080/swagger-ui.html`
- Frontend: `http://localhost:4173`
Variáveis sensíveis (ex.: `JWT_SECRET`, credenciais do banco) podem ser ajustadas diretamente no `docker-compose.yml` ou via arquivos `.env`.
## Documentação específica ## Documentação específica
- Backend: `backend/README.md` - Backend: `backend/README.md`
- Frontend: `frontned/README.md` - Frontend: `frontned/README.md`

View File

@ -0,0 +1,6 @@
build
.gradle
.idea
*.iml
*.log
HELP.md

20
backend/Dockerfile 100644
View File

@ -0,0 +1,20 @@
FROM gradle:8.10.2-jdk21 AS builder
WORKDIR /workspace
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
COPY src src
RUN chmod +x gradlew && ./gradlew bootJar --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
ENV JAVA_OPTS=""
COPY --from=builder /workspace/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

View File

@ -32,6 +32,20 @@ Seed: `src/main/resources/data.sql` cria usuário padrão `default@hittelco.com`
``` ```
API em `http://localhost:8080` com CORS liberado para `http://localhost:5173`. API em `http://localhost:8080` com CORS liberado para `http://localhost:5173`.
## Documentação OpenAPI
- Dependência `springdoc-openapi` habilita `/swagger-ui.html` (UI) e `/v3/api-docs` (JSON).
- Endpoints agora trazem `@Operation` e `@ApiResponses`, facilitando entendimento e testes.
- Para testar rotas protegidas via Swagger UI, execute `/api/auth/login` pela própria interface; os cookies emitidos serão armazenados no navegador e enviados nas requisições seguintes.
## Formatação com Spotless
O projeto usa [Spotless](https://github.com/diffplug/spotless) para padronizar o código Java (imports, formatação Google Java Format).
Comandos úteis:
```bash
./gradlew spotlessCheck # valida formatação (executado em pipelines via 'check')
./gradlew spotlessApply # ajusta os arquivos automaticamente
```
## Testes ## Testes
```bash ```bash
./gradlew test ./gradlew test

View File

@ -2,6 +2,7 @@ plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '4.0.0' id 'org.springframework.boot' version '4.0.0'
id 'io.spring.dependency-management' version '1.1.7' id 'io.spring.dependency-management' version '1.1.7'
id 'com.diffplug.spotless' version '6.25.0'
} }
group = 'com.hitcommunications' group = 'com.hitcommunications'
@ -33,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'org.mapstruct:mapstruct:1.6.3' implementation 'org.mapstruct:mapstruct:1.6.3'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
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'
@ -51,3 +53,16 @@ dependencies {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
} }
spotless {
java {
target 'src/**/*.java'
importOrder()
removeUnusedImports()
googleJavaFormat()
}
}
tasks.named('check') {
dependsOn 'spotlessCheck'
}

View File

@ -0,0 +1,36 @@
package com.hitcommunications.servermanager.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "Servers Manager API",
version = "v1",
description = "Catálogo interno de servidores, usuários e integrações."
)
)
@SecurityScheme(
name = "jwt-auth",
type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.COOKIE,
paramName = "access_token",
description = "O login emite o cookie 'access_token'. O Swagger UI reutiliza automaticamente esse cookie nas rotas protegidas."
)
public class OpenApiConfig {
@Bean
public GroupedOpenApi serversManagerApi() {
return GroupedOpenApi.builder()
.group("servers-manager")
.pathsToMatch("/api/**")
.build();
}
}

View File

@ -40,7 +40,15 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/api/auth/login", "/api/auth/refresh").permitAll() .requestMatchers(
"/",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.authenticationProvider(authenticationProvider()) .authenticationProvider(authenticationProvider())
@ -72,7 +80,9 @@ public class SecurityConfig {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of( configuration.setAllowedOrigins(List.of(
"http://localhost:5173", "http://localhost:5173",
"http://127.0.0.1:5173" "http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173"
)); ));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("*"));

View File

@ -9,6 +9,11 @@ import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
import com.hitcommunications.servermanager.model.dtos.UserDTO; import com.hitcommunications.servermanager.model.dtos.UserDTO;
import com.hitcommunications.servermanager.services.AuthService; import com.hitcommunications.servermanager.services.AuthService;
import com.hitcommunications.servermanager.utils.CookieUtils; import com.hitcommunications.servermanager.utils.CookieUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -20,9 +25,12 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Authentication")
public class AuthController { public class AuthController {
public static final String REFRESH_TOKEN_COOKIE = "refresh_token"; public static final String REFRESH_TOKEN_COOKIE = "refresh_token";
@ -31,12 +39,22 @@ public class AuthController {
private final JwtProperties jwtProperties; private final JwtProperties jwtProperties;
@PostMapping("/login") @PostMapping("/login")
@Operation(summary = "Autentica usuário e emite cookies JWT.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Login realizado e cookies emitidos."),
@ApiResponse(responseCode = "401", description = "Credenciais inválidas.")
})
public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request, HttpServletRequest httpRequest) { public ResponseEntity<AuthResponse> login(@RequestBody @Valid LoginRequest request, HttpServletRequest httpRequest) {
AuthTokens tokens = authService.login(request); AuthTokens tokens = authService.login(request);
return buildResponse(tokens, httpRequest); return buildResponse(tokens, httpRequest);
} }
@PostMapping("/refresh") @PostMapping("/refresh")
@Operation(summary = "Renova os cookies de sessão a partir do refresh token.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Tokens renovados com sucesso."),
@ApiResponse(responseCode = "401", description = "Refresh token inválido ou ausente.")
})
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request) { public ResponseEntity<AuthResponse> refresh(HttpServletRequest request) {
String refreshToken = CookieUtils.getCookieValue(request, REFRESH_TOKEN_COOKIE); String refreshToken = CookieUtils.getCookieValue(request, REFRESH_TOKEN_COOKIE);
AuthTokens tokens = authService.refresh(refreshToken); AuthTokens tokens = authService.refresh(refreshToken);
@ -44,11 +62,46 @@ public class AuthController {
} }
@PostMapping("/signup") @PostMapping("/signup")
@Operation(summary = "Cria um novo usuário autenticado.")
@SecurityRequirement(name = "jwt-auth")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário criado com sucesso."),
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
@ApiResponse(responseCode = "409", description = "Email já utilizado.")
})
public ResponseEntity<UserDTO> signup(@RequestBody @Valid NewUserDTO request) throws IllegalAccessException { public ResponseEntity<UserDTO> signup(@RequestBody @Valid NewUserDTO request) throws IllegalAccessException {
UserDTO user = authService.signup(request); UserDTO user = authService.signup(request);
return ResponseEntity.ok(user); return ResponseEntity.ok(user);
} }
@PostMapping("/logout")
@Operation(summary = "Remove tokens de sessão e encerra o login atual.")
@SecurityRequirement(name = "jwt-auth")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Sessão encerrada.")
})
public ResponseEntity<Void> logout(HttpServletRequest request) {
boolean secure = request.isSecure();
ResponseCookie expiredAccessCookie = CookieUtils.buildCookie(
JwtAuthenticationFilter.ACCESS_TOKEN_COOKIE,
"",
Duration.ZERO,
false,
secure
);
ResponseCookie expiredRefreshCookie = CookieUtils.buildCookie(
REFRESH_TOKEN_COOKIE,
"",
Duration.ZERO,
true,
secure
);
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, expiredAccessCookie.toString(), expiredRefreshCookie.toString())
.build();
}
private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) { private ResponseEntity<AuthResponse> buildResponse(AuthTokens tokens, HttpServletRequest request) {
boolean secure = request.isSecure(); boolean secure = request.isSecure();
ResponseCookie accessCookie = CookieUtils.buildCookie( ResponseCookie accessCookie = CookieUtils.buildCookie(

View File

@ -3,13 +3,25 @@ package com.hitcommunications.servermanager.controllers;
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse; import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
import com.hitcommunications.servermanager.model.dtos.NewServerDTO; import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
import com.hitcommunications.servermanager.model.dtos.ServerDTO; import com.hitcommunications.servermanager.model.dtos.ServerDTO;
import com.hitcommunications.servermanager.model.enums.Applications; import com.hitcommunications.servermanager.model.dtos.PagedResponse;
import com.hitcommunications.servermanager.model.enums.ServersType;
import com.hitcommunications.servermanager.services.ServersService; import com.hitcommunications.servermanager.services.ServersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.List; import java.util.List;
@ -18,56 +30,104 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/servers") @RequestMapping("/api/servers")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Servers")
@SecurityRequirement(name = "jwt-auth")
public class ServersController { public class ServersController {
private final ServersService serversService; private final ServersService serversService;
@PostMapping @PostMapping
@Operation(summary = "Cria um novo servidor.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Servidor criado."),
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
@ApiResponse(responseCode = "409", description = "Servidor duplicado.")
})
public ResponseEntity<ServerDTO> create(@RequestBody @Valid NewServerDTO createDTO) { public ResponseEntity<ServerDTO> create(@RequestBody @Valid NewServerDTO createDTO) {
return ResponseEntity.ok().body(serversService.create(createDTO)); return ResponseEntity.ok().body(serversService.create(createDTO));
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "Busca servidor pelo ID.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Servidor encontrado."),
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
})
public ResponseEntity<ServerDTO> getById(@PathVariable String id) { public ResponseEntity<ServerDTO> getById(@PathVariable String id) {
return ResponseEntity.ok().body(serversService.getById(id)); return ResponseEntity.ok().body(serversService.getById(id));
} }
@GetMapping("/name/{name}") @GetMapping("/name/{name}")
@Operation(summary = "Busca servidor pelo nome.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Servidor encontrado."),
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
})
public ResponseEntity<ServerDTO> getByName(@PathVariable String name) { public ResponseEntity<ServerDTO> getByName(@PathVariable String name) {
return ResponseEntity.ok().body(serversService.getByName(name)); return ResponseEntity.ok().body(serversService.getByName(name));
} }
@GetMapping("/type/{type}") @GetMapping("/type/{type}")
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable ServersType type) { @Operation(summary = "Lista servidores por tipo.")
@ApiResponse(responseCode = "200", description = "Lista retornada.")
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable String type) {
return ResponseEntity.ok().body(serversService.getByType(type)); return ResponseEntity.ok().body(serversService.getByType(type));
} }
@GetMapping("/type") @GetMapping("/type")
public ResponseEntity<Map<ServersType, Integer>> countAllByType() { @Operation(summary = "Conta servidores agrupando por tipo.")
@ApiResponse(responseCode = "200", description = "Mapa retornado.")
public ResponseEntity<Map<String, Integer>> countAllByType() {
return ResponseEntity.ok().body(serversService.countAllByType()); return ResponseEntity.ok().body(serversService.countAllByType());
} }
@PostMapping("/bulk") @PostMapping("/bulk")
@Operation(summary = "Realiza importação em massa via CSV.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Arquivo processado."),
@ApiResponse(responseCode = "400", description = "Arquivo inválido.")
})
public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) { public ResponseEntity<BulkServerImportResponse> bulkCreate(@RequestParam("file") MultipartFile file) {
return ResponseEntity.ok().body(serversService.bulkCreate(file)); return ResponseEntity.ok().body(serversService.bulkCreate(file));
} }
@GetMapping("/application/{application}") @GetMapping("/application/{application}")
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) { @Operation(summary = "Lista servidores por aplicação.")
@ApiResponse(responseCode = "200", description = "Lista retornada.")
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable String application) {
return ResponseEntity.ok().body(serversService.getByApplication(application)); return ResponseEntity.ok().body(serversService.getByApplication(application));
} }
@GetMapping @GetMapping
public ResponseEntity<List<ServerDTO>> getAll() { @Operation(summary = "Pesquisa servidores com filtros combinados.")
return ResponseEntity.ok().body(serversService.getAll()); @ApiResponse(responseCode = "200", description = "Resultados retornados.")
public ResponseEntity<PagedResponse<ServerDTO>> getAll(
@RequestParam(value = "query", required = false) String query,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "application", required = false) String application,
@RequestParam(value = "dbType", required = false) String dbType,
@RequestParam(value = "page", required = false, defaultValue = "0") Integer page,
@RequestParam(value = "size", required = false, defaultValue = "10") Integer size
) {
return ResponseEntity.ok().body(serversService.search(query, type, application, dbType, page, size));
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "Atualiza um servidor existente.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Servidor atualizado."),
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
})
public ResponseEntity<ServerDTO> update(@PathVariable String id, @RequestBody @Valid NewServerDTO updateDTO) { public ResponseEntity<ServerDTO> update(@PathVariable String id, @RequestBody @Valid NewServerDTO updateDTO) {
return ResponseEntity.ok().body(serversService.update(id, updateDTO)); return ResponseEntity.ok().body(serversService.update(id, updateDTO));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "Remove um servidor.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Servidor removido."),
@ApiResponse(responseCode = "404", description = "Servidor não encontrado.")
})
public ResponseEntity<Void> delete(@PathVariable String id) { public ResponseEntity<Void> delete(@PathVariable String id) {
serversService.delete(id); serversService.delete(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();

View File

@ -0,0 +1,29 @@
package com.hitcommunications.servermanager.controllers;
import com.hitcommunications.servermanager.model.enums.TypeCategory;
import com.hitcommunications.servermanager.services.TypeOptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/type-options")
@RequiredArgsConstructor
@Tag(name = "Type Options")
public class TypeOptionsController {
private final TypeOptionService typeOptionService;
@GetMapping("/{category}")
@Operation(summary = "Lista os valores disponíveis para um tipo de categoria (SERVER_TYPE, APPLICATION, DATABASE).")
public ResponseEntity<List<String>> list(@PathVariable TypeCategory category) {
return ResponseEntity.ok(typeOptionService.list(category));
}
}

View File

@ -3,10 +3,21 @@ package com.hitcommunications.servermanager.controllers;
import com.hitcommunications.servermanager.model.dtos.NewUserDTO; import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
import com.hitcommunications.servermanager.model.dtos.UserDTO; import com.hitcommunications.servermanager.model.dtos.UserDTO;
import com.hitcommunications.servermanager.services.UsersService; import com.hitcommunications.servermanager.services.UsersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -14,36 +25,66 @@ import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "Users")
@SecurityRequirement(name = "jwt-auth")
public class UsersController { public class UsersController {
private final UsersService usersService; private final UsersService usersService;
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "Busca usuário pelo ID.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
})
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));
} }
@GetMapping("/username/{username}") @GetMapping("/username/{username}")
@Operation(summary = "Busca usuário pelo username.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
})
public ResponseEntity<UserDTO> getByUsername(@PathVariable String username) { public ResponseEntity<UserDTO> getByUsername(@PathVariable String username) {
return ResponseEntity.ok().body(usersService.getByUsername(username)); return ResponseEntity.ok().body(usersService.getByUsername(username));
} }
@GetMapping("/email/{email}") @GetMapping("/email/{email}")
@Operation(summary = "Busca usuário pelo email.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário encontrado."),
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
})
public ResponseEntity<UserDTO> getByEmail(@PathVariable String email) { public ResponseEntity<UserDTO> getByEmail(@PathVariable String email) {
return ResponseEntity.ok().body(usersService.getByEmail(email)); return ResponseEntity.ok().body(usersService.getByEmail(email));
} }
@GetMapping @GetMapping
@Operation(summary = "Lista todos os usuários.")
@ApiResponse(responseCode = "200", description = "Lista retornada.")
public ResponseEntity<List<UserDTO>> getAll() { public ResponseEntity<List<UserDTO>> getAll() {
return ResponseEntity.ok().body(usersService.getAll()); return ResponseEntity.ok().body(usersService.getAll());
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "Atualiza os dados de um usuário.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Usuário atualizado."),
@ApiResponse(responseCode = "400", description = "Dados inválidos."),
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
})
public ResponseEntity<UserDTO> update(@PathVariable UUID id, @RequestBody @Valid NewUserDTO updateDTO) throws IllegalAccessException { public ResponseEntity<UserDTO> update(@PathVariable UUID id, @RequestBody @Valid NewUserDTO updateDTO) throws IllegalAccessException {
return ResponseEntity.ok().body(usersService.update(id, updateDTO)); return ResponseEntity.ok().body(usersService.update(id, updateDTO));
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "Remove um usuário.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Usuário removido."),
@ApiResponse(responseCode = "404", description = "Usuário não encontrado.")
})
public ResponseEntity<Void> delete(@PathVariable UUID id) { public ResponseEntity<Void> delete(@PathVariable UUID id) {
usersService.delete(id); usersService.delete(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();

View File

@ -1,15 +1,21 @@
package com.hitcommunications.servermanager.model; package com.hitcommunications.servermanager.model;
import com.hitcommunications.servermanager.model.enums.Applications; import java.sql.Timestamp;
import com.hitcommunications.servermanager.model.enums.DatabaseType;
import com.hitcommunications.servermanager.model.enums.ServersType;
import com.hitcommunications.servermanager.utils.ServerIdGenerator;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import java.sql.Timestamp; import com.hitcommunications.servermanager.utils.ServerIdGenerator;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity @Entity
@Table(name = "tab_servers") @Table(name = "tab_servers")
@ -28,7 +34,7 @@ public class Servers {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(nullable = false) @Column(nullable = false, columnDefinition = "VARCHAR(45)")
private String ip; private String ip;
@Column(nullable = false) @Column(nullable = false)
@ -40,17 +46,14 @@ public class Servers {
@Column(nullable = false) @Column(nullable = false)
private String password; private String password;
@Column(nullable = false) @Column(nullable = false, length = 80)
@Enumerated(EnumType.STRING) private String type;
private ServersType type;
@Column(nullable = false) @Column(nullable = false, length = 80)
@Enumerated(EnumType.STRING) private String application;
private Applications application;
@Column(nullable = false) @Column(nullable = false, length = 80, name = "db_type")
@Enumerated(EnumType.STRING) private String dbType;
private DatabaseType dbType;
@CreationTimestamp @CreationTimestamp
private Timestamp createdAt; private Timestamp createdAt;

View File

@ -0,0 +1,33 @@
package com.hitcommunications.servermanager.model;
import com.hitcommunications.servermanager.model.enums.TypeCategory;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "tab_type_options", schema = "server-manager", uniqueConstraints = {
@UniqueConstraint(columnNames = {"category", "value"})
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TypeOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private TypeCategory category;
@Column(nullable = false, length = 80)
private String value;
}

View File

@ -1,8 +1,5 @@
package com.hitcommunications.servermanager.model.dtos; package com.hitcommunications.servermanager.model.dtos;
import com.hitcommunications.servermanager.model.enums.Applications;
import com.hitcommunications.servermanager.model.enums.DatabaseType;
import com.hitcommunications.servermanager.model.enums.ServersType;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@ -12,9 +9,8 @@ public record NewServerDTO(
@NotNull Integer port, @NotNull Integer port,
@NotBlank String user, @NotBlank String user,
@NotBlank String password, @NotBlank String password,
@NotNull ServersType type, @NotBlank String type,
@NotNull Applications application, @NotBlank String application,
@NotNull DatabaseType dbType @NotBlank String dbType
) { ) {
} }

View File

@ -0,0 +1,12 @@
package com.hitcommunications.servermanager.model.dtos;
import java.util.List;
public record PagedResponse<T>(
List<T> content,
long totalItems,
int totalPages,
int page,
int size
) {
}

View File

@ -1,9 +1,5 @@
package com.hitcommunications.servermanager.model.dtos; package com.hitcommunications.servermanager.model.dtos;
import com.hitcommunications.servermanager.model.enums.Applications;
import com.hitcommunications.servermanager.model.enums.DatabaseType;
import com.hitcommunications.servermanager.model.enums.ServersType;
import java.sql.Timestamp; import java.sql.Timestamp;
public record ServerDTO( public record ServerDTO(
@ -12,11 +8,11 @@ public record ServerDTO(
String ip, String ip,
Integer port, Integer port,
String user, String user,
ServersType type, String password,
Applications application, String type,
DatabaseType dbType, String application,
String dbType,
Timestamp createdAt, Timestamp createdAt,
Timestamp updatedAt Timestamp updatedAt
) { ) {
} }

View File

@ -1,9 +0,0 @@
package com.hitcommunications.servermanager.model.enums;
public enum Applications {
ASTERISK,
HITMANAGER,
HITMANAGER_V2,
OMNIHIT,
HITPHONE
}

View File

@ -1,12 +0,0 @@
package com.hitcommunications.servermanager.model.enums;
public enum DatabaseType {
MYSQL,
POSTGRESQL,
SQLSERVER,
ORACLE,
REDIS,
MONGODB,
MARIADB,
NONE
}

View File

@ -1,7 +1,7 @@
package com.hitcommunications.servermanager.model.enums; package com.hitcommunications.servermanager.model.enums;
public enum ServersType { public enum TypeCategory {
PRODUCTION, SERVER_TYPE,
HOMOLOGATION, APPLICATION,
DATABASE DATABASE
} }

View File

@ -1,18 +1,59 @@
package com.hitcommunications.servermanager.repositories; package com.hitcommunications.servermanager.repositories;
import com.hitcommunications.servermanager.model.Servers; import com.hitcommunications.servermanager.model.Servers;
import com.hitcommunications.servermanager.model.enums.Applications;
import com.hitcommunications.servermanager.model.enums.ServersType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ServersRepository extends JpaRepository<Servers, String> { public interface ServersRepository extends JpaRepository<Servers, String> {
Optional<Servers> findByName(String name); Optional<Servers> findByName(String name);
List<Servers> findByType(ServersType type); List<Servers> findByType(String type);
List<Servers> findByApplication(Applications application); List<Servers> findByApplication(String application);
Optional<Servers> findByIpAndPort(String ip, Integer port); Optional<Servers> findByIpAndPort(String ip, Integer port);
Integer countAllByType(ServersType type); Integer countAllByType(String type);
}
@Query(
value = """
select s.* from "server-manager".tab_servers s
where
(case when :query is null or length(:query) = 0
then true
else (
lower(s.name) like lower(concat('%', cast(:query as text), '%'))
or lower(s.username) like lower(concat('%', cast(:query as text), '%'))
or lower(s.ip) like lower(concat('%', cast(:query as text), '%'))
)
end)
and (:type is null or s.type = :type)
and (:application is null or s.application = :application)
and (:dbType is null or s.db_type = :dbType)
""",
countQuery = """
select count(*) from "server-manager".tab_servers s
where
(case when :query is null or length(:query) = 0
then true
else (
lower(s.name) like lower(concat('%', cast(:query as text), '%'))
or lower(s.username) like lower(concat('%', cast(:query as text), '%'))
or lower(s.ip) like lower(concat('%', cast(:query as text), '%'))
)
end)
and (:type is null or s.type = :type)
and (:application is null or s.application = :application)
and (:dbType is null or s.db_type = :dbType)
""",
nativeQuery = true
)
Page<Servers> search(
@Param("query") String query,
@Param("type") String type,
@Param("application") String application,
@Param("dbType") String dbType,
Pageable pageable
);
}

View File

@ -0,0 +1,13 @@
package com.hitcommunications.servermanager.repositories;
import com.hitcommunications.servermanager.model.TypeOption;
import com.hitcommunications.servermanager.model.enums.TypeCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface TypeOptionRepository extends JpaRepository<TypeOption, Long> {
Optional<TypeOption> findByCategoryAndValue(TypeCategory category, String value);
List<TypeOption> findAllByCategoryOrderByValueAsc(TypeCategory category);
}

View File

@ -6,11 +6,15 @@ import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse;
import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow; import com.hitcommunications.servermanager.model.dtos.BulkServerImportResponse.FailedRow;
import com.hitcommunications.servermanager.model.dtos.NewServerDTO; import com.hitcommunications.servermanager.model.dtos.NewServerDTO;
import com.hitcommunications.servermanager.model.dtos.ServerDTO; import com.hitcommunications.servermanager.model.dtos.ServerDTO;
import com.hitcommunications.servermanager.model.enums.Applications; import com.hitcommunications.servermanager.model.dtos.PagedResponse;
import com.hitcommunications.servermanager.model.enums.DatabaseType; import com.hitcommunications.servermanager.model.enums.TypeCategory;
import com.hitcommunications.servermanager.model.enums.ServersType;
import com.hitcommunications.servermanager.repositories.ServersRepository; import com.hitcommunications.servermanager.repositories.ServersRepository;
import com.hitcommunications.servermanager.services.TypeOptionService;
import com.hitcommunications.servermanager.utils.TypeNormalizer;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -18,24 +22,23 @@ import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ServersService { public class ServersService {
private static final int DEFAULT_PAGE_SIZE = 10;
private static final int MAX_PAGE_SIZE = 50;
private final ServersMapper mapper; private final ServersMapper mapper;
private final ServersRepository repo; private final ServersRepository repo;
private final TypeOptionService typeOptionService;
public ServerDTO create(NewServerDTO createDTO) { public ServerDTO create(NewServerDTO createDTO) {
// Check if server with same IP and port already exists
repo.findByIpAndPort(createDTO.ip(), createDTO.port()).ifPresent(entity -> {
throw new RuntimeException("Server already exists with IP: " + createDTO.ip() + " and port: " + createDTO.port());
});
Servers entity = mapper.toEntity(createDTO); Servers entity = mapper.toEntity(createDTO);
applyNormalizedTypeData(entity, createDTO);
entity = repo.save(entity); entity = repo.save(entity);
return mapper.toDTO(entity); return mapper.toDTO(entity);
} }
@ -52,25 +55,34 @@ public class ServersService {
.orElseThrow(() -> new RuntimeException("Server not found with name: " + name)); .orElseThrow(() -> new RuntimeException("Server not found with name: " + name));
} }
public List<ServerDTO> getByType(ServersType type) { public List<ServerDTO> getByType(String type) {
return repo.findByType(type) String normalized = normalizeFilter(type, TypeCategory.SERVER_TYPE);
return repo.findByType(normalized)
.stream() .stream()
.map(mapper::toDTO) .map(mapper::toDTO)
.toList(); .toList();
} }
public Map<ServersType, Integer> countAllByType() { public Map<String, Integer> countAllByType() {
Map<ServersType, Integer> response = new HashMap<>(); Map<String, Integer> response = new LinkedHashMap<>();
for(ServersType type : ServersType.values()) { typeOptionService.list(TypeCategory.SERVER_TYPE).forEach(type -> {
response.put(type, repo.countAllByType(type)); response.put(type, repo.countAllByType(type));
} });
// Garantir que valores existentes, mas não registrados, também apareçam
repo.findAll().stream()
.map(Servers::getType)
.filter(type -> type != null && !response.containsKey(type))
.distinct()
.forEach(type -> response.put(type, repo.countAllByType(type)));
return response; return response;
} }
public List<ServerDTO> getByApplication(Applications application) { public List<ServerDTO> getByApplication(String application) {
return repo.findByApplication(application) String normalized = normalizeFilter(application, TypeCategory.APPLICATION);
return repo.findByApplication(normalized)
.stream() .stream()
.map(mapper::toDTO) .map(mapper::toDTO)
.toList(); .toList();
@ -97,6 +109,7 @@ public class ServersService {
processed++; processed++;
try { try {
registerTypeOptionsFromRow(columns);
NewServerDTO dto = toNewServerDTO(columns); NewServerDTO dto = toNewServerDTO(columns);
create(dto); create(dto);
succeeded++; succeeded++;
@ -131,13 +144,29 @@ public class ServersService {
Integer port = Integer.parseInt(columns[2]); Integer port = Integer.parseInt(columns[2]);
String user = columns[3]; String user = columns[3];
String password = columns[4]; String password = columns[4];
ServersType type = ServersType.valueOf(columns[5].toUpperCase()); String type = columns[5];
Applications application = Applications.valueOf(columns[6].toUpperCase()); String application = columns[6];
DatabaseType dbType = DatabaseType.valueOf(columns[7].toUpperCase()); String dbType = columns[7];
return new NewServerDTO(name, ip, port, user, password, type, application, dbType); return new NewServerDTO(name, ip, port, user, password, type, application, dbType);
} }
private void registerTypeOptionsFromRow(String[] columns) {
if (columns.length < 8) {
return;
}
registerTypeOption(TypeCategory.SERVER_TYPE, columns[5]);
registerTypeOption(TypeCategory.APPLICATION, columns[6]);
registerTypeOption(TypeCategory.DATABASE, columns[7]);
}
private void registerTypeOption(TypeCategory category, String value) {
if (value == null || value.isBlank()) {
return;
}
typeOptionService.register(category, value);
}
private boolean isHeaderRow(String[] columns) { private boolean isHeaderRow(String[] columns) {
if (columns.length < 8) { if (columns.length < 8) {
return false; return false;
@ -154,18 +183,37 @@ public class ServersService {
.toList(); .toList();
} }
public PagedResponse<ServerDTO> search(String query, String type, String application, String dbType, Integer page, Integer size) {
String normalizedQuery = (query == null || query.isBlank()) ? null : query.trim();
String typeFilter = normalizeFilter(type, TypeCategory.SERVER_TYPE);
String applicationFilter = normalizeFilter(application, TypeCategory.APPLICATION);
String dbTypeFilter = normalizeFilter(dbType, TypeCategory.DATABASE);
int safePage = page != null && page >= 0 ? page : 0;
int requestedSize = (size != null && size > 0) ? size : DEFAULT_PAGE_SIZE;
int safeSize = Math.min(requestedSize, MAX_PAGE_SIZE);
Pageable pageable = PageRequest.of(safePage, safeSize);
Page<Servers> result = repo.search(normalizedQuery, typeFilter, applicationFilter, dbTypeFilter, pageable);
List<ServerDTO> content = result.getContent()
.stream()
.map(mapper::toDTO)
.toList();
return new PagedResponse<>(
content,
result.getTotalElements(),
result.getTotalPages(),
result.getNumber(),
result.getSize()
);
}
public ServerDTO update(String id, NewServerDTO updateDTO) { public ServerDTO update(String id, NewServerDTO updateDTO) {
Servers entity = repo.findById(id) Servers entity = repo.findById(id)
.orElseThrow(() -> new RuntimeException("Server not found with id: " + id)); .orElseThrow(() -> new RuntimeException("Server not found with id: " + id));
// Check if IP/port combination already exists (excluding current server)
repo.findByIpAndPort(updateDTO.ip(), updateDTO.port()).ifPresent(existingServer -> {
if (!existingServer.getId().equals(id)) {
throw new RuntimeException("Server already exists with IP: " + updateDTO.ip() + " and port: " + updateDTO.port());
}
});
mapper.partialUpdate(updateDTO, entity); mapper.partialUpdate(updateDTO, entity);
applyNormalizedTypeData(entity, updateDTO);
entity = repo.save(entity); entity = repo.save(entity);
return mapper.toDTO(entity); return mapper.toDTO(entity);
} }
@ -176,4 +224,31 @@ public class ServersService {
} }
repo.deleteById(id); repo.deleteById(id);
} }
private void applyNormalizedTypeData(Servers entity, NewServerDTO dto) {
entity.setType(normalizeAndRegister(TypeCategory.SERVER_TYPE, dto.type()));
entity.setApplication(normalizeAndRegister(TypeCategory.APPLICATION, dto.application()));
entity.setDbType(normalizeAndRegister(TypeCategory.DATABASE, dto.dbType()));
}
private String normalizeFilter(String value, TypeCategory category) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = TypeNormalizer.normalize(value);
if (normalized == null) {
return null;
}
typeOptionService.register(category, normalized);
return normalized;
}
private String normalizeAndRegister(TypeCategory category, String value) {
String normalized = TypeNormalizer.normalize(value);
if (normalized == null) {
throw new IllegalArgumentException("Valor inválido para " + category.name());
}
typeOptionService.register(category, normalized);
return normalized;
}
} }

View File

@ -0,0 +1,42 @@
package com.hitcommunications.servermanager.services;
import com.hitcommunications.servermanager.model.TypeOption;
import com.hitcommunications.servermanager.model.enums.TypeCategory;
import com.hitcommunications.servermanager.repositories.TypeOptionRepository;
import com.hitcommunications.servermanager.utils.TypeNormalizer;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class TypeOptionService {
private final TypeOptionRepository repository;
@Transactional
public void register(TypeCategory category, String rawValue) {
String normalized = TypeNormalizer.normalize(rawValue);
if (normalized == null) {
return;
}
repository.findByCategoryAndValue(category, normalized)
.orElseGet(() -> repository.save(
TypeOption.builder()
.category(category)
.value(normalized)
.build()
));
}
public List<String> list(TypeCategory category) {
return repository.findAllByCategoryOrderByValueAsc(category)
.stream()
.map(TypeOption::getValue)
.collect(Collectors.toList());
}
}

View File

@ -6,11 +6,11 @@ import com.hitcommunications.servermanager.model.dtos.NewUserDTO;
import com.hitcommunications.servermanager.model.dtos.UserDTO; 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.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -20,17 +20,11 @@ public class UsersService {
private final UsersRepository repo; private final UsersRepository repo;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final String ALLOWED_DOMAIN = Arrays.asList("hittelco.com", "accesscommunications.com").toString(); private static final Set<String> ALLOWED_DOMAINS = Set.of("hittelco.com", "accesscommunications.com");
public UserDTO create(NewUserDTO createDTO) throws IllegalAccessException { public UserDTO create(NewUserDTO createDTO) throws IllegalAccessException {
String domain = getDomain(createDTO.email()); validateAllowedDomain(createDTO.email());
if (!ALLOWED_DOMAIN.contains(domain)) { ensureEmailIsUnique(createDTO.email(), null);
throw new IllegalAccessException("Email domain not allowed: " + domain);
}
repo.findByEmail(createDTO.email()).ifPresent(entity -> {
throw new RuntimeException("Email already exists: " + createDTO.email());
});
Users entity = mapper.toEntity(createDTO); Users entity = mapper.toEntity(createDTO);
entity.setPassword(passwordEncoder.encode(createDTO.password())); entity.setPassword(passwordEncoder.encode(createDTO.password()));
@ -67,17 +61,8 @@ public class UsersService {
Users entity = repo.findById(id) Users entity = repo.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id)); .orElseThrow(() -> new RuntimeException("User not found with id: " + id));
String domain = getDomain(updateDTO.email()); validateAllowedDomain(updateDTO.email());
if (!ALLOWED_DOMAIN.contains(domain)) { ensureEmailIsUnique(updateDTO.email(), id);
throw new IllegalAccessException("Email domain not allowed: " + domain);
}
// Check if email already exists (excluding current user)
repo.findByEmail(updateDTO.email()).ifPresent(existingUser -> {
if (!existingUser.getId().equals(id)) {
throw new RuntimeException("Email already exists: " + updateDTO.email());
}
});
mapper.partialUpdate(updateDTO, entity); mapper.partialUpdate(updateDTO, entity);
entity.setPassword(passwordEncoder.encode(updateDTO.password())); entity.setPassword(passwordEncoder.encode(updateDTO.password()));
@ -95,4 +80,19 @@ public class UsersService {
private String getDomain(String email) { private String getDomain(String email) {
return email.substring(email.indexOf("@") + 1); return email.substring(email.indexOf("@") + 1);
} }
private void validateAllowedDomain(String email) throws IllegalAccessException {
String domain = getDomain(email);
if (!ALLOWED_DOMAINS.contains(domain)) {
throw new IllegalAccessException("Email domain not allowed: " + domain);
}
}
private void ensureEmailIsUnique(String email, UUID currentUserId) {
repo.findByEmail(email).ifPresent(existingUser -> {
if (currentUserId == null || !existingUser.getId().equals(currentUserId)) {
throw new RuntimeException("Email already exists: " + email);
}
});
}
} }

View File

@ -0,0 +1,30 @@
package com.hitcommunications.servermanager.utils;
import java.text.Normalizer;
import java.util.Locale;
import java.util.regex.Pattern;
public final class TypeNormalizer {
private static final Pattern DIACRITICS = Pattern.compile("\\p{M}");
private TypeNormalizer() {
}
public static String normalize(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
return null;
}
String normalized = Normalizer.normalize(trimmed, Normalizer.Form.NFD);
normalized = DIACRITICS.matcher(normalized).replaceAll("");
normalized = normalized.replaceAll("[^A-Za-z0-9_\\- ]", "");
normalized = normalized.replaceAll("\\s+", "_");
return normalized.toUpperCase(Locale.ROOT);
}
}

View File

@ -6,6 +6,7 @@ spring:
init: init:
mode: always mode: always
schema-locations: classpath:schema.sql schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
datasource: datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
@ -13,6 +14,7 @@ spring:
password: ${DB_PASSWD} password: ${DB_PASSWD}
jpa: jpa:
defer-datasource-initialization: true
hibernate: hibernate:
ddl-auto: update ddl-auto: update
show-sql: true show-sql: true

View File

@ -18,3 +18,26 @@ INSERT INTO "server-manager".tab_users (
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO "server-manager".tab_type_options (id, category, value)
VALUES
(101, 'SERVER_TYPE', 'PRODUCTION'),
(102, 'SERVER_TYPE', 'HOMOLOGATION'),
(103, 'SERVER_TYPE', 'DATABASE'),
(201, 'APPLICATION', 'ASTERISK'),
(202, 'APPLICATION', 'HITMANAGER'),
(203, 'APPLICATION', 'HITMANAGER_V2'),
(204, 'APPLICATION', 'OMNIHIT'),
(205, 'APPLICATION', 'HITPHONE'),
(206, 'APPLICATION', 'CDR'),
(207, 'APPLICATION', 'FUNCIONALIDADE'),
(208, 'APPLICATION', 'VOICEMAIL'),
(301, 'DATABASE', 'MYSQL'),
(302, 'DATABASE', 'POSTGRESQL'),
(303, 'DATABASE', 'SQLSERVER'),
(304, 'DATABASE', 'ORACLE'),
(305, 'DATABASE', 'REDIS'),
(306, 'DATABASE', 'MONGODB'),
(307, 'DATABASE', 'MARIADB'),
(308, 'DATABASE', 'NONE')
ON CONFLICT DO NOTHING;

View File

@ -1 +1,3 @@
CREATE SCHEMA IF NOT EXISTS "server-manager"; CREATE SCHEMA IF NOT EXISTS "server-manager";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

50
docker-compose.yml 100644
View File

@ -0,0 +1,50 @@
services:
db:
image: postgres:15-alpine
container_name: servers-db
restart: unless-stopped
environment:
POSTGRES_DB: servermanager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
volumes:
- db-data:/var/lib/postgresql/data
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: servers-backend
depends_on:
- db
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: servermanager
DB_SCHEMA: server-manager
DB_USER: postgres
DB_PASSWD: postgres
JWT_SECRET: change-me-change-me-change-me-change-me-change-me
ports:
- "8081:8080"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_BACKEND_URL: http://localhost:8081
container_name: servers-frontend
depends_on:
- backend
environment:
PORT: 4173
ports:
- "4173:4173"
restart: unless-stopped
volumes:
db-data:

View File

@ -0,0 +1,6 @@
node_modules
dist
.turbo
.vite
.idea
*.log

View File

@ -0,0 +1,22 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN corepack enable && yarn install --frozen-lockfile
COPY . .
ARG VITE_BACKEND_URL=http://localhost:8080
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
RUN yarn build
FROM node:20-alpine
WORKDIR /app
RUN npm install -g serve
ENV PORT=4173
COPY --from=builder /app/dist ./dist
EXPOSE 4173
CMD ["sh", "-c", "serve -s dist -l ${PORT}"]

View File

@ -2,6 +2,7 @@ import axios from "axios";
const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080"; const baseURL = (import.meta.env.VITE_BACKEND_URL as string) || "http://localhost:8080";
const REFRESH_ENDPOINT = "/api/auth/refresh"; const REFRESH_ENDPOINT = "/api/auth/refresh";
const LOGOUT_ENDPOINT = "/api/auth/logout";
const ACCESS_TOKEN_COOKIE = "access_token"; const ACCESS_TOKEN_COOKIE = "access_token";
const REFRESH_THRESHOLD_MS = 60_000; const REFRESH_THRESHOLD_MS = 60_000;
@ -73,7 +74,19 @@ export const setAuthToken = (token?: string) => {
let refreshPromise: Promise<void> | null = null; let refreshPromise: Promise<void> | null = null;
const refreshAccessToken = async () => { const clearAccessTokenCookie = () => {
if (typeof document === "undefined") {
return;
}
document.cookie = `${ACCESS_TOKEN_COOKIE}=; Max-Age=0; path=/`;
};
const resetAuthState = () => {
clearAccessTokenCookie();
setAuthToken(undefined);
};
export const refreshAccessToken = async () => {
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = api refreshPromise = api
.post(REFRESH_ENDPOINT) .post(REFRESH_ENDPOINT)
@ -92,6 +105,56 @@ const refreshAccessToken = async () => {
return refreshPromise; return refreshPromise;
}; };
const ensureTokenFromRefresh = async () => {
await refreshAccessToken();
const updatedToken = getAccessToken();
if (!updatedToken) {
throw new Error("Token indisponível após atualização.");
}
setAuthToken(updatedToken);
return updatedToken;
};
export const validateSession = async () => {
try {
const token = getAccessToken();
if (!token) {
await ensureTokenFromRefresh();
return true;
}
const payload = decodeJwtPayload(token);
if (!payload?.sub) {
await ensureTokenFromRefresh();
return true;
}
if (payload.exp && payload.exp * 1000 <= Date.now()) {
await ensureTokenFromRefresh();
return true;
}
setAuthToken(token);
if (isTokenExpiringSoon(token)) {
await ensureTokenFromRefresh();
}
return true;
} catch {
resetAuthState();
return false;
}
};
export const logout = async () => {
try {
await api.post(LOGOUT_ENDPOINT);
} finally {
resetAuthState();
}
};
api.interceptors.request.use(async (config) => { api.interceptors.request.use(async (config) => {
const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT); const isRefreshCall = config.url?.includes(REFRESH_ENDPOINT);
@ -120,7 +183,7 @@ api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
setAuthToken(undefined); resetAuthState();
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
if (currentPath !== "/login") { if (currentPath !== "/login") {
window.location.href = "/login"; window.location.href = "/login";

View File

@ -1,6 +1,6 @@
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import api from "../Api"; import api, { logout as requestLogout } from "../Api";
import { HeaderActions } from "./header/HeaderActions"; import { HeaderActions } from "./header/HeaderActions";
import { HeaderBrand } from "./header/HeaderBrand"; import { HeaderBrand } from "./header/HeaderBrand";
import { ProfileModal } from "./header/ProfileModal"; import { ProfileModal } from "./header/ProfileModal";
@ -10,6 +10,7 @@ import type { Applications, DatabaseType, ServersType } from "../types/enums";
import type { User } from "../types/User"; import type { User } from "../types/User";
import type { ProfileFormState, ServerFormState } from "./header/types"; import type { ProfileFormState, ServerFormState } from "./header/types";
import type { BulkImportResult } from "../types/BulkImport"; import type { BulkImportResult } from "../types/BulkImport";
import { useNavigate } from "react-router-dom";
type ModalType = "addServer" | "editProfile" | "bulkImport" | null; type ModalType = "addServer" | "editProfile" | "bulkImport" | null;
@ -18,6 +19,9 @@ interface HeaderProps {
userError: string | null; userError: string | null;
onServerCreated?: () => Promise<void> | void; onServerCreated?: () => Promise<void> | void;
onProfileUpdated?: (user: User) => void; onProfileUpdated?: (user: User) => void;
serverTypeOptions?: ServersType[];
applicationOptions?: Applications[];
databaseOptions?: DatabaseType[];
} }
const defaultServerForm: ServerFormState = { const defaultServerForm: ServerFormState = {
@ -38,11 +42,16 @@ const defaultProfileForm: ProfileFormState = {
password: "", password: "",
}; };
const serverTypeOptions: ServersType[] = ["PRODUCTION", "HOMOLOGATION", "DATABASE"]; export const Header = ({
const applicationOptions: Applications[] = ["ASTERISK", "HITMANAGER", "HITMANAGER_V2", "OMNIHIT", "HITPHONE"]; currentUser,
const databaseOptions: DatabaseType[] = ["MYSQL", "POSTGRESQL", "SQLSERVER", "ORACLE", "REDIS", "MONGODB", "MARIADB", "NONE"]; userError,
onServerCreated,
export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdated }: HeaderProps) => { onProfileUpdated,
serverTypeOptions = [],
applicationOptions = [],
databaseOptions = [],
}: HeaderProps) => {
const navigate = useNavigate();
const [isMenuOpen, setMenuOpen] = useState(false); const [isMenuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<ModalType>(null); const [activeModal, setActiveModal] = useState<ModalType>(null);
const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm); const [serverForm, setServerForm] = useState<ServerFormState>(defaultServerForm);
@ -178,6 +187,20 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const handleLogout = async () => {
setMenuOpen(false);
setActiveModal(null);
try {
await requestLogout();
toast.success("Sessão encerrada com sucesso.");
} catch (err: any) {
const message = err?.response?.data?.message || "Não foi possível encerrar a sessão.";
toast.error(message);
} finally {
navigate("/login", { replace: true });
}
};
return ( return (
<> <>
<header className={Styles.wrapper}> <header className={Styles.wrapper}>
@ -188,6 +211,7 @@ export const Header = ({ currentUser, userError, onServerCreated, onProfileUpdat
onAddServer={() => openModal("addServer")} onAddServer={() => openModal("addServer")}
onEditProfile={() => openModal("editProfile")} onEditProfile={() => openModal("editProfile")}
onBulkCreate={() => openModal("bulkImport")} onBulkCreate={() => openModal("bulkImport")}
onLogout={handleLogout}
/> />
</header> </header>

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import api from "../Api"; import api from "../Api";
import { ServerTypeLabels, type ServersType } from "../types/enums"; import { ServerTypeLabels } from "../types/enums";
import { Code, Database, FlaskConical, Server } from "lucide-icons-react"; import { Code, Database, FlaskConical, Server } from "lucide-icons-react";
interface ServerCount { interface ServerCount {
type: ServersType; type: string;
total: number; total: number;
} }
@ -15,9 +15,9 @@ export const ServerCardMetrics = () => {
useEffect(() => { useEffect(() => {
const fetchCounts = async () => { const fetchCounts = async () => {
try { try {
const { data } = await api.get<Record<ServersType, number>>("/api/servers/type"); const { data } = await api.get<Record<string, number>>("/api/servers/type");
const normalized = Object.entries(data).map(([type, total]) => ({ const normalized = Object.entries(data).map(([type, total]) => ({
type: type as ServersType, type,
total, total,
})); }));
setCounts(normalized); setCounts(normalized);
@ -38,7 +38,7 @@ export const ServerCardMetrics = () => {
return <div className={Styles.placeholder}>Carregando métricas...</div>; return <div className={Styles.placeholder}>Carregando métricas...</div>;
} }
const handleCardIcon = (type: ServersType) => { const handleCardIcon = (type: string) => {
const iconProps = { size: 32, strokeWidth: 1.5 }; const iconProps = { size: 32, strokeWidth: 1.5 };
switch (type) { switch (type) {
@ -64,7 +64,7 @@ export const ServerCardMetrics = () => {
</div> </div>
<div className={Styles.textGroup}> <div className={Styles.textGroup}>
<p className={Styles.value}>{formatTotal(total)}</p> <p className={Styles.value}>{formatTotal(total)}</p>
<p className={Styles.label}>{ServerTypeLabels[type]}</p> <p className={Styles.label}>{ServerTypeLabels[type] ?? type}</p>
</div> </div>
</div> </div>
))} ))}

View File

@ -0,0 +1,109 @@
import type { ChangeEvent } from "react";
import type { Applications, DatabaseType, ServersType } from "../types/enums";
type OptionAll = "ALL";
interface Props {
search: string;
type: ServersType | OptionAll;
application: Applications | OptionAll;
dbType: DatabaseType | OptionAll;
onSearchChange: (value: string) => void;
onTypeChange: (value: ServersType | OptionAll) => void;
onApplicationChange: (value: Applications | OptionAll) => void;
onDbTypeChange: (value: DatabaseType | OptionAll) => void;
serverTypeOptions?: ServersType[];
applicationOptions?: Applications[];
databaseOptions?: DatabaseType[];
}
const withAllOption = <T extends string>(options?: T[]): Array<T | OptionAll> => {
if (!options || options.length === 0) {
return ["ALL"];
}
const unique = Array.from(new Set(options));
return ["ALL", ...unique];
};
export const ServersFilterBar = ({
search,
type,
application,
dbType,
onSearchChange,
onTypeChange,
onApplicationChange,
onDbTypeChange,
serverTypeOptions,
applicationOptions,
databaseOptions,
}: Props) => {
const typeOptions = withAllOption(serverTypeOptions);
const applicationOptionsList = withAllOption(applicationOptions);
const databaseOptionsList = withAllOption(databaseOptions);
return (
<div className={Styles.wrapper}>
<div className={Styles.searchGroup}>
<label htmlFor="server-search" className={Styles.label}>Buscar</label>
<input
id="server-search"
type="text"
placeholder="Buscar por nome, IP ou usuário..."
value={search}
onChange={(event) => onSearchChange(event.target.value)}
className={Styles.input}
/>
</div>
<Select
label="Tipo"
value={type}
onChange={(event) => onTypeChange(event.target.value as ServersType | OptionAll)}
options={typeOptions}
/>
<Select
label="Aplicação"
value={application}
onChange={(event) => onApplicationChange(event.target.value as Applications | OptionAll)}
options={applicationOptionsList}
/>
<Select
label="Banco"
value={dbType}
onChange={(event) => onDbTypeChange(event.target.value as DatabaseType | OptionAll)}
options={databaseOptionsList}
/>
</div>
);
};
interface SelectProps<T extends string> {
label: string;
value: T;
onChange: (event: ChangeEvent<HTMLSelectElement>) => void;
options: T[];
}
const Select = <T extends string>({ label, value, onChange, options }: SelectProps<T>) => {
return (
<div className={Styles.selectGroup}>
<label className={Styles.label}>{label}</label>
<select value={value} onChange={onChange} className={Styles.select}>
{options.map((option) => (
<option key={option} value={option}>
{option === "ALL" ? "Todos" : option.toLowerCase()}
</option>
))}
</select>
</div>
);
};
const Styles = {
wrapper: "flex flex-wrap gap-4 rounded-lg border border-cardBorder bg-white/70 p-4 shadow-sm",
searchGroup: "flex-1 min-w-[220px]",
selectGroup: "flex min-w-[150px] flex-col gap-1",
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",
input: "w-full rounded-lg border border-cardBorder bg-white px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent",
select: "w-full rounded-lg border border-cardBorder bg-white px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent capitalize",
};

View File

@ -1,58 +1,143 @@
import { useState } from "react";
import type { Server } from "../types/Server"; import type { Server } from "../types/Server";
interface Props { interface Props {
servers: Server[]; servers: Server[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
} }
export const ServersTable = ({ servers, loading, error }: Props) => { const PAGE_SIZE_OPTIONS = [5, 10, 20, 50];
export const ServersTable = ({
servers,
loading,
error,
page,
pageSize,
totalPages,
totalItems,
onPageChange,
onPageSizeChange,
}: Props) => {
const [hideSensitive, setHideSensitive] = useState(false);
const showingFrom = totalItems === 0 ? 0 : page * pageSize + 1;
const showingTo = totalItems === 0 ? 0 : Math.min((page + 1) * pageSize, totalItems);
const hasResults = servers.length > 0;
const formatSensitiveValue = (value: string | number) => (hideSensitive ? "••••" : value);
return ( return (
<div className={Styles.card}> <div className={Styles.card}>
<button
type="button"
className={Styles.hideButton}
onClick={() => setHideSensitive((prev) => !prev)}
disabled={loading || (!hasResults && !error)}
aria-pressed={hideSensitive}
>
{hideSensitive ? "Mostrar dados sensíveis" : "Ocultar dados sensíveis"}
</button>
{loading && <div className={Styles.status}>Carregando servidores...</div>} {loading && <div className={Styles.status}>Carregando servidores...</div>}
{error && <div className={Styles.error}>{error}</div>} {error && <div className={Styles.error}>{error}</div>}
{!loading && !error && servers.length === 0 && ( {!loading && !error && servers.length === 0 && (
<div className={Styles.status}>Nenhum servidor encontrado.</div> <div className={Styles.status}>Nenhum servidor encontrado.</div>
)} )}
{!loading && !error && servers.length > 0 && ( {!loading && !error && hasResults && (
<div className={Styles.tableWrapper}> <>
<table className={Styles.table}> <div className={Styles.tableWrapper}>
<thead className={Styles.tableHead}> <table className={Styles.table}>
<tr className="text-left"> <thead className={Styles.tableHead}>
<th className={Styles.tableHeadCell}>Nome</th> <tr className="text-left">
<th className={Styles.tableHeadCell}>IP</th> <th className={Styles.tableHeadCell}>Nome</th>
<th className={Styles.tableHeadCell}>Porta</th> <th className={Styles.tableHeadCell}>IP</th>
<th className={Styles.tableHeadCell}>Usuário</th> <th className={Styles.tableHeadCell}>Porta</th>
<th className={Styles.tableHeadCell}>Tipo</th> <th className={Styles.tableHeadCell}>Usuário</th>
<th className={Styles.tableHeadCell}>Aplicação</th> <th className={Styles.tableHeadCell}>Senha</th>
<th className={Styles.tableHeadCell}>Banco</th> <th className={Styles.tableHeadCell}>Tipo</th>
</tr> <th className={Styles.tableHeadCell}>Aplicação</th>
</thead> <th className={Styles.tableHeadCell}>Banco</th>
<tbody className={Styles.tableBody}> </tr>
{servers.map((server) => ( </thead>
<tr <tbody className={Styles.tableBody}>
key={server.id} {servers.map((server) => (
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50" <tr
> key={server.id}
className="hover:bg-gray-50 transition-colors even:bg-gray-50/50"
>
<td className={Styles.rowCell}>{server.name}</td> <td className={Styles.rowCell}>{server.name}</td>
<td className={Styles.rowCell}>{server.ip}</td> <td className={Styles.rowCell}>{formatSensitiveValue(server.ip)}</td>
<td className={Styles.rowCell}>{server.port}</td> <td className={Styles.rowCell}>{formatSensitiveValue(server.port)}</td>
<td className={Styles.rowCell}>{server.user}</td> <td className={Styles.rowCell}>{formatSensitiveValue(server.user)}</td>
<td className={Styles.rowCell}>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{hideSensitive ? "••••" : server.password}
</code>
</td>
<td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td> <td className={`${Styles.rowCell} capitalize`}>{server.type.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td> <td className={`${Styles.rowCell} capitalize`}>{server.application.toLowerCase()}</td>
<td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td> <td className={`${Styles.rowCell} capitalize`}>{server.dbType.toLowerCase()}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className={Styles.pagination}>
<div className={Styles.pageInfo}>
Mostrando {showingFrom} - {showingTo} de {totalItems}
</div>
<div className={Styles.paginationControls}>
<label className={Styles.pageSizeLabel}>
Linhas por página
<select
className={Styles.pageSizeSelect}
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className={Styles.pageButtons}>
<button
type="button"
className={Styles.pageButton}
onClick={() => onPageChange(page - 1)}
disabled={page <= 0}
>
Anterior
</button>
<span className={Styles.pageIndicator}>
Página {page + 1} de {Math.max(totalPages, 1)}
</span>
<button
type="button"
className={Styles.pageButton}
onClick={() => onPageChange(page + 1)}
disabled={totalPages === 0 || page >= totalPages - 1}
>
Próxima
</button>
</div>
</div>
</div>
</>
)} )}
</div> </div>
); );
}; };
const Styles = { const Styles = {
card: "bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden", card: "relative bg-card border border-cardBorder shadow-sm rounded-lg overflow-hidden",
status: "p-4 text-text-secondary text-sm", status: "p-4 text-text-secondary text-sm",
error: "p-4 text-red-600 text-sm", error: "p-4 text-red-600 text-sm",
tableWrapper: "overflow-x-auto rounded-lg shadow-sm border border-cardBorder", tableWrapper: "overflow-x-auto rounded-lg shadow-sm border border-cardBorder",
@ -61,4 +146,13 @@ const Styles = {
tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary", tableHeadCell: "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-secondary",
tableBody: "bg-white divide-y divide-cardBorder", tableBody: "bg-white divide-y divide-cardBorder",
rowCell: "px-4 py-3 text-sm text-text", rowCell: "px-4 py-3 text-sm text-text",
pagination: "flex flex-col gap-2 border-t border-cardBorder bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between",
pageInfo: "text-sm text-text-secondary",
paginationControls: "flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4",
pageSizeLabel: "text-sm text-text flex items-center gap-2",
pageSizeSelect: "rounded-md border border-cardBorder bg-white px-2 py-1 text-sm text-text outline-none focus:border-accent focus:ring-1 focus:ring-accent",
pageButtons: "flex items-center gap-3",
pageButton: "rounded-md border border-cardBorder px-3 py-1.5 text-sm font-medium text-text hover:bg-bg disabled:opacity-50 disabled:hover:bg-transparent",
pageIndicator: "text-sm text-text-secondary",
hideButton: "absolute right-3 top-3 rounded-md border border-cardBorder px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary hover:bg-bg disabled:opacity-50 disabled:hover:bg-transparent bg-white shadow-sm",
}; };

View File

@ -0,0 +1,74 @@
import { useEffect, useId, type ReactNode } from "react";
interface ModalProps {
isOpen: boolean;
title: string;
onClose: () => void;
children: ReactNode;
description?: ReactNode;
bodyClassName?: string;
}
export const Modal = ({ isOpen, title, onClose, children, description, bodyClassName }: ModalProps) => {
const instanceId = useId();
const titleId = `${instanceId}-title`;
const descriptionId = description ? `${instanceId}-description` : undefined;
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
const bodyClasses = [Styles.body, bodyClassName].filter(Boolean).join(" ");
return (
<div className={Styles.overlay} role="presentation">
<div
className={Styles.dialog}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
<div className={Styles.header}>
<h2 id={titleId} className={Styles.title}>{title}</h2>
<button type="button" aria-label="Fechar modal" className={Styles.closeButton} onClick={onClose}>
×
</button>
</div>
{description && (
<div id={descriptionId} className={Styles.description}>
{description}
</div>
)}
<div className={bodyClasses}>{children}</div>
</div>
</div>
);
};
const Styles = {
overlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0",
dialog: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
header: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
title: "text-lg font-semibold text-text",
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
description: "pt-3 text-sm text-text-secondary",
body: "pt-4",
};

View File

@ -1,5 +1,6 @@
import type { ChangeEvent } from "react"; import type { ChangeEvent } from "react";
import type { BulkImportResult } from "../../types/BulkImport"; import type { BulkImportResult } from "../../types/BulkImport";
import { Modal } from "../common/Modal";
interface BulkImportModalProps { interface BulkImportModalProps {
isOpen: boolean; isOpen: boolean;
@ -24,91 +25,74 @@ export const BulkImportModal = ({
onSubmit, onSubmit,
onDownloadTemplate, onDownloadTemplate,
}: BulkImportModalProps) => { }: BulkImportModalProps) => {
if (!isOpen) return null;
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0]; const selected = event.target.files?.[0];
onFileChange(selected ?? null); onFileChange(selected ?? null);
}; };
return ( return (
<div className={Styles.modalOverlay} role="dialog" aria-modal="true"> <Modal isOpen={isOpen} title="Cadastro em massa" onClose={onClose} bodyClassName={Styles.modalBody}>
<div className={Styles.modal}> <div className={Styles.uploadCard}>
<div className={Styles.modalHeader}> <label htmlFor="bulk-file" className={Styles.dropLabel}>
<h2 className={Styles.modalTitle}>Cadastro em massa</h2> <span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal"> <span className="text-xs text-text-secondary">
× Arraste e solte ou clique para procurar
</span>
</label>
<input
id="bulk-file"
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleInputChange}
/>
<div className={Styles.dropzone} onClick={() => document.getElementById("bulk-file")?.click()}>
{file ? (
<>
<p className="text-sm font-medium text-text">{file.name}</p>
<p className="text-xs text-text-secondary">{(file.size / 1024).toFixed(1)} KB</p>
</>
) : (
<p className="text-sm text-text-secondary">Nenhum arquivo selecionado.</p>
)}
</div>
<p className={Styles.helperText}>
Estrutura esperada: <code>name;ip;port;user;password;type;application;dbType</code>
</p>
<div className={Styles.actionsRow}>
<button type="button" className={Styles.secondaryButton} onClick={onDownloadTemplate}>
Baixar CSV de exemplo
</button>
<button type="button" className={Styles.primaryButton} disabled={!file || loading} onClick={onSubmit}>
{loading ? "Importando..." : "Importar arquivo"}
</button> </button>
</div> </div>
<div className={Styles.modalBody}> </div>
<div className={Styles.uploadCard}>
<label htmlFor="bulk-file" className={Styles.dropLabel}> {error && <p className={Styles.errorText}>{error}</p>}
<span className="text-base font-medium text-text">Selecionar arquivo CSV</span>
<span className="text-xs text-text-secondary"> {result && (
Arraste e solte ou clique para procurar <div className={Styles.resultCard}>
</span> <div className={Styles.statsGrid}>
</label> <Stat label="Processados" value={result.total} />
<input <Stat label="Sucesso" value={result.succeeded} accent />
id="bulk-file" <Stat label="Falhas" value={result.failed} danger />
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleInputChange}
/>
<div className={Styles.dropzone} onClick={() => document.getElementById("bulk-file")?.click()}>
{file ? (
<>
<p className="text-sm font-medium text-text">{file.name}</p>
<p className="text-xs text-text-secondary">{(file.size / 1024).toFixed(1)} KB</p>
</>
) : (
<p className="text-sm text-text-secondary">Nenhum arquivo selecionado.</p>
)}
</div>
<p className={Styles.helperText}>
Estrutura esperada: <code>name;ip;port;user;password;type;application;dbType</code>
</p>
<div className={Styles.actionsRow}>
<button type="button" className={Styles.secondaryButton} onClick={onDownloadTemplate}>
Baixar CSV de exemplo
</button>
<button
type="button"
className={Styles.primaryButton}
disabled={!file || loading}
onClick={onSubmit}
>
{loading ? "Importando..." : "Importar arquivo"}
</button>
</div>
</div> </div>
{result.failed > 0 && (
{error && <p className={Styles.errorText}>{error}</p>} <div className="space-y-2">
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
{result && ( <ul className={Styles.failureList}>
<div className={Styles.resultCard}> {result.failures.map((failure) => (
<div className="flex flex-wrap gap-4"> <li key={failure.line}>
<Stat label="Processados" value={result.total} /> <span className="font-semibold">Linha {failure.line}</span>: {failure.error}
<Stat label="Sucesso" value={result.succeeded} accent /> </li>
<Stat label="Falhas" value={result.failed} danger /> ))}
</div> </ul>
{result.failed > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-text">Detalhes das falhas:</p>
<ul className={Styles.failureList}>
{result.failures.map((failure) => (
<li key={failure.line}>
<span className="font-semibold">Linha {failure.line}</span>: {failure.error}
</li>
))}
</ul>
</div>
)}
</div> </div>
)} )}
</div> </div>
</div> )}
</div> </Modal>
); );
}; };
@ -123,23 +107,14 @@ const Stat = ({
accent?: boolean; accent?: boolean;
danger?: boolean; danger?: boolean;
}) => ( }) => (
<div <div className={`${Styles.statBase} ${accent ? Styles.statAccent : danger ? Styles.statDanger : Styles.statDefault}`}>
className={`flex flex-col rounded-lg border px-4 py-3 text-sm ${
accent ? "border-accent/40 bg-accent/10 text-accent" : danger ? "border-red-200 bg-red-50 text-red-600" : "border-cardBorder bg-white text-text"
}`}
>
<span className="text-xs uppercase tracking-wide opacity-70">{label}</span> <span className="text-xs uppercase tracking-wide opacity-70">{label}</span>
<span className="text-2xl font-bold leading-tight">{value}</span> <span className="text-2xl font-bold leading-tight">{value}</span>
</div> </div>
); );
const Styles = { const Styles = {
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0", modalBody: "space-y-5",
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up",
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
modalTitle: "text-lg font-semibold text-text",
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
modalBody: "space-y-5 pt-4",
uploadCard: "rounded-xl border border-dashed border-cardBorder bg-white/70 p-6 shadow-inner space-y-4", uploadCard: "rounded-xl border border-dashed border-cardBorder bg-white/70 p-6 shadow-inner space-y-4",
dropLabel: "flex flex-col gap-1 text-center", dropLabel: "flex flex-col gap-1 text-center",
dropzone: "flex flex-col items-center justify-center rounded-lg border border-cardBorder bg-bg px-4 py-6 text-center cursor-pointer hover:border-accent hover:bg-white transition-colors", dropzone: "flex flex-col items-center justify-center rounded-lg border border-cardBorder bg-bg px-4 py-6 text-center cursor-pointer hover:border-accent hover:bg-white transition-colors",
@ -149,5 +124,10 @@ const Styles = {
primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70", primaryButton: "rounded-md bg-accent px-4 py-2 text-sm font-semibold text-white hover:bg-hover disabled:opacity-70",
errorText: "text-sm text-red-600", errorText: "text-sm text-red-600",
resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4", resultCard: "rounded-xl border border-cardBorder bg-white/90 p-5 space-y-4",
statsGrid: "flex flex-wrap gap-4",
failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto", failureList: "list-disc pl-5 space-y-1 text-sm text-text-secondary max-h-40 overflow-auto",
statBase: "flex flex-col rounded-lg border px-4 py-3 text-sm",
statDefault: "border-cardBorder bg-white text-text",
statAccent: "border-accent/40 bg-accent/10 text-accent",
statDanger: "border-red-200 bg-red-50 text-red-600",
}; };

View File

@ -1,6 +1,7 @@
import type { ChangeEvent, FormEvent } from "react"; import type { ChangeEvent, FormEvent } from "react";
import type { User } from "../../types/User"; import type { User } from "../../types/User";
import type { ProfileFormState } from "./types"; import type { ProfileFormState } from "./types";
import { Modal } from "../common/Modal";
interface ProfileModalProps { interface ProfileModalProps {
isOpen: boolean; isOpen: boolean;
@ -23,63 +24,50 @@ export const ProfileModal = ({
onChange, onChange,
onSubmit, onSubmit,
}: ProfileModalProps) => { }: ProfileModalProps) => {
if (!isOpen) return null;
const isDisabled = !currentUser; const isDisabled = !currentUser;
return ( return (
<div className={Styles.modalOverlay} role="dialog" aria-modal="true"> <Modal isOpen={isOpen} title="Editar perfil" onClose={onClose} bodyClassName={Styles.modalBody}>
<div className={Styles.modal}> {userError ? (
<div className={Styles.modalHeader}> <p className={Styles.errorMessage}>{userError}</p>
<h2 className={Styles.modalTitle}>Editar perfil</h2> ) : (
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal"> <form onSubmit={onSubmit} className={Styles.form}>
× <div className={Styles.formGrid}>
</button> <div className={Styles.field}>
</div> <label htmlFor="firstName" className={Styles.label}>Nome</label>
{userError ? ( <input id="firstName" name="firstName" className={Styles.input} value={form.firstName} onChange={onChange} required disabled={isDisabled} />
<p className={Styles.helperText}>{userError}</p>
) : (
<form onSubmit={onSubmit} className={Styles.form}>
<div className={Styles.formGrid}>
<div className={Styles.field}>
<label htmlFor="firstName" className={Styles.label}>Nome</label>
<input id="firstName" name="firstName" className={Styles.input} value={form.firstName} onChange={onChange} required disabled={isDisabled} />
</div>
<div className={Styles.field}>
<label htmlFor="lastName" className={Styles.label}>Sobrenome</label>
<input id="lastName" name="lastName" className={Styles.input} value={form.lastName} onChange={onChange} required disabled={isDisabled} />
</div>
</div> </div>
<div className={Styles.field}> <div className={Styles.field}>
<label htmlFor="email" className={Styles.label}>Email</label> <label htmlFor="lastName" className={Styles.label}>Sobrenome</label>
<input id="email" name="email" type="email" className={Styles.input} value={form.email} onChange={onChange} required disabled={isDisabled} /> <input id="lastName" name="lastName" className={Styles.input} value={form.lastName} onChange={onChange} required disabled={isDisabled} />
</div> </div>
<div className={Styles.field}> </div>
<label htmlFor="password" className={Styles.label}>Nova senha</label> <div className={Styles.field}>
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} placeholder="Informe uma nova senha" required disabled={isDisabled} /> <label htmlFor="email" className={Styles.label}>Email</label>
<p className={Styles.helperText}>Informe uma nova senha para confirmar a alteração.</p> <input id="email" name="email" type="email" className={Styles.input} value={form.email} onChange={onChange} required disabled={isDisabled} />
</div> </div>
<div className={Styles.modalActions}> <div className={Styles.field}>
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button> <label htmlFor="password" className={Styles.label}>Nova senha</label>
<button type="submit" className={Styles.primaryButton} disabled={loading || isDisabled}> <input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} placeholder="Informe uma nova senha" required disabled={isDisabled} />
{loading ? "Salvando..." : "Salvar alterações"} <p className={Styles.helperText}>Informe uma nova senha para confirmar a alteração.</p>
</button> </div>
</div> <div className={Styles.modalActions}>
</form> <button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
)} <button type="submit" className={Styles.primaryButton} disabled={loading || isDisabled}>
</div> {loading ? "Salvando..." : "Salvar alterações"}
</div> </button>
</div>
</form>
)}
</Modal>
); );
}; };
const Styles = { const Styles = {
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0", modalBody: "space-y-4",
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up", helperText: "text-sm text-text-secondary",
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder", errorMessage: "text-sm text-red-600",
modalTitle: "text-lg font-semibold text-text", form: "space-y-4",
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
helperText: "pt-4 text-sm text-text-secondary",
form: "pt-4 space-y-4",
formGrid: "grid gap-4 md:grid-cols-2", formGrid: "grid gap-4 md:grid-cols-2",
field: "flex flex-col gap-2", field: "flex flex-col gap-2",
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary", label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",

View File

@ -1,6 +1,7 @@
import type { ChangeEvent, FormEvent } from "react"; import type { ChangeEvent, FormEvent } from "react";
import type { Applications, DatabaseType, ServersType } from "../../types/enums"; import type { Applications, DatabaseType, ServersType } from "../../types/enums";
import type { ServerFormState } from "./types"; import type { ServerFormState } from "./types";
import { Modal } from "../common/Modal";
interface ServerModalProps { interface ServerModalProps {
isOpen: boolean; isOpen: boolean;
@ -25,87 +26,73 @@ export const ServerModal = ({
applicationOptions, applicationOptions,
databaseOptions, databaseOptions,
}: ServerModalProps) => { }: ServerModalProps) => {
if (!isOpen) return null;
return ( return (
<div className={Styles.modalOverlay} role="dialog" aria-modal="true"> <Modal isOpen={isOpen} title="Adicionar novo servidor" onClose={onClose} bodyClassName={Styles.modalBody}>
<div className={Styles.modal}> <form onSubmit={onSubmit} className={Styles.form}>
<div className={Styles.modalHeader}> <div className={Styles.formGrid}>
<h2 className={Styles.modalTitle}>Adicionar novo servidor</h2> <div className={Styles.field}>
<button type="button" onClick={onClose} className={Styles.closeButton} aria-label="Fechar modal"> <label htmlFor="name" className={Styles.label}>Nome</label>
× <input id="name" name="name" className={Styles.input} value={form.name} onChange={onChange} required />
</button> </div>
<div className={Styles.field}>
<label htmlFor="ip" className={Styles.label}>IP</label>
<input id="ip" name="ip" className={Styles.input} value={form.ip} onChange={onChange} placeholder="192.168.0.10" required />
</div>
</div> </div>
<form onSubmit={onSubmit} className={Styles.form}> <div className={Styles.formGrid}>
<div className={Styles.formGrid}> <div className={Styles.field}>
<div className={Styles.field}> <label htmlFor="port" className={Styles.label}>Porta</label>
<label htmlFor="name" className={Styles.label}>Nome</label> <input id="port" name="port" type="number" min="1" className={Styles.input} value={form.port} onChange={onChange} required />
<input id="name" name="name" className={Styles.input} value={form.name} onChange={onChange} required />
</div>
<div className={Styles.field}>
<label htmlFor="ip" className={Styles.label}>IP</label>
<input id="ip" name="ip" className={Styles.input} value={form.ip} onChange={onChange} placeholder="192.168.0.10" required />
</div>
</div>
<div className={Styles.formGrid}>
<div className={Styles.field}>
<label htmlFor="port" className={Styles.label}>Porta</label>
<input id="port" name="port" type="number" min="1" className={Styles.input} value={form.port} onChange={onChange} required />
</div>
<div className={Styles.field}>
<label htmlFor="user" className={Styles.label}>Usuário</label>
<input id="user" name="user" className={Styles.input} value={form.user} onChange={onChange} required />
</div>
</div> </div>
<div className={Styles.field}> <div className={Styles.field}>
<label htmlFor="password" className={Styles.label}>Senha</label> <label htmlFor="user" className={Styles.label}>Usuário</label>
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} required /> <input id="user" name="user" className={Styles.input} value={form.user} onChange={onChange} required />
</div>
<div className={Styles.formGrid}>
<div className={Styles.field}>
<label htmlFor="type" className={Styles.label}>Tipo</label>
<select id="type" name="type" className={Styles.select} value={form.type} onChange={onChange}>
{serverTypeOptions.map((option) => (
<option key={option} value={option}>{option.toLowerCase()}</option>
))}
</select>
</div>
<div className={Styles.field}>
<label htmlFor="application" className={Styles.label}>Aplicação</label>
<select id="application" name="application" className={Styles.select} value={form.application} onChange={onChange}>
{applicationOptions.map((option) => (
<option key={option} value={option}>{option.toLowerCase()}</option>
))}
</select>
</div>
</div> </div>
</div>
<div className={Styles.field}>
<label htmlFor="password" className={Styles.label}>Senha</label>
<input id="password" name="password" type="password" className={Styles.input} value={form.password} onChange={onChange} required />
</div>
<div className={Styles.formGrid}>
<div className={Styles.field}> <div className={Styles.field}>
<label htmlFor="dbType" className={Styles.label}>Banco de dados</label> <label htmlFor="type" className={Styles.label}>Tipo</label>
<select id="dbType" name="dbType" className={Styles.select} value={form.dbType} onChange={onChange}> <select id="type" name="type" className={Styles.select} value={form.type} onChange={onChange}>
{databaseOptions.map((option) => ( {serverTypeOptions.map((option) => (
<option key={option} value={option}>{option.toLowerCase()}</option> <option key={option} value={option}>{option.toLowerCase()}</option>
))} ))}
</select> </select>
</div> </div>
<div className={Styles.modalActions}> <div className={Styles.field}>
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button> <label htmlFor="application" className={Styles.label}>Aplicação</label>
<button type="submit" className={Styles.primaryButton} disabled={loading}> <select id="application" name="application" className={Styles.select} value={form.application} onChange={onChange}>
{loading ? "Salvando..." : "Salvar servidor"} {applicationOptions.map((option) => (
</button> <option key={option} value={option}>{option.toLowerCase()}</option>
))}
</select>
</div> </div>
</form> </div>
</div> <div className={Styles.field}>
</div> <label htmlFor="dbType" className={Styles.label}>Banco de dados</label>
<select id="dbType" name="dbType" className={Styles.select} value={form.dbType} onChange={onChange}>
{databaseOptions.map((option) => (
<option key={option} value={option}>{option.toLowerCase()}</option>
))}
</select>
</div>
<div className={Styles.modalActions}>
<button type="button" className={Styles.secondaryButton} onClick={onClose}>Cancelar</button>
<button type="submit" className={Styles.primaryButton} disabled={loading}>
{loading ? "Salvando..." : "Salvar servidor"}
</button>
</div>
</form>
</Modal>
); );
}; };
const Styles = { const Styles = {
modalOverlay: "fixed inset-0 z-40 flex items-center justify-center bg-black/40 backdrop-blur-sm px-4 !mt-0", modalBody: "space-y-4",
modal: "w-full max-w-2xl rounded-2xl border border-cardBorder bg-card p-6 shadow-xl transform transition-all duration-200 animate-fade-up", form: "space-y-4",
modalHeader: "flex items-start justify-between gap-4 pb-4 border-b border-cardBorder",
modalTitle: "text-lg font-semibold text-text",
closeButton: "text-2xl leading-none text-text-secondary hover:text-text",
form: "pt-4 space-y-4",
formGrid: "grid gap-4 md:grid-cols-2", formGrid: "grid gap-4 md:grid-cols-2",
field: "flex flex-col gap-2", field: "flex flex-col gap-2",
label: "text-xs font-semibold uppercase tracking-wide text-text-secondary", label: "text-xs font-semibold uppercase tracking-wide text-text-secondary",

View File

@ -1,14 +1,12 @@
import type { Applications, DatabaseType, ServersType } from "../../types/enums";
export type ServerFormState = { export type ServerFormState = {
name: string; name: string;
ip: string; ip: string;
port: string; port: string;
user: string; user: string;
password: string; password: string;
type: ServersType; type: string;
application: Applications; application: string;
dbType: DatabaseType; dbType: string;
}; };
export type ProfileFormState = { export type ProfileFormState = {

View File

@ -1,25 +1,65 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api, { getCurrentUsername } from "../Api"; import api, { getCurrentUsername } from "../Api";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
import { ServerCardMetrics } from "../components/ServerCardMetrics"; import { ServerCardMetrics } from "../components/ServerCardMetrics";
import { ServersTable } from "../components/ServersTable"; import { ServersTable } from "../components/ServersTable";
import { ServersFilterBar } from "../components/ServersFilterBar";
import type { Server } from "../types/Server"; import type { Server } from "../types/Server";
import type { User } from "../types/User"; import type { User } from "../types/User";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import type { PaginatedResponse } from "../types/Pagination";
export const Dashboard = () => { export const Dashboard = () => {
const navigate = useNavigate();
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [userError, setUserError] = useState<string | null>(null); const [userError, setUserError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [applicationFilter, setApplicationFilter] = useState<string>("ALL");
const [dbFilter, setDbFilter] = useState<string>("ALL");
const [page, setPage] = useState<number>(0);
const [pageSize, setPageSize] = useState<number>(10);
const [totalPages, setTotalPages] = useState<number>(0);
const [totalItems, setTotalItems] = useState<number>(0);
const [serverTypeOptions, setServerTypeOptions] = useState<string[]>([]);
const [applicationOptions, setApplicationOptions] = useState<string[]>([]);
const [databaseOptions, setDatabaseOptions] = useState<string[]>([]);
const fetchServers = useCallback(async () => { const fetchServers = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const { data } = await api.get<Server[]>("/api/servers"); const params = new URLSearchParams();
setServers(data); const trimmedQuery = searchTerm.trim();
if (trimmedQuery.length > 0) {
params.set("query", trimmedQuery);
}
if (typeFilter !== "ALL" && typeFilter.trim().length > 0) {
params.set("type", typeFilter);
}
if (applicationFilter !== "ALL" && applicationFilter.trim().length > 0) {
params.set("application", applicationFilter);
}
if (dbFilter !== "ALL" && dbFilter.trim().length > 0) {
params.set("dbType", dbFilter);
}
params.set("page", String(page));
params.set("size", String(pageSize));
const endpoint = params.toString() ? `/api/servers?${params.toString()}` : "/api/servers";
const { data } = await api.get<PaginatedResponse<Server>>(endpoint);
setServers(data.content ?? []);
setTotalPages(data.totalPages ?? 0);
setTotalItems(data.totalItems ?? 0);
if (typeof data.page === "number") {
setPage(data.page);
}
if (typeof data.size === "number") {
setPageSize(data.size);
}
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
const message = err?.response?.data?.message || "Falha ao carregar servidores."; const message = err?.response?.data?.message || "Falha ao carregar servidores.";
@ -27,13 +67,16 @@ export const Dashboard = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [searchTerm, typeFilter, applicationFilter, dbFilter, page, pageSize]);
const fetchCurrentUser = useCallback(async () => { const fetchCurrentUser = useCallback(async () => {
try { try {
const username = getCurrentUsername(); const username = getCurrentUsername();
if (!username) { if (!username) {
setUserError("Não foi possível identificar o usuário logado."); const message = "Não foi possível identificar o usuário logado.";
setUserError(message);
toast.error(message);
navigate("/login", { replace: true });
return; return;
} }
const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`); const { data } = await api.get<User>(`/api/users/username/${encodeURIComponent(username)}`);
@ -44,12 +87,72 @@ export const Dashboard = () => {
setUserError(message); setUserError(message);
toast.error(message); toast.error(message);
} }
}, [navigate]);
useEffect(() => {
fetchCurrentUser();
}, [fetchCurrentUser]);
const fetchTypeOptions = useCallback(async () => {
try {
const [typesResponse, applicationsResponse, databasesResponse] = await Promise.all([
api.get<string[]>("/api/type-options/SERVER_TYPE"),
api.get<string[]>("/api/type-options/APPLICATION"),
api.get<string[]>("/api/type-options/DATABASE"),
]);
setServerTypeOptions(typesResponse.data ?? []);
setApplicationOptions(applicationsResponse.data ?? []);
setDatabaseOptions(databasesResponse.data ?? []);
} catch (err: any) {
const message = err?.response?.data?.message || "Falha ao carregar opções padrão.";
toast.error(message);
setServerTypeOptions([]);
setApplicationOptions([]);
setDatabaseOptions([]);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchServers(); fetchServers();
fetchCurrentUser(); }, [fetchServers]);
}, [fetchServers, fetchCurrentUser]);
const handleSearchChange = (value: string) => {
setPage(0);
setSearchTerm(value);
};
const handleTypeChange = (value: string) => {
setPage(0);
setTypeFilter(value);
};
const handleApplicationChange = (value: string) => {
setPage(0);
setApplicationFilter(value);
};
const handleDbTypeChange = (value: string) => {
setPage(0);
setDbFilter(value);
};
const handlePageChange = (nextPage: number) => {
const maxPage = totalPages > 0 ? totalPages - 1 : 0;
const safePage = Math.max(0, Math.min(nextPage, maxPage));
setPage(safePage);
};
const handlePageSizeChange = (nextSize: number) => {
if (nextSize <= 0) {
return;
}
setPage(0);
setPageSize(nextSize);
};
useEffect(() => {
fetchTypeOptions();
}, [fetchTypeOptions]);
return ( return (
<Layout className="h-screen py-10"> <Layout className="h-screen py-10">
@ -59,9 +162,35 @@ export const Dashboard = () => {
userError={userError} userError={userError}
onServerCreated={fetchServers} onServerCreated={fetchServers}
onProfileUpdated={setCurrentUser} onProfileUpdated={setCurrentUser}
serverTypeOptions={serverTypeOptions}
applicationOptions={applicationOptions}
databaseOptions={databaseOptions}
/> />
<ServerCardMetrics /> <ServerCardMetrics />
<ServersTable servers={servers} loading={loading} error={error} /> <ServersFilterBar
search={searchTerm}
type={typeFilter}
application={applicationFilter}
dbType={dbFilter}
onSearchChange={handleSearchChange}
onTypeChange={handleTypeChange}
onApplicationChange={handleApplicationChange}
onDbTypeChange={handleDbTypeChange}
serverTypeOptions={serverTypeOptions}
applicationOptions={applicationOptions}
databaseOptions={databaseOptions}
/>
<ServersTable
servers={servers}
loading={loading}
error={error}
page={page}
pageSize={pageSize}
totalPages={totalPages}
totalItems={totalItems}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</div> </div>
</Layout> </Layout>
); );

View File

@ -1,16 +1,45 @@
import type { ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { getAccessToken } from "../Api"; import { validateSession } from "../Api";
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: ReactNode; children: ReactNode;
} }
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const token = getAccessToken();
const location = useLocation(); const location = useLocation();
const [checkingAuth, setCheckingAuth] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
if (!token) { useEffect(() => {
let active = true;
const verify = async () => {
try {
const valid = await validateSession();
if (active) {
setIsAuthenticated(valid);
}
} finally {
if (active) {
setCheckingAuth(false);
}
}
};
void verify();
return () => {
active = false;
};
}, []);
if (checkingAuth) {
return (
<div className="flex h-screen items-center justify-center text-text">
Validando sessão...
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />; return <Navigate to="/login" state={{ from: location }} replace />;
} }

View File

@ -0,0 +1,7 @@
export interface PaginatedResponse<T> {
content: T[];
totalItems: number;
totalPages: number;
page: number;
size: number;
}

View File

@ -6,6 +6,7 @@ export interface Server {
ip: string; ip: string;
port: number; port: number;
user: string; user: string;
password: string;
type: ServersType; type: ServersType;
application: Applications; application: Applications;
dbType: DatabaseType; dbType: DatabaseType;

View File

@ -1,24 +1,9 @@
export type DatabaseType = export type ServersType = string;
| 'MYSQL' export type Applications = string;
| 'POSTGRESQL' export type DatabaseType = string;
| 'SQLSERVER'
| 'ORACLE'
| 'REDIS'
| 'MONGODB'
| 'MARIADB'
| 'NONE';
export type Applications = export const ServerTypeLabels: Record<string, string> = {
| 'ASTERISK' PRODUCTION: "Produção",
| 'HITMANAGER' HOMOLOGATION: "Homologação",
| 'HITMANAGER_V2' DATABASE: "Banco de Dados",
| 'OMNIHIT'
| 'HITPHONE';
export type ServersType = 'PRODUCTION' | 'HOMOLOGATION' | 'DATABASE';
export const ServerTypeLabels: Record<ServersType, string> = {
PRODUCTION: "Produção",
HOMOLOGATION: "Homologação",
DATABASE: "Banco de Dados",
}; };