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

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

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

View File

@@ -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
# 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