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)"
This commit is contained in:
Domonkos
2026-01-21 13:35:20 +01:00
parent 0c7d525e4a
commit bf92b7a5e1
9 changed files with 328 additions and 99 deletions

View File

@@ -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 Ziel
@@ -13,36 +13,48 @@ Bereits implementiert:
- Products - Products
- SKUs (Varianten) - SKUs (Varianten)
- InventoryMovements (Bestandsbuchungen) - InventoryMovements (Bestandsbuchungen)
- Current Stock pro SKU - Current Stock pro SKU (Aggregation)
- Inventory Overview Endpoint (aggregierte Bestandsübersicht) - Inventory Overview Endpoint (GET /api/inventory/overview)
- Workspace UI (Next.js, minimal)
- Login-System (Spring Security, Form Login, Session) - Login-System (Spring Security, Form Login, Session)
- /api/** ist geschützt (nur eingeloggt) - /api/** ist geschützt (nur eingeloggt)
- Login per Browser (/login) und per curl (Cookie/JSESSIONID) funktioniert - 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. Damit ist das Domain-Fundament + Zugriffsschutz + UI-taugliche API abgeschlossen.
Wichtiger Hinweis zur Datenbank (AKTUELLER ZUSTAND) 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:... Aktueller Stand:
- Die Datenbank liegt ausschließlich im RAM - H2 File-basierte Datenbank (persistent)
- BEI JEDEM APP-RESTART WERDEN ALLE DATEN GELÖSCHT - JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE
- Daten liegen auf der Festplatte (data/voyage-db.mv.db)
Dieser Zustand ist bewusst gewählt und sinnvoll für: - KEIN Datenverlust bei App-Restart
- frühe Entwicklung
- schnelles Testen
- Debugging
- Fokus auf Domain-Logik statt Persistenz
Wichtig: Wichtig:
- Products, SKUs und InventoryMovements müssen nach jedem Neustart neu angelegt werden - Die DB überlebt Neustarts
- IDs (productId, skuId) ändern sich nach jedem Restart - IDs (productId, skuId, userId) bleiben stabil
- AUTO_SERVER erlaubt parallelen Zugriff (App + H2 Console)
Geplanter späterer Schritt: SEHR WICHTIG:
- Umstellung auf persistente DB (z. B. H2 file, Postgres) - Das H2 Passwort wird beim ERSTEN Start festgelegt
- Einführung von Migrations (Flyway) - 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 Grundprinzip
@@ -58,13 +70,14 @@ Empfohlene Architektur
---------------------- ----------------------
1) workspace-api (Spring Boot) 1) workspace-api (Spring Boot)
- Products / SKUs / Inventory (besteht) - Products / SKUs / Inventory
- Aggregierte Inventory-Übersicht (besteht) - Inventory Overview (aggregiert)
- Auth (besteht) - Auth & Rollen
- persistente DB
- später Orders / Drops - später Orders / Drops
2) workspace-ui (Webinterface) 2) workspace-ui (Webinterface)
- internes Dashboard - internes Dashboard (Next.js)
- spricht mit workspace-api - spricht mit workspace-api
- nutzt Session-Cookies (Browser) - nutzt Session-Cookies (Browser)
@@ -100,10 +113,17 @@ Implementiert:
- Spring Security - Spring Security
- Form Login - Form Login
- Session-basiert (kein JWT) - Session-basiert (kein JWT)
- Logout mit Session-Invalidierung
- Redirect zurück ins Workspace UI
Offene Endpoints: User-Modell:
- /health (öffentlich) - UserAccount in DB
- /h2-console (nur Development) - Rollen: ADMIN / WORKER
- active Flag (true / false)
Inactive User:
- kann sich NICHT einloggen
- bleibt für Historie erhalten
SCHRITT 2: Workspace-taugliche Endpoints SCHRITT 2: Workspace-taugliche Endpoints
@@ -121,63 +141,97 @@ Liefert pro SKU:
- Aktueller Bestand (SUM der InventoryMovements) - Aktueller Bestand (SUM der InventoryMovements)
Bedeutung: Bedeutung:
- Ein einzelner Request ist ausreichend für das gesamte Inventory-Dashboard - Ein Request = komplette Inventory Tabelle
- Backend liefert UI-fertige Daten (keine Aggregation im Frontend nötig) - Keine Aggregation im Frontend nötig
- Grundlage für UI, Low-Stock, Drops
Grundlage für:
- Inventory Tabelle
- Low-Stock Checks
- Drop-Planung
- Workspace UI
SCHRITT 3: Workspace UI (minimal starten) SCHRITT 3: Workspace UI (minimal starten)
----------------------------------------- -----------------------------------------
Status: ALS NÄCHSTES Status: ERLEDIGT (MINIMAL)
Nicht zu groß denken. Start mit EINER Seite:
Implementiert:
- Inventory Übersicht - Inventory Übersicht
- Tabelle mit allen SKUs + currentStock - Tabelle mit allen SKUs + currentStock
- Suche & Filter (Name, SKU, Kategorie) - Suche & Filter (Name, SKU, Kategorie)
- Button für Inventory Movements (+ / -) - Inventory Movements (+ / -)
- SALE erzeugt negatives Delta
- PRODUCTION positives Delta
Ergebnis: 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 ✅ Status: PARALLEL MÖGLICH ✅
Empfohlener Start: Empfohlener Start:
- Eigenes Frontend (public-web) - Eigenes Frontend (public-web)
- Content als Markdown-Dateien im Repo - Content als Markdown im Repo
- Git als CMS - Git als CMS
Vorteile: Vorteile:
- Kein Admin-UI nötig - Kein Admin-UI nötig
- Keine Auth - Keine Auth
- Sehr schneller Content-Workflow - Sehr schneller Workflow
Warum diese Reihenfolge sinnvoll ist Warum diese Reihenfolge sinnvoll ist
------------------------------------ ------------------------------------
- Workspace erzeugt operativen Wert - Workspace erzeugt operativen Wert
- Login schützt eure Arbeit - Login schützt eure Arbeit
- Inventory Overview macht das System direkt benutzbar - Inventory Overview macht das System sofort nutzbar
- UI kann ohne weitere Backend-Arbeit gebaut werden - UI basiert auf stabiler API
- Blog kann unabhängig wachsen - Blog kann unabhängig wachsen
- Keine unnötige Komplexität zu früh
Langfristige Erweiterungen Langfristige Erweiterungen
-------------------------- --------------------------
- Orders → automatische InventoryMovements - Orders → automatische SALE Movements
- Drops → Production → initialer Bestand - Drops → Production → initialer Bestand
- Automations (Low-Stock Alerts) - Low-Stock Alerts
- Rollen (Admin / Viewer) - Rollenfeingranularität
- Persistente Datenbank + Migrations - Flyway Migrations
- Postgres als DB
- Public Shop (optional) - Public Shop (optional)
@@ -187,14 +241,34 @@ Merksätze
- Login früh einbauen (erledigt) - Login früh einbauen (erledigt)
- Inventory niemals direkt ändern - Inventory niemals direkt ändern
- SKUs sind die operative Wahrheit - 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 Nächster konkreter Schritt
-------------------------- --------------------------
Start von: - Admin UI für User + Active/Inactive
- workspace-ui (Inventory Tabelle auf Basis von /api/inventory/overview) - Deactivate-Flow für Products & SKUs
- Orders mit automatischen InventoryMovements
Danach: Curls
- Orders + automatische Inventory-Abbuchung -----------------
- Umstellung auf persistente DB, sobald Stabilität erreicht ist 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
}'

View File

@@ -2,4 +2,4 @@
# https://curl.se/docs/http-cookies.html # https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk. # 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

View File

@@ -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");
}
}

View File

@@ -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);
});
}
}

View File

@@ -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
);
}
}

View File

@@ -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; }
}

View File

@@ -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<UserAccount, Long> {
Optional<UserAccount> findByUsername(String username);
}

View File

@@ -2,15 +2,9 @@ package com.voyage.workspace.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
@Configuration @Configuration
@@ -19,35 +13,42 @@ public class SecurityConfig {
@Bean @Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http.authorizeHttpRequests(auth -> auth
// Autorisierung .requestMatchers("/health", "/error").permitAll()
.authorizeHttpRequests(auth -> auth .requestMatchers("/h2-console/**").permitAll()
// offen
.requestMatchers("/health", "/error").permitAll()
// H2-Console nur DEV (siehe extra Bean unten), hier erstmal erlaubt, // Admin-only user management
// wird durch Profile gesteuert .requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/h2-console/**").permitAll()
// alles unter /api nur eingeloggt // Everything else under /api requires login
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
// sonst auch nur eingeloggt (Workspace nicht public) // Workspace should not be public
.anyRequest().authenticated() .anyRequest().authenticated()
) );
// Form Login (Session) http.formLogin(form -> form
.formLogin(Customizer.withDefaults()) .defaultSuccessUrl("http://localhost:3000/", true)
);
// Logout ok http.logout(logout -> logout
.logout(Customizer.withDefaults()); .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())); http.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
// CSRF: Für H2 Console + einfache curl Tests disable (für internes Tool OK zum Start). // Dev/testing convenience: allow curl + UI without CSRF tokens
// Später kann man das feiner machen (nur für /api/** tokenbasiert etc.) http.csrf(csrf -> csrf.ignoringRequestMatchers(
http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**", "/login", "/api/**")); "/h2-console/**",
"/login",
"/logout",
"/api/**"
));
return http.build(); return http.build();
} }
@@ -55,18 +56,4 @@ public class SecurityConfig {
PasswordEncoder passwordEncoder() { PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); 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);
}
} }

View File

@@ -1,6 +1,12 @@
spring.application.name=workspace-api spring.application.name=workspace-api
spring.profiles.active=dev
spring.h2.console.enabled=true spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:voyage;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=update # PERSISTENT (file) DB instead of mem
spring.profiles.active=dev 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