Initial Commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
backend/target/
|
||||
backend/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -0,0 +1,17 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Klaro Ticketdesk
|
||||
|
||||
Ein vollständiges Ticketsystem mit Java-Backend und React-Frontend. Das MVP unterstützt Anmeldung, Selbstregistrierung, Benutzer- und Rechteverwaltung, persönliche Ticketbereiche, Suche, Filter, Statuswechsel, Prioritäten, Zuweisungen und Kommentare.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: Java 21, Spring Boot 4.1.1, Spring Web, Spring Data JPA, Validation
|
||||
- Sicherheit: Spring Security, BCrypt-Passwörter, serverseitige Sessions und CSRF-Schutz
|
||||
- Datenbank: H2 im lokalen Entwicklungsmodus, PostgreSQL mit Docker Compose
|
||||
- Frontend: React 19, Vite 8, responsive Oberfläche
|
||||
- Betrieb: Docker Compose mit PostgreSQL, Java-Backend und Nginx-Frontend
|
||||
|
||||
## Schnellstart mit Docker
|
||||
|
||||
Voraussetzung: Docker mit Compose.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Danach öffnen:
|
||||
|
||||
- Weboberfläche: http://localhost:3000
|
||||
- REST-API: http://localhost:8080/api/tickets
|
||||
- Healthcheck: http://localhost:8080/actuator/health
|
||||
|
||||
Die PostgreSQL-Daten bleiben im Docker-Volume `ticket_db` erhalten.
|
||||
|
||||
## Demo-Anmeldungen
|
||||
|
||||
| Rolle | E-Mail | Passwort | Sichtbarkeit |
|
||||
| --- | --- | --- | --- |
|
||||
| Administrator | `admin@klaro.de` | `demo123` | Alle Tickets, eigener Bereich und Nutzerverwaltung |
|
||||
| Support | `support@klaro.de` | `demo123` | „Alle Tickets“ und „Mein Bereich“ |
|
||||
| Benutzer | `user@klaro.de` | `demo123` | Nur „Mein Bereich“ mit eigenen Tickets |
|
||||
|
||||
Über „Registrieren“ können neue Benutzer selbst ein Konto anlegen. Neue Konten erhalten immer zunächst die Rolle `USER`. Ein Administrator kann anschließend in der Nutzerverwaltung die Rollen `USER`, `SUPPORT` oder `ADMIN` vergeben. Ticketzugehörigkeit und Rollenprüfung werden im Backend erzwungen.
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### Backend
|
||||
|
||||
Voraussetzungen: Java 21 und Maven 3.9 oder neuer.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
Standardmäßig verwendet das Backend eine persistente H2-Dateidatenbank unter `backend/data/`. Die H2-Konsole ist unter http://localhost:8080/h2-console erreichbar. JDBC-URL: `jdbc:h2:file:./data/tickets`.
|
||||
|
||||
### Frontend
|
||||
|
||||
Voraussetzung: Node.js 24 oder neuer.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite leitet `/api` im Entwicklungsmodus an `http://localhost:8080` weiter. Die Oberfläche ist unter http://localhost:5173 erreichbar.
|
||||
|
||||
## REST-API
|
||||
|
||||
| Methode | Pfad | Funktion |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/auth/register` | Benutzerkonto erstellen und anmelden |
|
||||
| `GET` | `/api/tickets?scope=MINE` | Eigene Tickets auflisten |
|
||||
| `GET` | `/api/tickets?scope=ALL` | Alle Tickets auflisten – Support und Administratoren |
|
||||
| `GET` | `/api/tickets/{id}` | Ticket inklusive Kommentaren laden |
|
||||
| `POST` | `/api/tickets` | Ticket erstellen |
|
||||
| `PATCH` | `/api/tickets/{id}` | Ticket bearbeiten |
|
||||
| `POST` | `/api/tickets/{id}/comments` | Kommentar hinzufügen |
|
||||
| `GET` | `/api/users` | Registrierte Benutzer auflisten – nur Administratoren |
|
||||
| `PATCH` | `/api/users/{id}/role` | Benutzerrolle ändern – nur Administratoren |
|
||||
|
||||
Beispiel für ein neues Ticket:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Zugriff auf Projektordner fehlt",
|
||||
"description": "Der Ordner ist seit heute nicht mehr erreichbar.",
|
||||
"category": "IT & Zugriff",
|
||||
"priority": "HIGH",
|
||||
"assignee": "Jonas Weber"
|
||||
}
|
||||
```
|
||||
|
||||
Gültige Statuswerte: `OPEN`, `IN_PROGRESS`, `WAITING`, `RESOLVED`, `CLOSED`.
|
||||
|
||||
Gültige Prioritäten: `LOW`, `MEDIUM`, `HIGH`, `URGENT`.
|
||||
|
||||
## Tests und Builds
|
||||
|
||||
```bash
|
||||
cd backend && mvn test
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
## Nächste sinnvolle Ausbaustufen
|
||||
|
||||
- E-Mail-Benachrichtigungen und SLA-Regeln
|
||||
- Anhänge, Tags und Wissensdatenbank
|
||||
- Flyway-Migrationen statt automatischer Schema-Aktualisierung
|
||||
- Audit-Log, Reporting und Export
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="target/classes" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="optional" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="test" value="true"/>
|
||||
<attribute name="optional" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="output" path="target/classes"/>
|
||||
</classpath>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>backend</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.m2e.core.maven2Builder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.m2e.core.maven2Nature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -0,0 +1,5 @@
|
||||
eclipse.preferences.version=1
|
||||
encoding//src/main/java=UTF-8
|
||||
encoding//src/main/resources=UTF-8
|
||||
encoding//src/test/java=UTF-8
|
||||
encoding/<project>=UTF-8
|
||||
@@ -0,0 +1,9 @@
|
||||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.methodParameters=generate
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=21
|
||||
org.eclipse.jdt.core.compiler.compliance=21
|
||||
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
|
||||
org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
|
||||
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
|
||||
org.eclipse.jdt.core.compiler.release=enabled
|
||||
org.eclipse.jdt.core.compiler.source=21
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
RUN mvn -q -B dependency:go-offline
|
||||
COPY src src
|
||||
RUN mvn -q -B package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/ticket-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
<groupId>de.klaro</groupId>
|
||||
<artifactId>ticket-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Klaro Ticket Backend</name>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.klaro.tickets;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class TicketApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TicketApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
@ExceptionHandler(EntityNotFoundException.class)
|
||||
ProblemDetail notFound(EntityNotFoundException exception) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
ProblemDetail validation(MethodArgumentNotValidException exception) {
|
||||
String detail = exception.getBindingResult().getFieldErrors().stream()
|
||||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
ProblemDetail forbidden(AccessDeniedException exception) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
ProblemDetail unauthorized(AuthenticationException exception) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.service.CurrentUserService;
|
||||
import de.klaro.tickets.service.UserService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController implements InitializingBean {
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final CurrentUserService currentUsers;
|
||||
private final UserService users;
|
||||
private boolean registrationDisabled;
|
||||
@Autowired
|
||||
private Environment environment;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, CurrentUserService currentUsers, UserService users) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.currentUsers = currentUsers;
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
String propertyValue = environment.getProperty("ticket.registration.disabled");
|
||||
registrationDisabled = "1".equals(propertyValue) || "true".equalsIgnoreCase(propertyValue);
|
||||
}
|
||||
|
||||
@GetMapping("/csrf")
|
||||
public Map<String, String> csrf(CsrfToken token) { return Map.of("token", token.getToken()); }
|
||||
|
||||
@GetMapping("/me")
|
||||
public AuthResponse me(Authentication authentication) { return AuthResponse.from(currentUsers.require(authentication)); }
|
||||
|
||||
@PostMapping("/login")
|
||||
public AuthResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
|
||||
return establishSession(request.username(), request.password(), servletRequest, servletResponse);
|
||||
}
|
||||
|
||||
@GetMapping("/register")
|
||||
public boolean registrationDisabled() {
|
||||
return registrationDisabled;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
|
||||
if (registrationDisabled) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Die Registrierung neuer Nutzer ist deaktiviert.");
|
||||
}
|
||||
users.register(request);
|
||||
return ResponseEntity.status(201).body(establishSession(request.username(), request.password(), servletRequest, servletResponse));
|
||||
}
|
||||
|
||||
private AuthResponse establishSession(String email, String password, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
|
||||
Authentication authentication = authenticationManager.authenticate(
|
||||
UsernamePasswordAuthenticationToken.unauthenticated(email.trim().toLowerCase(), password));
|
||||
var context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(authentication);
|
||||
SecurityContextHolder.setContext(context);
|
||||
new HttpSessionSecurityContextRepository().saveContext(context, servletRequest, servletResponse);
|
||||
return AuthResponse.from(currentUsers.require(authentication));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
|
||||
new SecurityContextLogoutHandler().logout(request, response, authentication);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
|
||||
public record AuthResponse(Long id, String name, String email, UserRole role) {
|
||||
public static AuthResponse from(AppUser user) {
|
||||
return new AuthResponse(user.getId(), user.getName(), user.getUsername(), user.getRole());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CommentCreateRequest(
|
||||
@NotBlank @Size(max = 120) String author,
|
||||
@NotBlank @Size(max = 5000) String body
|
||||
) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.Comment;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CommentResponse(Long id, String author, String body, LocalDateTime createdAt) {
|
||||
public static CommentResponse from(Comment comment) {
|
||||
return new CommentResponse(comment.getId(), comment.getAuthor(), comment.getBody(), comment.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(@NotBlank String username, @NotBlank String password) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Size(max = 120) String name,
|
||||
@NotBlank @Size(max = 180) String username,
|
||||
@NotBlank @Size(min = 8, max = 72) String password
|
||||
) {}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import de.klaro.tickets.domain.TicketStatus;
|
||||
import de.klaro.tickets.service.TicketService;
|
||||
import de.klaro.tickets.service.CurrentUserService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tickets")
|
||||
public class TicketController {
|
||||
private final TicketService service;
|
||||
private final CurrentUserService currentUsers;
|
||||
|
||||
public TicketController(TicketService service, CurrentUserService currentUsers) { this.service = service; this.currentUsers = currentUsers; }
|
||||
|
||||
@GetMapping
|
||||
public List<TicketResponse> search(
|
||||
Authentication authentication,
|
||||
@RequestParam(defaultValue = "MINE") TicketScope scope,
|
||||
@RequestParam(required = false) String query,
|
||||
@RequestParam(required = false) TicketStatus status,
|
||||
@RequestParam(required = false) Priority priority
|
||||
) { return service.search(currentUsers.require(authentication), scope, query, status, priority); }
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public TicketResponse get(Authentication authentication, @PathVariable long id) { return service.get(currentUsers.require(authentication), id); }
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<TicketResponse> create(Authentication authentication, @Valid @RequestBody TicketCreateRequest request) {
|
||||
TicketResponse ticket = service.create(currentUsers.require(authentication), request);
|
||||
return ResponseEntity.created(URI.create("/api/tickets/" + ticket.id())).body(ticket);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public TicketResponse update(Authentication authentication, @PathVariable long id, @Valid @RequestBody TicketUpdateRequest request) {
|
||||
return service.update(currentUsers.require(authentication), id, request);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/comments")
|
||||
public ResponseEntity<CommentResponse> addComment(Authentication authentication, @PathVariable long id, @Valid @RequestBody CommentCreateRequest request) {
|
||||
return ResponseEntity.status(201).body(service.addComment(currentUsers.require(authentication), id, request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record TicketCreateRequest(
|
||||
@NotBlank @Size(max = 180) String subject,
|
||||
@NotBlank @Size(max = 5000) String description,
|
||||
@Size(max = 80) String category,
|
||||
Priority priority,
|
||||
@Size(max = 120) String assignee,
|
||||
@Size(max = 120) String contact
|
||||
) {}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import de.klaro.tickets.domain.Ticket;
|
||||
import de.klaro.tickets.domain.TicketStatus;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record TicketResponse(
|
||||
Long id,
|
||||
String subject,
|
||||
String description,
|
||||
String requester,
|
||||
String category,
|
||||
Priority priority,
|
||||
TicketStatus status,
|
||||
String assignee,
|
||||
String contact,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
long version,
|
||||
List<CommentResponse> comments
|
||||
) {
|
||||
public static TicketResponse from(Ticket ticket, boolean includeComments) {
|
||||
var comments = includeComments
|
||||
? ticket.getComments().stream().map(CommentResponse::from).toList()
|
||||
: List.<CommentResponse>of();
|
||||
return new TicketResponse(ticket.getId(), ticket.getSubject(), ticket.getDescription(), ticket.getRequester(),
|
||||
ticket.getCategory(), ticket.getPriority(), ticket.getStatus(), ticket.getAssignee(), ticket.getContact(), ticket.getCreatedAt(),
|
||||
ticket.getUpdatedAt(), ticket.getVersion(), comments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
public enum TicketScope {
|
||||
MINE, ALL
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import de.klaro.tickets.domain.TicketStatus;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record TicketUpdateRequest(
|
||||
@Size(max = 180) String subject,
|
||||
@Size(max = 5000) String description,
|
||||
@Size(max = 80) String category,
|
||||
Priority priority,
|
||||
TicketStatus status,
|
||||
@Size(max = 120) String assignee,
|
||||
@Size(max = 120) String contact
|
||||
) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.service.CurrentUserService;
|
||||
import de.klaro.tickets.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
private final UserService service;
|
||||
private final CurrentUserService currentUsers;
|
||||
|
||||
public UserController(UserService service, CurrentUserService currentUsers) {
|
||||
this.service = service;
|
||||
this.currentUsers = currentUsers;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<UserResponse> list(Authentication authentication) {
|
||||
return service.list(currentUsers.require(authentication));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/role")
|
||||
public UserResponse updateRole(Authentication authentication, @PathVariable long id, @Valid @RequestBody UserRoleUpdateRequest request) {
|
||||
return service.updateRole(currentUsers.require(authentication), id, request.role());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
|
||||
public record UserResponse(Long id, String name, String email, UserRole role) {
|
||||
public static UserResponse from(AppUser user) {
|
||||
return new UserResponse(user.getId(), user.getName(), user.getUsername(), user.getRole());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.klaro.tickets.api;
|
||||
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record UserRoleUpdateRequest(@NotNull UserRole role) {}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.klaro.tickets.config;
|
||||
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
import de.klaro.tickets.repository.TicketRepository;
|
||||
import de.klaro.tickets.repository.UserRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Component
|
||||
public class DemoDataInitializer implements CommandLineRunner {
|
||||
private final TicketRepository tickets;
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwords;
|
||||
public DemoDataInitializer(TicketRepository tickets, UserRepository users, PasswordEncoder passwords) {
|
||||
this.tickets = tickets; this.users = users; this.passwords = passwords;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
AppUser admin = users.findByUsernameIgnoreCase("admin").orElseGet(() -> users.save(user("Admin", "admin", "zdigbw!26", UserRole.ADMIN)));
|
||||
if (tickets.count() > 0) {
|
||||
tickets.findAll().stream().filter(ticket -> ticket.getOwner() == null).forEach(ticket -> ticket.setOwner(admin));
|
||||
tickets.flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private AppUser user(String name, String username, String password, UserRole role) {
|
||||
AppUser user = new AppUser();
|
||||
user.setName(name); user.setUsername(username); user.setPasswordHash(passwords.encode(password)); user.setRole(role);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.klaro.tickets.config;
|
||||
|
||||
import de.klaro.tickets.repository.UserRepository;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
|
||||
|
||||
@Bean
|
||||
UserDetailsService userDetailsService(UserRepository users) {
|
||||
return username -> users.findByUsernameIgnoreCase(username)
|
||||
.map(user -> User.withUsername(user.getUsername())
|
||||
.password(user.getPasswordHash())
|
||||
.roles(user.getRole().name())
|
||||
.build())
|
||||
.orElseThrow(() -> new org.springframework.security.core.userdetails.UsernameNotFoundException("Benutzer nicht gefunden."));
|
||||
}
|
||||
|
||||
@Bean
|
||||
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/csrf", "/actuator/health", "/error").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(errors -> errors.authenticationEntryPoint((request, response, exception) -> response.sendError(HttpStatus.UNAUTHORIZED.value())))
|
||||
.formLogin(form -> form.disable())
|
||||
.httpBasic(basic -> basic.disable())
|
||||
.logout(logout -> logout.disable())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.klaro.tickets.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
@Value("${app.cors.allowed-origin:http://localhost:5173/}")
|
||||
private String allowedOrigin;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**").allowedOrigins("http://localhost:5173/", "http://10.10.150.20:3000", "https://support.repmus.visentra.de").allowedMethods("GET", "POST", "PATCH", "OPTIONS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "app_users", uniqueConstraints = @UniqueConstraint(name = "uk_user_username", columnNames = "username"))
|
||||
public class AppUser {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 100)
|
||||
private String passwordHash;
|
||||
|
||||
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20)
|
||||
private UserRole role;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
public String getPasswordHash() { return passwordHash; }
|
||||
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||
public UserRole getRole() { return role; }
|
||||
public void setRole(UserRole role) { this.role = role; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "ticket_comments", indexes = @Index(name = "idx_comment_ticket", columnList = "ticket_id"))
|
||||
public class Comment {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "ticket_id", nullable = false)
|
||||
private Ticket ticket;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String author;
|
||||
|
||||
@Column(nullable = false, length = 5000)
|
||||
private String body;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void createTimestamp() { createdAt = LocalDateTime.now(); }
|
||||
|
||||
public Long getId() { return id; }
|
||||
public Ticket getTicket() { return ticket; }
|
||||
public void setTicket(Ticket ticket) { this.ticket = ticket; }
|
||||
public String getAuthor() { return author; }
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
public String getBody() { return body; }
|
||||
public void setBody(String body) { this.body = body; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
public enum Priority {
|
||||
LOW, MEDIUM, HIGH, URGENT
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tickets", indexes = {
|
||||
@Index(name = "idx_ticket_status", columnList = "status"),
|
||||
@Index(name = "idx_ticket_priority", columnList = "priority"),
|
||||
@Index(name = "idx_ticket_updated", columnList = "updated_at")
|
||||
})
|
||||
public class Ticket {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String subject;
|
||||
|
||||
@Column(nullable = false, length = 5000)
|
||||
private String description;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String requester;
|
||||
|
||||
@Column(nullable = false, length = 80)
|
||||
private String category;
|
||||
|
||||
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 24)
|
||||
private Priority priority;
|
||||
|
||||
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 24)
|
||||
private TicketStatus status;
|
||||
|
||||
@Column(length = 120)
|
||||
private String assignee;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_id")
|
||||
private AppUser owner;
|
||||
|
||||
@Column(length = 120)
|
||||
private String contact;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
@OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("createdAt DESC")
|
||||
private List<Comment> comments = new ArrayList<>();
|
||||
|
||||
@PrePersist
|
||||
void createTimestamps() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = createdAt;
|
||||
if (priority == null) priority = Priority.MEDIUM;
|
||||
if (status == null) status = TicketStatus.OPEN;
|
||||
if (category == null || category.isBlank()) category = "Allgemein";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void updateTimestamp() { updatedAt = LocalDateTime.now(); }
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getSubject() { return subject; }
|
||||
public void setSubject(String subject) { this.subject = subject; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getRequester() { return requester; }
|
||||
public void setRequester(String requester) { this.requester = requester; }
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
public Priority getPriority() { return priority; }
|
||||
public void setPriority(Priority priority) { this.priority = priority; }
|
||||
public TicketStatus getStatus() { return status; }
|
||||
public void setStatus(TicketStatus status) { this.status = status; }
|
||||
public String getAssignee() { return assignee; }
|
||||
public void setAssignee(String assignee) { this.assignee = assignee; }
|
||||
public AppUser getOwner() { return owner; }
|
||||
public void setOwner(AppUser owner) { this.owner = owner; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public long getVersion() { return version; }
|
||||
public List<Comment> getComments() { return comments; }
|
||||
public void addComment(Comment comment) { comments.add(comment); comment.setTicket(this); }
|
||||
|
||||
public String getContact() {
|
||||
return contact;
|
||||
}
|
||||
|
||||
public void setContact(String contact) {
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
public enum TicketStatus {
|
||||
OPEN, IN_PROGRESS, WAITING, RESOLVED, CLOSED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.klaro.tickets.domain;
|
||||
|
||||
public enum UserRole {
|
||||
USER, SUPPORT, ADMIN
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.klaro.tickets.repository;
|
||||
|
||||
import de.klaro.tickets.domain.Comment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CommentRepository extends JpaRepository<Comment, Long> {}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.klaro.tickets.repository;
|
||||
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import de.klaro.tickets.domain.Ticket;
|
||||
import de.klaro.tickets.domain.TicketStatus;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface TicketRepository extends JpaRepository<Ticket, Long> {
|
||||
@EntityGraph(attributePaths = "comments")
|
||||
@Query("select distinct t from Ticket t where t.id = :id")
|
||||
Optional<Ticket> findDetailedById(@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
select t from Ticket t
|
||||
where (:query = '' or lower(t.subject) like lower(concat('%', :query, '%'))
|
||||
or lower(t.requester) like lower(concat('%', :query, '%'))
|
||||
or lower(t.category) like lower(concat('%', :query, '%')))
|
||||
and (:status is null or t.status = :status)
|
||||
and (:priority is null or t.priority = :priority)
|
||||
order by t.updatedAt desc
|
||||
""")
|
||||
List<Ticket> searchAll(@Param("query") String query, @Param("status") TicketStatus status, @Param("priority") Priority priority);
|
||||
|
||||
@Query("""
|
||||
select t from Ticket t
|
||||
where t.owner.id = :ownerId
|
||||
and (:query = '' or lower(t.subject) like lower(concat('%', :query, '%'))
|
||||
or lower(t.requester) like lower(concat('%', :query, '%'))
|
||||
or lower(t.category) like lower(concat('%', :query, '%')))
|
||||
and (:status is null or t.status = :status)
|
||||
and (:priority is null or t.priority = :priority)
|
||||
order by t.updatedAt desc
|
||||
""")
|
||||
List<Ticket> searchOwned(@Param("ownerId") Long ownerId, @Param("query") String query, @Param("status") TicketStatus status, @Param("priority") Priority priority);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.klaro.tickets.repository;
|
||||
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
Optional<AppUser> findByUsernameIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.klaro.tickets.service;
|
||||
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.repository.UserRepository;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CurrentUserService {
|
||||
private final UserRepository users;
|
||||
|
||||
public CurrentUserService(UserRepository users) { this.users = users; }
|
||||
|
||||
public AppUser require(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw new EntityNotFoundException("Benutzer ist nicht angemeldet.");
|
||||
}
|
||||
return users.findByUsernameIgnoreCase(authentication.getName())
|
||||
.orElseThrow(() -> new EntityNotFoundException("Angemeldeter Benutzer wurde nicht gefunden."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.klaro.tickets.service;
|
||||
|
||||
import de.klaro.tickets.api.*;
|
||||
import de.klaro.tickets.domain.Comment;
|
||||
import de.klaro.tickets.domain.Priority;
|
||||
import de.klaro.tickets.domain.Ticket;
|
||||
import de.klaro.tickets.domain.TicketStatus;
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
import de.klaro.tickets.repository.CommentRepository;
|
||||
import de.klaro.tickets.repository.TicketRepository;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class TicketService {
|
||||
private final TicketRepository tickets;
|
||||
private final CommentRepository comments;
|
||||
|
||||
public TicketService(TicketRepository tickets, CommentRepository comments) {
|
||||
this.tickets = tickets;
|
||||
this.comments = comments;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TicketResponse> search(AppUser currentUser, TicketScope scope, String query, TicketStatus status, Priority priority) {
|
||||
String normalized = query == null || query.isBlank() ? "" : query.trim();
|
||||
if (scope == TicketScope.ALL) {
|
||||
requireSupport(currentUser);
|
||||
return tickets.searchAll(normalized, status, priority).stream().map(ticket -> TicketResponse.from(ticket, false)).toList();
|
||||
}
|
||||
return tickets.searchOwned(currentUser.getId(), normalized, status, priority).stream().map(ticket -> TicketResponse.from(ticket, false)).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TicketResponse get(AppUser currentUser, long id) {
|
||||
Ticket ticket = findDetailed(id);
|
||||
requireAccess(currentUser, ticket);
|
||||
return TicketResponse.from(ticket, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TicketResponse create(AppUser currentUser, TicketCreateRequest request) {
|
||||
Ticket ticket = new Ticket();
|
||||
ticket.setSubject(request.subject().trim());
|
||||
ticket.setDescription(request.description().trim());
|
||||
ticket.setRequester(currentUser.getName());
|
||||
ticket.setOwner(currentUser);
|
||||
ticket.setCategory(blankToDefault(request.category(), "Allgemein"));
|
||||
ticket.setPriority(request.priority() == null ? Priority.MEDIUM : request.priority());
|
||||
ticket.setStatus(TicketStatus.OPEN);
|
||||
ticket.setContact(blankToNull(request.contact()));
|
||||
ticket.setAssignee(canManageTickets(currentUser) ? blankToNull(request.assignee()) : null);
|
||||
return TicketResponse.from(tickets.save(ticket), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TicketResponse update(AppUser currentUser, long id, TicketUpdateRequest request) {
|
||||
Ticket ticket = find(id);
|
||||
if (canManageTickets(currentUser)) {
|
||||
if (request.subject() != null && !request.subject().isBlank()) ticket.setSubject(request.subject().trim());
|
||||
if (request.description() != null && !request.description().isBlank()) ticket.setDescription(request.description().trim());
|
||||
if (request.category() != null) ticket.setCategory(blankToDefault(request.category(), "Allgemein"));
|
||||
if (request.priority() != null) ticket.setPriority(request.priority());
|
||||
if (request.status() != null) ticket.setStatus(request.status());
|
||||
if (request.assignee() != null) ticket.setAssignee(blankToNull(request.assignee()));
|
||||
}
|
||||
if (request.contact() != null) ticket.setContact(blankToNull(request.contact()));
|
||||
return TicketResponse.from(tickets.save(ticket), true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CommentResponse addComment(AppUser currentUser, long id, CommentCreateRequest request) {
|
||||
Ticket ticket = find(id);
|
||||
requireAccess(currentUser, ticket);
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(currentUser.getName());
|
||||
comment.setBody(request.body().trim());
|
||||
comment.setTicket(ticket);
|
||||
return CommentResponse.from(comments.save(comment));
|
||||
}
|
||||
|
||||
private Ticket find(long id) {
|
||||
return tickets.findById(id).orElseThrow(() -> new EntityNotFoundException("Ticket " + id + " wurde nicht gefunden."));
|
||||
}
|
||||
|
||||
private Ticket findDetailed(long id) {
|
||||
return tickets.findDetailedById(id).orElseThrow(() -> new EntityNotFoundException("Ticket " + id + " wurde nicht gefunden."));
|
||||
}
|
||||
|
||||
private static void requireSupport(AppUser user) {
|
||||
if (!canManageTickets(user)) throw new AccessDeniedException("Nur Support und Administratoren dürfen alle Tickets verwalten.");
|
||||
}
|
||||
|
||||
private static void requireAccess(AppUser user, Ticket ticket) {
|
||||
boolean ownsTicket = ticket.getOwner() != null && ticket.getOwner().getId().equals(user.getId());
|
||||
if (!canManageTickets(user) && !ownsTicket) throw new AccessDeniedException("Dieses Ticket gehört einem anderen Benutzer.");
|
||||
}
|
||||
|
||||
private static boolean canManageTickets(AppUser user) {
|
||||
return user.getRole() == UserRole.SUPPORT || user.getRole() == UserRole.ADMIN;
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String blankToDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.klaro.tickets.service;
|
||||
|
||||
import de.klaro.tickets.api.RegisterRequest;
|
||||
import de.klaro.tickets.api.UserResponse;
|
||||
import de.klaro.tickets.domain.AppUser;
|
||||
import de.klaro.tickets.domain.UserRole;
|
||||
import de.klaro.tickets.repository.UserRepository;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwords;
|
||||
|
||||
public UserService(UserRepository users, PasswordEncoder passwords) {
|
||||
this.users = users;
|
||||
this.passwords = passwords;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser register(RegisterRequest request) {
|
||||
String username = request.username().trim().toLowerCase();
|
||||
if (users.findByUsernameIgnoreCase(username).isPresent()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Für diesen Nutzernamen besteht bereits ein Konto.");
|
||||
}
|
||||
AppUser user = new AppUser();
|
||||
user.setName(request.name().trim());
|
||||
user.setUsername(username);
|
||||
user.setPasswordHash(passwords.encode(request.password()));
|
||||
user.setRole(UserRole.USER);
|
||||
return users.save(user);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserResponse> list(AppUser currentUser) {
|
||||
requireAdmin(currentUser);
|
||||
return users.findAll().stream()
|
||||
.sorted((left, right) -> left.getName().compareToIgnoreCase(right.getName()))
|
||||
.map(UserResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateRole(AppUser currentUser, long id, UserRole role) {
|
||||
requireAdmin(currentUser);
|
||||
AppUser user = users.findById(id).orElseThrow(() -> new EntityNotFoundException("Benutzer wurde nicht gefunden."));
|
||||
if (user.getId().equals(currentUser.getId()) && role != UserRole.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Die eigene Adminrolle kann nicht entfernt werden.");
|
||||
}
|
||||
user.setRole(role);
|
||||
return UserResponse.from(users.save(user));
|
||||
}
|
||||
|
||||
private static void requireAdmin(AppUser user) {
|
||||
if (user.getRole() != UserRole.ADMIN) {
|
||||
throw new AccessDeniedException("Nur Administratoren dürfen Benutzer und Rollen verwalten.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DATABASE_URL:jdbc:postgresql://postgres:5432/tickets}
|
||||
username: ${DATABASE_USER:tickets}
|
||||
password: ${DATABASE_PASSWORD:tickets}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
h2:
|
||||
console:
|
||||
enabled: false
|
||||
@@ -0,0 +1,34 @@
|
||||
spring:
|
||||
application:
|
||||
name: uniity-ticketdesk
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/tickets;MODE=PostgreSQL;AUTO_SERVER=TRUE
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
open-in-view: false
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
|
||||
ticket:
|
||||
registration:
|
||||
disabled: ${TICKET_REGISTRATION_DISABLED:false}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
session:
|
||||
timeout: 8h
|
||||
cookie:
|
||||
http-only: true
|
||||
same-site: lax
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.klaro.tickets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:testdb")
|
||||
class TicketApplicationTests {
|
||||
@Test void contextLoads() {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: tickets
|
||||
POSTGRES_USER: tickets
|
||||
POSTGRES_PASSWORD: tickets
|
||||
volumes:
|
||||
- ticket_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tickets"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: postgres
|
||||
DATABASE_URL: jdbc:postgresql://postgres:5432/tickets
|
||||
DATABASE_USER: tickets
|
||||
DATABASE_PASSWORD: tickets
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "9080:8080"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "3000:80"
|
||||
|
||||
volumes:
|
||||
ticket_db:
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>frontend</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="UNIITY Ticketdesk – Supportanfragen zentral bearbeiten." />
|
||||
<title>UNIITY Ticketdesk</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "klaro-ticket-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "1.31.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-textarea-autosize": "^8.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"vite": "8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, CircleDot, Clock3, Inbox, LifeBuoy, LockKeyhole, LogOut, MessageSquareText, Pencil, Plus, SaveCheck, Search, Send, ShieldCheck, TicketCheck, UserPlus, UserRound, Users, X } from "lucide-react";
|
||||
import { api } from "./api";
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
|
||||
const statusNames = { OPEN: "Offen", IN_PROGRESS: "In Bearbeitung", WAITING: "Wartet auf Antwort", RESOLVED: "Gelöst", CLOSED: "Geschlossen" };
|
||||
const priorityNames = { LOW: "Niedrig", MEDIUM: "Mittel", HIGH: "Hoch", URGENT: "Dringend" };
|
||||
const roleNames = { USER: "Benutzer", SUPPORT: "Support", ADMIN: "Administrator" };
|
||||
|
||||
function code(id) { return `TK-${String(id).padStart(4, "0")}`; }
|
||||
function date(value) { return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
|
||||
function canManageTickets(user) { return user?.role === "SUPPORT" || user?.role === "ADMIN"; }
|
||||
function Badge({ status }) { return <span className={`badge badge-${status.toLowerCase()}`}>{statusNames[status]}</span>; }
|
||||
function Priority({ value }) { return <span className="priority"><i className={`dot dot-${value.toLowerCase()}`} />{priorityNames[value]}</span>; }
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(undefined);
|
||||
const [view, setView] = useState("TICKETS");
|
||||
const [scope, setScope] = useState("MINE");
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("ALL");
|
||||
const [priority, setPriority] = useState("ALL");
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [editDescription, setEditDescription] = useState(false);
|
||||
const createDialog = useRef(null);
|
||||
|
||||
async function load(nextScope = scope) {
|
||||
try { setLoading(true); setError(""); setTickets(await api.list(nextScope)); }
|
||||
catch (e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const current = await api.me();
|
||||
const initialScope = canManageTickets(current) ? "ALL" : "MINE";
|
||||
setUser(current); setScope(initialScope);
|
||||
await load(initialScope);
|
||||
} catch (e) {
|
||||
if (e.status === 401) setUser(null); else setError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
bootstrap();
|
||||
}, []);
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setError(""); setLoading(true);
|
||||
const current = await api.login(Object.fromEntries(new FormData(event.currentTarget)));
|
||||
const initialScope = canManageTickets(current) ? "ALL" : "MINE";
|
||||
setUser(current); setScope(initialScope); setView("TICKETS");
|
||||
await load(initialScope);
|
||||
} catch (e) { setError(e.status === 401 ? "Benutzername oder Passwort ist falsch." : e.message); setLoading(false); }
|
||||
}
|
||||
|
||||
async function register(event) {
|
||||
event.preventDefault();
|
||||
const values = Object.fromEntries(new FormData(event.currentTarget));
|
||||
if (values.password !== values.passwordConfirmation) { setError("Die Passwörter stimmen nicht überein."); return; }
|
||||
delete values.passwordConfirmation;
|
||||
try {
|
||||
setError(""); setLoading(true);
|
||||
const current = await api.register(values);
|
||||
setUser(current); setScope("MINE"); setView("TICKETS");
|
||||
await load("MINE");
|
||||
} catch (e) { setError(e.message); setLoading(false); }
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
setUser(null); setTickets([]); setUsers([]); setSelected(null); setError("");
|
||||
}
|
||||
|
||||
async function switchScope(nextScope) {
|
||||
setView("TICKETS"); setScope(nextScope); setSelected(null); setQuery(""); setStatus("ALL"); setPriority("ALL");
|
||||
await load(nextScope);
|
||||
}
|
||||
|
||||
async function openUsers() {
|
||||
setView("USERS"); setSelected(null); setLoading(true); setError("");
|
||||
try { setUsers(await api.users()); } catch (e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function changeRole(account, role) {
|
||||
try {
|
||||
setError("");
|
||||
const updated = await api.updateUserRole(account.id, role);
|
||||
setUsers((current) => current.map((entry) => entry.id === updated.id ? updated : entry));
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => tickets.filter((ticket) => {
|
||||
const term = query.trim().toLowerCase();
|
||||
const match = !term || `${ticket.id} ${ticket.subject} ${ticket.requester} ${ticket.category}`.toLowerCase().includes(term);
|
||||
return match && (status === "ALL" || ticket.status === status) && (priority === "ALL" || ticket.priority === priority);
|
||||
}), [tickets, query, status, priority]);
|
||||
|
||||
const stats = {
|
||||
OPEN: tickets.filter((t) => t.status === "OPEN").length,
|
||||
IN_PROGRESS: tickets.filter((t) => t.status === "IN_PROGRESS").length,
|
||||
WAITING: tickets.filter((t) => t.status === "WAITING").length,
|
||||
DONE: tickets.filter((t) => ["RESOLVED", "CLOSED"].includes(t.status)).length,
|
||||
};
|
||||
|
||||
async function openTicket(ticket) {
|
||||
setSelected(ticket);
|
||||
setDescription(ticket.description);
|
||||
try { setSelected(await api.get(ticket.id)); } catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function createTicket(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
try {
|
||||
const created = await api.create(Object.fromEntries(new FormData(form)));
|
||||
setTickets((current) => [created, ...current]);
|
||||
createDialog.current?.close();
|
||||
form.reset();
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function update(values) {
|
||||
try {
|
||||
const updated = await api.update(selected.id, values);
|
||||
setSelected((current) => ({ ...current, ...updated }));
|
||||
setTickets((current) => current.map((ticket) => ticket.id === updated.id ? updated : ticket));
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function sendComment(event) {
|
||||
event.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
try {
|
||||
const created = await api.comment(selected.id, { author: user.name, body: comment });
|
||||
setSelected((current) => ({ ...current, comments: [created, ...(current.comments || [])] }));
|
||||
setComment("");
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function sendDescription(event) {
|
||||
event.preventDefault();
|
||||
update({ description: description });
|
||||
setEditDescription(false);
|
||||
}
|
||||
|
||||
if (user === undefined) return <div className="login-shell"><div className="login-loading">Anmeldung wird geprüft …</div></div>;
|
||||
if (!user) return <LoginScreen onLogin={login} onRegister={register} error={error} loading={loading} clearError={() => setError("")} />;
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand"><span className="brand-mark"><TicketCheck size={20} /></span><div><strong>UNIITY</strong><small>Ticketdesk</small></div></div>
|
||||
<nav>
|
||||
{canManageTickets(user) && <button className={view === "TICKETS" && scope === "ALL" ? "nav-active" : ""} onClick={() => switchScope("ALL")}><Inbox size={17} /> Alle Tickets {view === "TICKETS" && scope === "ALL" && <span>{tickets.length}</span>}</button>}
|
||||
<button className={view === "TICKETS" && scope === "MINE" ? "nav-active" : ""} onClick={() => switchScope("MINE")}><UserRound size={17} /> Mein Bereich {view === "TICKETS" && scope === "MINE" && <span>{tickets.length}</span>}</button>
|
||||
{user.role === "ADMIN" && <button className={view === "USERS" ? "nav-active" : ""} onClick={openUsers}><Users size={17} /> Nutzerverwaltung</button>}
|
||||
</nav>
|
||||
<div className="support-card"><LifeBuoy size={18} /><strong>{roleNames[user.role]}</strong><p>{canManageTickets(user) ? "Zentrale Übersicht für Anfragen, Zuständigkeiten und Lösungen." : "Hier siehst du ausschließlich deine eigenen Anfragen."}</p></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header className="topbar"><div><small>{view === "USERS" ? "Administration" : scope === "ALL" ? "Support Workspace" : "Persönlicher Bereich"}</small><h1>{view === "USERS" ? "Nutzerverwaltung" : scope === "ALL" ? "Alle Tickets" : "Mein Bereich"}</h1></div><div className="topbar-actions"><div className="user-chip"><span>{user.name.slice(0, 1)}</span><div><strong>{user.name}</strong><small>{roleNames[user.role]}</small></div></div><button className="logout-button" onClick={logout} aria-label="Abmelden" title="Abmelden"><LogOut size={17} /></button>{view === "TICKETS" && <button className="button primary" onClick={() => createDialog.current?.showModal()}><Plus size={17} /> Neues Ticket</button>}</div></header>
|
||||
{view === "USERS" ? <UserManagement users={users} currentUser={user} loading={loading} error={error} onRoleChange={changeRole} /> : <div className="content">
|
||||
<section className="stats"><Stat label="Offen" value={stats.OPEN} icon={CircleDot} tone="blue" /><Stat label="In Bearbeitung" value={stats.IN_PROGRESS} icon={Clock3} tone="violet" /><Stat label="Wartet" value={stats.WAITING} icon={AlertCircle} tone="amber" /><Stat label="Erledigt" value={stats.DONE} icon={CheckCircle2} tone="green" /></section>
|
||||
<section className="tickets-card">
|
||||
<div className="toolbar"><div><h2>Ticketübersicht</h2><p>{filtered.length} von {tickets.length} Tickets</p></div><div className="filters"><label className="search"><Search size={16} /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ticket, Person, Kategorie …" aria-label="Tickets durchsuchen" /></label><select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Nach Status filtern"><option value="ALL">Alle Status</option>{Object.entries(statusNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select><select value={priority} onChange={(e) => setPriority(e.target.value)} aria-label="Nach Priorität filtern"><option value="ALL">Alle Prioritäten</option>{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></div></div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{loading ? <div className="empty">Tickets werden geladen …</div> : filtered.length === 0 ? <div className="empty"><Inbox size={30} /><strong>Keine passenden Tickets</strong><span>Passe Suche oder Filter an.</span></div> : <><div className="desktop-table"><table><thead><tr><th>Ticket</th><th>Status</th><th>Priorität</th><th>Anfragende Person</th><th>Zuständig</th><th>Aktualisiert</th></tr></thead><tbody>{filtered.map((ticket) => <tr key={ticket.id} onClick={() => openTicket(ticket)}><td><div className="ticket-title"><code>{code(ticket.id)}</code><div><strong>{ticket.subject}</strong><small>{ticket.category}</small></div></div></td><td><Badge status={ticket.status} /></td><td><Priority value={ticket.priority} /></td><td>{ticket.requester}</td><td className="muted">{ticket.assignee || "Nicht zugewiesen"}</td><td className="muted right">{date(ticket.updatedAt)}</td></tr>)}</tbody></table></div><div className="mobile-list">{filtered.map((ticket) => <button key={ticket.id} onClick={() => openTicket(ticket)}><span><code>{code(ticket.id)}</code><Badge status={ticket.status} /></span><strong>{ticket.subject}</strong><small>{ticket.requester}<Priority value={ticket.priority} /></small></button>)}</div></>}
|
||||
</section>
|
||||
</div>}
|
||||
</main>
|
||||
|
||||
<dialog ref={createDialog} className="create-dialog" onClick={(e) => { if (e.target === createDialog.current) createDialog.current?.close(); }}>
|
||||
<form onSubmit={createTicket} className="create-form">
|
||||
<button type="button" className="icon-close" onClick={() => createDialog.current?.close()} aria-label="Schließen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h2>Neues Ticket erfassen</h2>
|
||||
<p>Das Ticket wird automatisch deinem Konto zugeordnet.</p>
|
||||
<label>Betreff<input name="subject" required placeholder="Worum geht es?" /></label>
|
||||
<label>Beschreibung<textarea name="description" required placeholder="Beschreibe das Anliegen und relevante Details." /></label>
|
||||
<div className="form-grid">
|
||||
<label>Kategorie<input name="category" placeholder="z. B. IT & Zugriff" /></label>
|
||||
<label>Priorität<select name="priority" defaultValue="MEDIUM">{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||
<label className="full">Erreichbarkeit<input name="contact" placeholder="Optional" /></label>
|
||||
{canManageTickets(user) && <label className="full">Zuständigkeit<input name="assignee" placeholder="Optional" /></label>}
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button secondary" onClick={() => createDialog.current?.close()}>Abbrechen</button>
|
||||
<button className="button primary">Ticket erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
{selected && <>
|
||||
<button className="scrim" aria-label="Ticket schließen" onClick={() => setSelected(null)} />
|
||||
<aside className="detail-panel">
|
||||
<button className="icon-close" onClick={() => setSelected(null)} aria-label="Schließen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
<header>
|
||||
<div>
|
||||
<code>{code(selected.id)}</code>
|
||||
<Badge status={selected.status} />
|
||||
</div>
|
||||
<h2>{selected.subject}</h2>
|
||||
<p>Erstellt von {selected.requester} · {date(selected.createdAt)}</p>
|
||||
</header>
|
||||
<div className="detail-content">
|
||||
<section>
|
||||
<div style={{ display: 'flex', height: '20px', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<h3 style={{ diplay: 'flex', alignItems: 'center', margin: 0 }}>Beschreibung</h3>
|
||||
{(editDescription && description !== selected.description) &&
|
||||
<SaveCheck size={20} onClick={sendDescription} style={{ cursor: 'pointer', marginLeft: '10px' }} />
|
||||
}
|
||||
</div>
|
||||
<TextareaAutosize
|
||||
readOnly={!editDescription}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'none'
|
||||
}}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onClick={(e) => { if (!editDescription) setEditDescription(true) }}
|
||||
/>
|
||||
|
||||
</section>
|
||||
<section>
|
||||
<h3>Erreichbarkeit</h3>
|
||||
<input value={selected.contact || ""} onChange={(e) => setSelected({ ...selected, contact: e.target.value })} onBlur={(e) => update({ contact: e.target.value })} placeholder="Optional" style={{ width: '100%' }} />
|
||||
</section>
|
||||
{canManageTickets(user) &&
|
||||
<section className="edit-box">
|
||||
<label>Status<select value={selected.status} onChange={(e) => update({ status: e.target.value })}>{Object.entries(statusNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||
<label>Priorität<select value={selected.priority} onChange={(e) => update({ priority: e.target.value })}>{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
|
||||
<label classname="full">Zuständig<input value={selected.assignee || ""} onChange={(e) => setSelected({ ...selected, assignee: e.target.value })} onBlur={(e) => update({ assignee: e.target.value })} placeholder="Teammitglied zuweisen" /></label>
|
||||
<label>Kategorie<input value={selected.category} onChange={(e) => setSelected({ ...selected, category: e.target.value })} onBlur={(e) => update({ category: e.target.value })} placeholder="z. B. IT & Zugriff"/></label>
|
||||
</section>
|
||||
}
|
||||
<section>
|
||||
<h3 className="comments-title"><MessageSquareText size={17} /> Kommentare <span>{selected.comments?.length || 0}</span></h3>
|
||||
<form className="comment-form" onSubmit={sendComment}>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Antwort oder interne Notiz …" />
|
||||
<button className="button primary" aria-label="Kommentar senden" disabled={!comment.trim()}>
|
||||
<Send size={17} />
|
||||
</button>
|
||||
</form>
|
||||
<div className="comments">{selected.comments?.length ? selected.comments.map((entry) => <article key={entry.id}>
|
||||
<div>
|
||||
<strong>{entry.author}</strong>
|
||||
<time>{date(entry.createdAt)}</time>
|
||||
</div>
|
||||
<p>{entry.body}</p>
|
||||
</article>) : <p className="no-comments">Noch keine Kommentare.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function LoginScreen({ onLogin, onRegister, error, loading, clearError }) {
|
||||
const [mode, setMode] = useState("LOGIN");
|
||||
function switchMode(next) { setMode(next); clearError(); }
|
||||
return <main className="login-shell">
|
||||
<section className="login-card">
|
||||
<div className="login-brand">
|
||||
<span className="brand-mark">
|
||||
<TicketCheck size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>UNIITY</strong>
|
||||
<small>Ticketdesk</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-tabs">
|
||||
<button className={mode === "LOGIN" ? "active" : ""} onClick={() => switchMode("LOGIN")}>Anmelden</button>
|
||||
<button className={mode === "REGISTER" ? "active" : ""} onClick={() => switchMode("REGISTER")} disabled={ api.registerDisabled() }>Registrieren</button>
|
||||
</div>
|
||||
<div className="login-heading">
|
||||
<span>
|
||||
{mode === "LOGIN" ? <LockKeyhole size={18} /> : <UserPlus size={18} />}
|
||||
</span>
|
||||
<h1>{mode === "LOGIN" ? "Willkommen zurück" : "Konto erstellen"}</h1>
|
||||
<p>{mode === "LOGIN" ? "Melde dich an, um deine Tickets zu verwalten." : "Neue Konten starten mit der Rolle Benutzer."}</p>
|
||||
</div>
|
||||
<form onSubmit={mode === "LOGIN" ? onLogin : onRegister}>
|
||||
{mode === "REGISTER" && <label>Name<input name="name" autoComplete="name" required maxLength="120" placeholder="Vor- und Nachname" /></label>}
|
||||
<label>Benutzername<input name="username" autoComplete="username" required placeholder="Benutzername" /></label>
|
||||
<label>Passwort<input name="password" type="password" autoComplete={mode === "LOGIN" ? "current-password" : "new-password"} required minLength={mode === "LOGIN" ? undefined : 8} placeholder="••••••••" /></label>
|
||||
{mode === "REGISTER" && <label>Passwort bestätigen<input name="passwordConfirmation" type="password" autoComplete="new-password" required minLength="8" placeholder="••••••••" /></label>}
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button className="button primary" disabled={loading}>{loading ? "Bitte warten …" : mode === "LOGIN" ? "Anmelden" : "Konto erstellen"}</button>
|
||||
</form>
|
||||
</section></main>;
|
||||
}
|
||||
|
||||
function UserManagement({ users, currentUser, loading, error, onRoleChange }) {
|
||||
return <div className="content"><section className="users-card"><div className="users-heading"><div><h2>Benutzer und Berechtigungen</h2><p>Registrierte Konten verwalten und Rollen vergeben.</p></div><span>{users.length} Konten</span></div>{error && <div className="error">{error}</div>}{loading ? <div className="empty">Benutzer werden geladen …</div> : <div className="users-table"><table><thead><tr><th>Name</th><th>Benutzername</th><th>Berechtigung</th></tr></thead><tbody>{users.map((account) => <tr key={account.id}><td><div className="account-name"><span>{account.name.slice(0, 1)}</span><strong>{account.name}{account.id === currentUser.id && <small>Du</small>}</strong></div></td><td>{account.username}</td><td><select value={account.role} disabled={account.id === currentUser.id} onChange={(event) => onRoleChange(account, event.target.value)} aria-label={`Rolle für ${account.name}`}><option value="USER">Benutzer</option><option value="SUPPORT">Support</option><option value="ADMIN">Administrator</option></select></td></tr>)}</tbody></table></div>}</section></div>;
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon: Icon, tone }) {
|
||||
return <article className="stat"><span className={`stat-icon ${tone}`}><Icon size={20} /></span><div><strong>{value}</strong><small>{label}</small></div></article>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
let csrfToken;
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(path, { credentials: "include", ...options });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(body.detail || body.message || (response.status === 401 ? "Bitte melde dich an." : "Die Anfrage ist fehlgeschlagen."));
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function csrf() {
|
||||
if (!csrfToken) csrfToken = (await request("/api/auth/csrf")).token;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
async function mutate(path, method, body) {
|
||||
const token = await csrf();
|
||||
return request(path, { method, headers: { ...jsonHeaders, "X-XSRF-TOKEN": token }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request("/api/auth/me"),
|
||||
login: async (credentials) => {
|
||||
const user = await mutate("/api/auth/login", "POST", credentials);
|
||||
csrfToken = undefined;
|
||||
return user;
|
||||
},
|
||||
registerDisabled: async () => {return await mutate("api/auth/register", "GET")},
|
||||
register: async (account) => {
|
||||
const user = await mutate("/api/auth/register", "POST", account);
|
||||
csrfToken = undefined;
|
||||
return user;
|
||||
},
|
||||
logout: async () => {
|
||||
const result = await mutate("/api/auth/logout", "POST");
|
||||
csrfToken = undefined;
|
||||
return result;
|
||||
},
|
||||
list: (scope) => request(`/api/tickets?scope=${scope}`),
|
||||
get: (id) => request(`/api/tickets/${id}`),
|
||||
create: (ticket) => mutate("/api/tickets", "POST", ticket),
|
||||
update: (id, ticket) => mutate(`/api/tickets/${id}`, "PATCH", ticket),
|
||||
comment: (id, comment) => mutate(`/api/tickets/${id}/comments`, "POST", comment),
|
||||
users: () => request("/api/users"),
|
||||
updateUserRole: (id, role) => mutate(`/api/users/${id}/role`, "PATCH", { role }),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(<StrictMode><App /></StrictMode>);
|
||||
@@ -0,0 +1,26 @@
|
||||
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162033; background: #f4f7fb; font-synthesis: none; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 248px 1fr; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 22px 18px; color: white; background: #17213a; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; padding: 8px; }
|
||||
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; color: #14213d; background: #7ea0ff; border-radius: 12px; box-shadow: 0 8px 24px rgba(89,125,238,.35); }
|
||||
.brand strong { display: block; font-size: 19px; }.brand small { display: block; margin-top: 2px; color: #98a2b3; font-size: 12px; }
|
||||
nav { margin-top: 36px; display: grid; gap: 8px; } nav button { display: flex; align-items: center; gap: 11px; padding: 12px 14px; color: #cbd5e1; border: 0; border-radius: 12px; background: transparent; text-align: left; } nav button:hover { background: rgba(255,255,255,.05); } nav .nav-active { color: white; font-weight: 600; background: #2a395f; } nav button span { margin-left: auto; padding: 2px 8px; border-radius: 6px; background: rgba(255,255,255,.09); font-size: 12px; }
|
||||
.support-card { margin-top: auto; padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.04); }.support-card svg { color: #90adff; vertical-align: middle; margin-right: 8px; }.support-card strong { font-size: 14px; }.support-card p { margin: 10px 0 0; color: #98a2b3; font-size: 12px; line-height: 1.65; }
|
||||
main { min-width: 0; }.topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 14px 38px; border-bottom: 1px solid #dfe5ee; background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }.topbar small { color: #667085; }.topbar h1 { margin: 3px 0 0; font-size: 21px; }.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; border: 0; border-radius: 11px; font-weight: 600; }.button.primary { color: white; background: #2858d8; box-shadow: 0 5px 14px rgba(40,88,216,.2); }.button.primary:hover { background: #214fc8; }.button.secondary { color: #344054; border: 1px solid #d0d5dd; background: white; }.button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.content { max-width: 1500px; margin: 0 auto; padding: 34px 38px; }.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 13px; margin-bottom: 26px; }.stat { display: flex; align-items: center; gap: 13px; padding: 19px; border: 1px solid #dfe5ee; border-radius: 16px; background: white; box-shadow: 0 4px 18px rgba(29,45,75,.04); }.stat-icon { width: 41px; height: 41px; display: grid; place-items: center; border-radius: 12px; }.stat-icon.blue { color:#1d4ed8;background:#eff6ff}.stat-icon.violet{color:#6d28d9;background:#f5f3ff}.stat-icon.amber{color:#b45309;background:#fffbeb}.stat-icon.green{color:#047857;background:#ecfdf5}.stat strong { display:block;font-size:24px;line-height:1.1}.stat small{display:block;margin-top:4px;color:#667085;font-size:13px}
|
||||
.tickets-card { overflow: hidden; border: 1px solid #dfe5ee; border-radius: 17px; background: white; box-shadow: 0 10px 35px rgba(29,45,75,.06); }.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 19px 20px; border-bottom: 1px solid #dfe5ee; }.toolbar h2{margin:0;font-size:18px}.toolbar p{margin:4px 0 0;color:#667085;font-size:13px}.filters{display:flex;gap:8px}.search{position:relative}.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#667085}.search input{width:275px;padding-left:36px}input,select,textarea{min-height:38px;padding:8px 11px;color:#162033;border:1px solid #d8e0eb;border-radius:9px;background:white;outline:none}input:focus,select:focus,textarea:focus{border-color:#6688ec;box-shadow:0 0 0 3px rgba(93,127,240,.14)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}th{padding:12px 13px;color:#475467;background:#f8fafc;text-align:left;font-size:12px;font-weight:600}td{padding:15px 13px;border-top:1px solid #edf0f4;white-space:nowrap}tbody tr{cursor:pointer;transition:.15s}tbody tr:hover{background:#f8faff}.ticket-title{display:flex;align-items:flex-start;gap:13px;max-width:410px}.ticket-title div{min-width:0}.ticket-title strong,.ticket-title small{display:block;overflow:hidden;text-overflow:ellipsis}.ticket-title strong{font-size:13px}.ticket-title small{margin-top:5px;color:#667085;font-size:11px}code{color:#667085;font-family:ui-monospace,monospace;font-size:12px;font-weight:700}.muted{color:#667085}.right{text-align:right}.badge{display:inline-flex;padding:5px 9px;border:1px solid;border-radius:999px;font-size:11px;font-weight:600}.badge-open{color:#1d4ed8;border-color:#bfdbfe;background:#eff6ff}.badge-in_progress{color:#6d28d9;border-color:#ddd6fe;background:#f5f3ff}.badge-waiting{color:#92400e;border-color:#fde68a;background:#fffbeb}.badge-resolved{color:#047857;border-color:#a7f3d0;background:#ecfdf5}.badge-closed{color:#475569;border-color:#e2e8f0;background:#f1f5f9}.priority{display:inline-flex;align-items:center;gap:7px}.dot{width:8px;height:8px;border-radius:50%}.dot-low{background:#94a3b8}.dot-medium{background:#3b82f6}.dot-high{background:#f97316}.dot-urgent{background:#e11d48}.empty{min-height:260px;display:grid;place-content:center;justify-items:center;gap:9px;color:#667085;font-size:14px}.empty svg{color:#cbd5e1}.empty strong{color:#344054}.error{margin:18px;padding:13px 15px;color:#b42318;border-radius:10px;background:#fef3f2}.mobile-list{display:none}
|
||||
.create-dialog{width:min(580px,calc(100% - 28px));padding:0;border:0;border-radius:18px;box-shadow:0 28px 80px rgba(16,24,40,.25)}.create-dialog::backdrop{background:rgba(15,23,42,.55);backdrop-filter:blur(2px)}.create-form{position:relative;display:grid;gap:16px;padding:26px}.create-form h2{margin:0;font-size:21px}.create-form>p{margin:-9px 0 2px;color:#667085;font-size:13px;line-height:1.5}.create-form label,.edit-box label{display:grid;gap:7px;font-size:13px;font-weight:600}.create-form textarea{min-height:110px;resize:vertical}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}.dialog-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:5px}.icon-close{position:absolute;top:18px;right:18px;width:34px;height:34px;display:grid;place-items:center;color:#667085;border:0;border-radius:9px;background:transparent}.icon-close:hover{background:#f2f4f7}
|
||||
.scrim{position:fixed;inset:0;z-index:20;border:0;background:rgba(15,23,42,.48);cursor:default}.detail-panel{position:fixed;z-index:21;top:0;right:0;width:min(590px,100%);height:100vh;overflow-y:auto;color:#162033;background:white;box-shadow:-18px 0 50px rgba(16,24,40,.15)}.detail-panel>header{padding:27px 52px 22px 26px;border-bottom:1px solid #dfe5ee}.detail-panel>header>div{display:flex;align-items:center;gap:10px}.detail-panel h2{margin:13px 0 8px;font-size:21px;line-height:1.35}.detail-panel header p{margin:0;color:#667085;font-size:13px}.detail-content{display:grid;gap:26px;padding:25px}.detail-content h3{margin:0 0 10px;color:#667085;font-size:12px;text-transform:uppercase;letter-spacing:.06em}.detail-content section>p{margin:0;color:#475467;font-size:14px;line-height:1.75}.edit-box{display:grid;grid-template-columns:1fr 1fr;gap:15px;padding:16px;border-radius:15px;background:#f8fafc}.edit-box .full{grid-column:1/-1}.comments-title{display:flex;align-items:center;gap:7px;color:#162033!important;font-size:14px!important;text-transform:none!important;letter-spacing:0!important}.comments-title svg{color:#2858d8}.comments-title span{color:#667085;font-weight:400}.comment-form{display:flex;align-items:flex-end;gap:8px;margin-bottom:17px}.comment-form textarea{flex:1;min-height:78px;resize:vertical}.comment-form .button{width:42px;padding:0}.comments{display:grid;gap:10px}.comments article{padding:14px;border:1px solid #dfe5ee;border-radius:12px}.comments article>div{display:flex;justify-content:space-between;gap:10px}.comments strong{font-size:13px}.comments time{color:#667085;font-size:11px}.comments article p{margin:8px 0 0;color:#475467;font-size:13px;line-height:1.6}.no-comments{padding:14px;border:1px dashed #d0d5dd!important;border-radius:11px;color:#667085!important;font-size:13px!important}
|
||||
@media(max-width:1100px){.stats{grid-template-columns:repeat(2,1fr)}.toolbar{align-items:flex-start;flex-direction:column}.filters{width:100%}.search{flex:1}.search input{width:100%}}
|
||||
@media(max-width:780px){.app-shell{display:block}.sidebar{display:none}.topbar{padding:14px 18px}.topbar small{display:none}.content{padding:22px 14px}.stats{gap:9px}.stat{padding:14px}.stat-icon{width:37px;height:37px}.stat strong{font-size:20px}.toolbar{padding:16px}.filters{display:grid;grid-template-columns:1fr 1fr}.search{grid-column:1/-1}.desktop-table{display:none}.mobile-list{display:block}.mobile-list>button{width:100%;display:grid;gap:9px;padding:15px;color:#162033;border:0;border-top:1px solid #edf0f4;background:white;text-align:left}.mobile-list>button>span,.mobile-list>button>small{display:flex;align-items:center;justify-content:space-between;gap:10px}.mobile-list strong{font-size:14px}.mobile-list small{color:#667085}.form-grid{grid-template-columns:1fr}.detail-content{padding:20px}.edit-box{grid-template-columns:1fr}.edit-box .full{grid-column:auto}}
|
||||
|
||||
.topbar-actions{display:flex;align-items:center;gap:10px}.user-chip{display:flex;align-items:center;gap:9px;padding-right:4px}.user-chip>span{width:34px;height:34px;display:grid;place-items:center;color:#214fc8;border-radius:50%;background:#eaf0ff;font-weight:700}.user-chip strong,.user-chip small{display:block}.user-chip strong{max-width:150px;overflow:hidden;text-overflow:ellipsis;font-size:12px;white-space:nowrap}.user-chip small{margin-top:2px;font-size:11px}.logout-button{width:38px;height:38px;display:grid;place-items:center;color:#667085;border:1px solid #d8e0eb;border-radius:10px;background:white}.logout-button:hover{color:#b42318;background:#fef3f2}.form-grid .full{grid-column:1/-1}
|
||||
.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 15% 10%,#dce7ff 0,transparent 32%),#f4f7fb}.login-card{width:min(440px,100%);padding:32px;border:1px solid #dfe5ee;border-radius:22px;background:white;box-shadow:0 24px 70px rgba(29,45,75,.13)}.login-brand{display:flex;align-items:center;gap:11px}.login-brand>div strong,.login-brand>div small{display:block}.login-brand>div strong{font-size:19px}.login-brand>div small{margin-top:2px;color:#667085;font-size:12px}.login-heading{margin:34px 0 24px}.login-heading>span{width:38px;height:38px;display:grid;place-items:center;color:#2858d8;border-radius:11px;background:#eaf0ff}.login-heading h1{margin:15px 0 7px;font-size:25px}.login-heading p{margin:0;color:#667085;font-size:14px}.login-card form{display:grid;gap:15px}.login-card form label{display:grid;gap:7px;font-size:13px;font-weight:600}.login-card form .button{width:100%;margin-top:4px}.login-error{padding:11px 12px;color:#b42318;border-radius:9px;background:#fef3f2;font-size:13px}.demo-users{display:grid;gap:9px;margin-top:24px;padding-top:20px;border-top:1px solid #edf0f4}.demo-users>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;background:#f8fafc}.demo-users svg{color:#667085}.demo-users p{margin:0}.demo-users strong,.demo-users code{display:block}.demo-users strong{font-size:12px}.demo-users code{margin-top:3px;color:#667085;font-size:11px}.login-loading{color:#667085;font-size:14px}
|
||||
.auth-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:28px;padding:4px;border-radius:11px;background:#f2f4f7}.auth-tabs button{min-height:36px;color:#667085;border:0;border-radius:8px;background:transparent;font-size:13px;font-weight:600}.auth-tabs button.active{color:#2349ad;background:white;box-shadow:0 1px 4px rgba(16,24,40,.1)}.auth-tabs+.login-heading{margin-top:24px}
|
||||
.users-card{overflow:hidden;border:1px solid #dfe5ee;border-radius:17px;background:white;box-shadow:0 10px 35px rgba(29,45,75,.06)}.users-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:21px;border-bottom:1px solid #dfe5ee}.users-heading h2{margin:0;font-size:18px}.users-heading p{margin:5px 0 0;color:#667085;font-size:13px}.users-heading>span{padding:6px 10px;color:#2349ad;border-radius:999px;background:#eaf0ff;font-size:12px;font-weight:700}.users-table select{min-width:170px}.account-name{display:flex;align-items:center;gap:11px}.account-name>span{width:34px;height:34px;display:grid;place-items:center;color:#214fc8;border-radius:50%;background:#eaf0ff;font-weight:700}.account-name strong{display:flex;align-items:center;gap:8px}.account-name small{padding:3px 6px;color:#667085;border-radius:6px;background:#f2f4f7;font-size:10px}.users-table tr{cursor:default}.users-table tbody tr:hover{background:#f8faff}
|
||||
@media(max-width:780px){.user-chip{display:none}.topbar-actions{gap:7px}.topbar .button{padding:0 11px}.form-grid .full{grid-column:auto}.login-card{padding:24px}.users-heading{align-items:flex-start}.users-table{overflow-x:auto}.users-table table{min-width:620px}}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user