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 backendmaster
parent
d08e42732f
commit
f9b62dcc4e
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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", "/api/auth/logout").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())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -25,6 +30,7 @@ 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";
|
||||||
|
|
@ -33,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);
|
||||||
|
|
@ -46,12 +62,24 @@ 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")
|
@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) {
|
public ResponseEntity<Void> logout(HttpServletRequest request) {
|
||||||
boolean secure = request.isSecure();
|
boolean secure = request.isSecure();
|
||||||
ResponseCookie expiredAccessCookie = CookieUtils.buildCookie(
|
ResponseCookie expiredAccessCookie = CookieUtils.buildCookie(
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,23 @@ import com.hitcommunications.servermanager.model.enums.Applications;
|
||||||
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
import com.hitcommunications.servermanager.model.enums.DatabaseType;
|
||||||
import com.hitcommunications.servermanager.model.enums.ServersType;
|
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;
|
||||||
|
|
@ -19,46 +32,77 @@ 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}")
|
||||||
|
@Operation(summary = "Lista servidores por tipo.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Lista retornada.")
|
||||||
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable ServersType type) {
|
public ResponseEntity<List<ServerDTO>> getByType(@PathVariable ServersType type) {
|
||||||
return ResponseEntity.ok().body(serversService.getByType(type));
|
return ResponseEntity.ok().body(serversService.getByType(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/type")
|
@GetMapping("/type")
|
||||||
|
@Operation(summary = "Conta servidores agrupando por tipo.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Mapa retornado.")
|
||||||
public ResponseEntity<Map<ServersType, Integer>> countAllByType() {
|
public ResponseEntity<Map<ServersType, 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}")
|
||||||
|
@Operation(summary = "Lista servidores por aplicação.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Lista retornada.")
|
||||||
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
public ResponseEntity<List<ServerDTO>> getByApplication(@PathVariable Applications application) {
|
||||||
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
return ResponseEntity.ok().body(serversService.getByApplication(application));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@Operation(summary = "Pesquisa servidores com filtros combinados.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Resultados retornados.")
|
||||||
public ResponseEntity<List<ServerDTO>> getAll(
|
public ResponseEntity<List<ServerDTO>> getAll(
|
||||||
@RequestParam(value = "query", required = false) String query,
|
@RequestParam(value = "query", required = false) String query,
|
||||||
@RequestParam(value = "type", required = false) ServersType type,
|
@RequestParam(value = "type", required = false) ServersType type,
|
||||||
|
|
@ -69,11 +113,21 @@ public class ServersController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,7 @@ public class ServersService {
|
||||||
private final ServersRepository repo;
|
private final ServersRepository repo;
|
||||||
|
|
||||||
public ServerDTO create(NewServerDTO createDTO) {
|
public ServerDTO create(NewServerDTO createDTO) {
|
||||||
// Check if server with same IP and port already exists
|
ensureUniqueIpAndPort(createDTO.ip(), createDTO.port(), null);
|
||||||
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);
|
||||||
entity = repo.save(entity);
|
entity = repo.save(entity);
|
||||||
|
|
@ -169,12 +166,7 @@ public class ServersService {
|
||||||
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)
|
ensureUniqueIpAndPort(updateDTO.ip(), updateDTO.port(), id);
|
||||||
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);
|
||||||
entity = repo.save(entity);
|
entity = repo.save(entity);
|
||||||
|
|
@ -187,4 +179,12 @@ public class ServersService {
|
||||||
}
|
}
|
||||||
repo.deleteById(id);
|
repo.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureUniqueIpAndPort(String ip, Integer port, String currentServerId) {
|
||||||
|
repo.findByIpAndPort(ip, port).ifPresent(existingServer -> {
|
||||||
|
if (currentServerId == null || !existingServer.getId().equals(currentServerId)) {
|
||||||
|
throw new RuntimeException("Server already exists with IP: " + ip + " and port: " + port);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue