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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user