From bf92b7a5e1b35d07dfb1e4fb912da101cd90cda6 Mon Sep 17 00:00:00 2001 From: Domonkos <162434141+domonkosszer@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:35:20 +0100 Subject: [PATCH] git add . git commit -m "feat(workspace): inventory overview, auth, persistent h2 db, users & roles - add inventory overview endpoint (aggregated stock per SKU) - implement inventory movements (production, sale, return, adjustment) - add session-based login with Spring Security (form login) - protect /api/** endpoints - add logout with session invalidation - introduce user roles (ADMIN, WORKER) and active flag - seed admin user in dev profile - switch from H2 in-memory to persistent H2 file database - enable H2 console for development - add Next.js workspace UI (inventory table, filters, movements) - support active/inactive products and users (soft disable)" --- apps/workspace-api/ToDo's | 182 ++++++++++++------ .../src/main/java/com/voyage/cookies.txt | 2 +- .../workspace/auth/AdminUsersController.java | 45 +++++ .../com/voyage/workspace/auth/AuthSeed.java | 31 +++ .../workspace/auth/DbUserDetailsService.java | 36 ++++ .../voyage/workspace/auth/UserAccount.java | 41 ++++ .../workspace/auth/UserAccountRepository.java | 9 + .../workspace/config/SecurityConfig.java | 69 +++---- .../src/main/resources/application.properties | 12 +- 9 files changed, 328 insertions(+), 99 deletions(-) create mode 100644 apps/workspace-api/src/main/java/com/voyage/workspace/auth/AdminUsersController.java create mode 100644 apps/workspace-api/src/main/java/com/voyage/workspace/auth/AuthSeed.java create mode 100644 apps/workspace-api/src/main/java/com/voyage/workspace/auth/DbUserDetailsService.java create mode 100644 apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccount.java create mode 100644 apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccountRepository.java diff --git a/apps/workspace-api/ToDo's b/apps/workspace-api/ToDo's index 121840b..db34ce2 100644 --- a/apps/workspace-api/ToDo's +++ b/apps/workspace-api/ToDo's @@ -1,4 +1,4 @@ -VOYAGE – Nächste Projektschritte (Workspace + Login + Webblog) – UPDATE 2026-01-20 +VOYAGE – Nächste Projektschritte (Workspace + Login + Webblog) – UPDATE 2026-01-21 =============================================================================== Ziel @@ -13,36 +13,48 @@ Bereits implementiert: - Products - SKUs (Varianten) - InventoryMovements (Bestandsbuchungen) -- Current Stock pro SKU -- Inventory Overview Endpoint (aggregierte Bestandsübersicht) +- Current Stock pro SKU (Aggregation) +- Inventory Overview Endpoint (GET /api/inventory/overview) +- Workspace UI (Next.js, minimal) - Login-System (Spring Security, Form Login, Session) - /api/** ist geschützt (nur eingeloggt) - Login per Browser (/login) und per curl (Cookie/JSESSIONID) funktioniert + - Logout invalidiert Session + löscht JSESSIONID Damit ist das Domain-Fundament + Zugriffsschutz + UI-taugliche API abgeschlossen. Wichtiger Hinweis zur Datenbank (AKTUELLER ZUSTAND) --------------------------------------------------- -Die Anwendung verwendet derzeit eine H2 In-Memory Datenbank: +Die Anwendung verwendet NICHT MEHR eine In-Memory Datenbank. -- JDBC URL: jdbc:h2:mem:... -- Die Datenbank liegt ausschließlich im RAM -- BEI JEDEM APP-RESTART WERDEN ALLE DATEN GELÖSCHT - -Dieser Zustand ist bewusst gewählt und sinnvoll für: -- frühe Entwicklung -- schnelles Testen -- Debugging -- Fokus auf Domain-Logik statt Persistenz +Aktueller Stand: +- H2 File-basierte Datenbank (persistent) +- JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE +- Daten liegen auf der Festplatte (data/voyage-db.mv.db) +- KEIN Datenverlust bei App-Restart Wichtig: -- Products, SKUs und InventoryMovements müssen nach jedem Neustart neu angelegt werden -- IDs (productId, skuId) ändern sich nach jedem Restart +- Die DB überlebt Neustarts +- IDs (productId, skuId, userId) bleiben stabil +- AUTO_SERVER erlaubt parallelen Zugriff (App + H2 Console) -Geplanter späterer Schritt: -- Umstellung auf persistente DB (z. B. H2 file, Postgres) -- Einführung von Migrations (Flyway) +SEHR WICHTIG: +- Das H2 Passwort wird beim ERSTEN Start festgelegt +- Danach MUSS es gleich bleiben +- Falsches Passwort → App startet NICHT (Error 28000) + +Empfohlene Config: +- Username: sa +- Password: leer (oder exakt das beim ersten Start gesetzte) + +H2 Console: +- URL: http://localhost:8080/h2-console +- JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE +- User: sa +- Password: (leer oder wie initial gesetzt) + +H2 Login ≠ Workspace Login (komplett getrennt) Grundprinzip @@ -58,13 +70,14 @@ Empfohlene Architektur ---------------------- 1) workspace-api (Spring Boot) - - Products / SKUs / Inventory (besteht) - - Aggregierte Inventory-Übersicht (besteht) - - Auth (besteht) + - Products / SKUs / Inventory + - Inventory Overview (aggregiert) + - Auth & Rollen + - persistente DB - später Orders / Drops 2) workspace-ui (Webinterface) - - internes Dashboard + - internes Dashboard (Next.js) - spricht mit workspace-api - nutzt Session-Cookies (Browser) @@ -100,10 +113,17 @@ Implementiert: - Spring Security - Form Login - Session-basiert (kein JWT) +- Logout mit Session-Invalidierung +- Redirect zurück ins Workspace UI -Offene Endpoints: -- /health (öffentlich) -- /h2-console (nur Development) +User-Modell: +- UserAccount in DB +- Rollen: ADMIN / WORKER +- active Flag (true / false) + +Inactive User: +- kann sich NICHT einloggen +- bleibt für Historie erhalten SCHRITT 2: Workspace-taugliche Endpoints @@ -121,63 +141,97 @@ Liefert pro SKU: - Aktueller Bestand (SUM der InventoryMovements) Bedeutung: -- Ein einzelner Request ist ausreichend für das gesamte Inventory-Dashboard -- Backend liefert UI-fertige Daten (keine Aggregation im Frontend nötig) - -Grundlage für: -- Inventory Tabelle -- Low-Stock Checks -- Drop-Planung -- Workspace UI +- Ein Request = komplette Inventory Tabelle +- Keine Aggregation im Frontend nötig +- Grundlage für UI, Low-Stock, Drops SCHRITT 3: Workspace UI (minimal starten) ----------------------------------------- -Status: ALS NÄCHSTES ✅ - -Nicht zu groß denken. Start mit EINER Seite: +Status: ERLEDIGT (MINIMAL) ✅ +Implementiert: - Inventory Übersicht - Tabelle mit allen SKUs + currentStock - Suche & Filter (Name, SKU, Kategorie) -- Button für Inventory Movements (+ / -) +- Inventory Movements (+ / -) +- SALE erzeugt negatives Delta +- PRODUCTION positives Delta Ergebnis: -Ein echtes internes Tool, das täglich nutzbar ist. +Ein echtes internes Tool, sofort nutzbar. -SCHRITT 4: Public Webblog (parallel, aber getrennt) +SCHRITT 4: Active / Inactive statt Löschen +------------------------------------------ +Status: IMPLEMENTIERT / EMPFOHLEN ✅ + +Grundregel: +- NIEMALS löschen, wenn Historie existiert + +Umsetzung: +- Products und SKUs haben active Flag +- active = false: + - wird im Workspace ausgeblendet + - bleibt in DB für Historie + +Beispiel: +- Caps werden nicht mehr verkauft +- Product/SKUs auf inactive setzen +- Inventory bleibt korrekt nachvollziehbar + + +SCHRITT 5: Worker-User +--------------------- +Status: IMPLEMENTIERT (Basis) ✅ + +User-Typen: +- ADMIN: volle Rechte, Userverwaltung +- WORKER: Inventory sehen & buchen + +Worker anlegen: +- per Admin Endpoint oder DB +- Passwort wird BCrypt-gehasht +- active = true notwendig + +Worker deaktivieren: +- active = false +- Login gesperrt +- Daten bleiben erhalten + + +SCHRITT 6: Public Webblog (parallel, aber getrennt) --------------------------------------------------- Status: PARALLEL MÖGLICH ✅ Empfohlener Start: - Eigenes Frontend (public-web) -- Content als Markdown-Dateien im Repo +- Content als Markdown im Repo - Git als CMS Vorteile: - Kein Admin-UI nötig - Keine Auth -- Sehr schneller Content-Workflow +- Sehr schneller Workflow Warum diese Reihenfolge sinnvoll ist ------------------------------------ - Workspace erzeugt operativen Wert - Login schützt eure Arbeit -- Inventory Overview macht das System direkt benutzbar -- UI kann ohne weitere Backend-Arbeit gebaut werden +- Inventory Overview macht das System sofort nutzbar +- UI basiert auf stabiler API - Blog kann unabhängig wachsen -- Keine unnötige Komplexität zu früh Langfristige Erweiterungen -------------------------- -- Orders → automatische InventoryMovements +- Orders → automatische SALE Movements - Drops → Production → initialer Bestand -- Automations (Low-Stock Alerts) -- Rollen (Admin / Viewer) -- Persistente Datenbank + Migrations +- Low-Stock Alerts +- Rollenfeingranularität +- Flyway Migrations +- Postgres als DB - Public Shop (optional) @@ -187,14 +241,34 @@ Merksätze - Login früh einbauen (erledigt) - Inventory niemals direkt ändern - SKUs sind die operative Wahrheit -- In-Memory DB = flüchtig, aber perfekt für den Start +- Löschen ist fast immer falsch → inactive ist richtig +- H2 Passwort niemals ändern ohne DB Reset Nächster konkreter Schritt -------------------------- -Start von: -- workspace-ui (Inventory Tabelle auf Basis von /api/inventory/overview) +- Admin UI für User + Active/Inactive +- Deactivate-Flow für Products & SKUs +- Orders mit automatischen InventoryMovements -Danach: -- Orders + automatische Inventory-Abbuchung -- Umstellung auf persistente DB, sobald Stabilität erreicht ist \ No newline at end of file +Curls +----------------- +curl -X POST http://localhost:8080/api/admin/users \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "username": "admin2", + "password": "admin123!", + "role": "ADMIN", + "active": true + }' + + curl -X POST http://localhost:8080/api/admin/users \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "username": "worker1", + "password": "worker123!", + "role": "WORKER", + "active": true + }' \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/cookies.txt b/apps/workspace-api/src/main/java/com/voyage/cookies.txt index 4581aad..9438d56 100644 --- a/apps/workspace-api/src/main/java/com/voyage/cookies.txt +++ b/apps/workspace-api/src/main/java/com/voyage/cookies.txt @@ -2,4 +2,4 @@ # https://curl.se/docs/http-cookies.html # This file was generated by libcurl! Edit at your own risk. -#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID 1D763BECEF84ECD1D335BF07E96D3691 +#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID 1464F869FB095717F548FC25F22453F1 diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AdminUsersController.java b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AdminUsersController.java new file mode 100644 index 0000000..1f956f4 --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AdminUsersController.java @@ -0,0 +1,45 @@ +package com.voyage.workspace.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/users") +public class AdminUsersController { + + private final UserAccountRepository repo; + private final PasswordEncoder encoder; + + public AdminUsersController(UserAccountRepository repo, PasswordEncoder encoder) { + this.repo = repo; + this.encoder = encoder; + } + + public record CreateUserRequest(String username, String password, UserAccount.Role role) {} + + @PostMapping + public ResponseEntity create(@RequestBody CreateUserRequest req) { + if (req.username() == null || req.username().isBlank()) return ResponseEntity.badRequest().body("username required"); + if (req.password() == null || req.password().length() < 6) return ResponseEntity.badRequest().body("password min 6 chars"); + if (repo.findByUsername(req.username()).isPresent()) return ResponseEntity.badRequest().body("username exists"); + + UserAccount u = new UserAccount(); + u.setUsername(req.username()); + u.setPasswordHash(encoder.encode(req.password())); + u.setRole(req.role() == null ? UserAccount.Role.WORKER : req.role()); + u.setActive(true); + + repo.save(u); + return ResponseEntity.ok("created"); + } + + @PostMapping("/{id}/disable") + public ResponseEntity disable(@PathVariable Long id) { + var u = repo.findById(id).orElse(null); + if (u == null) return ResponseEntity.badRequest().body("unknown id"); + u.setActive(false); + repo.save(u); + return ResponseEntity.ok("disabled"); + } +} \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AuthSeed.java b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AuthSeed.java new file mode 100644 index 0000000..9bc6cbf --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/AuthSeed.java @@ -0,0 +1,31 @@ +package com.voyage.workspace.auth; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@Profile("dev") +public class AuthSeed implements CommandLineRunner { + + private final UserAccountRepository repo; + private final PasswordEncoder encoder; + + public AuthSeed(UserAccountRepository repo, PasswordEncoder encoder) { + this.repo = repo; + this.encoder = encoder; + } + + @Override + public void run(String... args) { + repo.findByUsername("admin").orElseGet(() -> { + UserAccount u = new UserAccount(); + u.setUsername("admin"); + u.setPasswordHash(encoder.encode("admin123!")); + u.setRole(UserAccount.Role.ADMIN); + u.setActive(true); + return repo.save(u); + }); + } +} diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/auth/DbUserDetailsService.java b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/DbUserDetailsService.java new file mode 100644 index 0000000..0aa85f3 --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/DbUserDetailsService.java @@ -0,0 +1,36 @@ +package com.voyage.workspace.auth; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.*; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class DbUserDetailsService implements UserDetailsService { + + private final UserAccountRepository repo; + + public DbUserDetailsService(UserAccountRepository repo) { + this.repo = repo; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UserAccount u = repo.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Unknown user: " + username)); + + if (!u.isActive()) { + throw new UsernameNotFoundException("User disabled: " + username); + } + + // Spring wants ROLE_ prefix for roles + var auth = List.of(new SimpleGrantedAuthority("ROLE_" + u.getRole().name())); + + return new org.springframework.security.core.userdetails.User( + u.getUsername(), + u.getPasswordHash(), + auth + ); + } +} \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccount.java b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccount.java new file mode 100644 index 0000000..722643f --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccount.java @@ -0,0 +1,41 @@ +package com.voyage.workspace.auth; + +import jakarta.persistence.*; + +@Entity +@Table(name = "user_account") +public class UserAccount { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String username; + + @Column(nullable = false, length = 120) + private String passwordHash; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role; + + @Column(nullable = false) + private boolean active = true; + + public enum Role { ADMIN, WORKER } + + // getters/setters + public Long getId() { return id; } + + 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 Role getRole() { return role; } + public void setRole(Role role) { this.role = role; } + + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } +} \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccountRepository.java b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccountRepository.java new file mode 100644 index 0000000..74da48c --- /dev/null +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/auth/UserAccountRepository.java @@ -0,0 +1,9 @@ +package com.voyage.workspace.auth; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserAccountRepository extends JpaRepository { + Optional findByUsername(String username); +} \ No newline at end of file diff --git a/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java b/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java index 98dafd3..dc4cbda 100644 --- a/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java +++ b/apps/workspace-api/src/main/java/com/voyage/workspace/config/SecurityConfig.java @@ -2,15 +2,9 @@ package com.voyage.workspace.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -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.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -19,35 +13,42 @@ public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - // Autorisierung - .authorizeHttpRequests(auth -> auth - // offen - .requestMatchers("/health", "/error").permitAll() + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/health", "/error").permitAll() + .requestMatchers("/h2-console/**").permitAll() - // H2-Console nur DEV (siehe extra Bean unten), hier erstmal erlaubt, - // wird durch Profile gesteuert - .requestMatchers("/h2-console/**").permitAll() + // Admin-only user management + .requestMatchers("/api/admin/**").hasRole("ADMIN") - // alles unter /api nur eingeloggt - .requestMatchers("/api/**").authenticated() + // Everything else under /api requires login + .requestMatchers("/api/**").authenticated() - // sonst auch nur eingeloggt (Workspace nicht public) - .anyRequest().authenticated() - ) + // Workspace should not be public + .anyRequest().authenticated() + ); - // Form Login (Session) - .formLogin(Customizer.withDefaults()) + http.formLogin(form -> form + .defaultSuccessUrl("http://localhost:3000/", true) + ); - // Logout ok - .logout(Customizer.withDefaults()); + http.logout(logout -> logout + .logoutUrl("/logout") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") + .logoutSuccessUrl("http://localhost:3000/login") + .permitAll() + ); - // Damit H2 Console im Browser funktioniert: http.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); - // CSRF: Für H2 Console + einfache curl Tests disable (für internes Tool OK zum Start). - // Später kann man das feiner machen (nur für /api/** tokenbasiert etc.) - http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**", "/login", "/api/**")); + // Dev/testing convenience: allow curl + UI without CSRF tokens + http.csrf(csrf -> csrf.ignoringRequestMatchers( + "/h2-console/**", + "/login", + "/logout", + "/api/**" + )); + return http.build(); } @@ -55,18 +56,4 @@ public class SecurityConfig { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - - /** - * DEV-User: später ersetzt ihr das durch DB-User / Admin Tabelle. - */ - @Bean - @Profile("dev") - UserDetailsService devUsers(PasswordEncoder encoder) { - UserDetails admin = User.withUsername("admin") - .password(encoder.encode("admin123!")) - .roles("ADMIN") - .build(); - - return new InMemoryUserDetailsManager(admin); - } } \ No newline at end of file diff --git a/apps/workspace-api/src/main/resources/application.properties b/apps/workspace-api/src/main/resources/application.properties index 91deed2..a779ed7 100644 --- a/apps/workspace-api/src/main/resources/application.properties +++ b/apps/workspace-api/src/main/resources/application.properties @@ -1,6 +1,12 @@ spring.application.name=workspace-api +spring.profiles.active=dev spring.h2.console.enabled=true -spring.datasource.url=jdbc:h2:mem:voyage;DB_CLOSE_DELAY=-1 -spring.jpa.hibernate.ddl-auto=update -spring.profiles.active=dev \ No newline at end of file + +# PERSISTENT (file) DB instead of mem +spring.datasource.url=jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=admin + +spring.jpa.hibernate.ddl-auto=update \ No newline at end of file