Harden auth and improve user management

This commit is contained in:
2026-03-12 20:28:06 +01:00
parent 1a8e37bd36
commit eb699666d9
26 changed files with 1105 additions and 283 deletions

View File

@@ -37,6 +37,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId> <artifactId>spring-boot-starter-mail</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId> <artifactId>spring-security-crypto</artifactId>

View File

@@ -8,7 +8,8 @@ import java.time.LocalDateTime;
@Document("users") @Document("users")
public record AppUser( public record AppUser(
@Id String id, @Id String id,
String code, String accountId,
Boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
@@ -18,7 +19,6 @@ public record AppUser(
String city, String city,
String email, String email,
String phoneNumber, String phoneNumber,
String portalLogin,
String passwordHash, String passwordHash,
boolean active, boolean active,
UserRole role, UserRole role,

View File

@@ -9,9 +9,7 @@ import java.util.Optional;
public interface AppUserRepository extends MongoRepository<AppUser, String> { public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByActiveTrueOrderByDisplayNameAsc(); List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
Optional<AppUser> findByCodeIgnoreCase(String code); List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
Optional<AppUser> findByEmailIgnoreCase(String email); Optional<AppUser> findByEmailIgnoreCase(String email);
Optional<AppUser> findByPortalLoginIgnoreCase(String portalLogin);
} }

View File

@@ -0,0 +1,81 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.UserRole;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Base64;
@Service
public class AuthTokenService {
private final byte[] secret;
private final long validitySeconds;
public AuthTokenService(
@Value("${muh.security.token-secret}") String secret,
@Value("${muh.security.token-validity-hours:12}") long validityHours
) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.validitySeconds = Math.max(1, validityHours) * 3600L;
}
public String createToken(AppUser user) {
long expiresAt = Instant.now().plusSeconds(validitySeconds).getEpochSecond();
String payload = user.id() + "|" + user.role().name() + "|" + expiresAt;
return encode(payload) + "." + sign(payload);
}
public AuthenticatedUser parseToken(String token) {
String[] parts = token.split("\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("Token-Format ungueltig");
}
String payload = decode(parts[0]);
String expectedSignature = sign(payload);
if (!expectedSignature.equals(parts[1])) {
throw new IllegalArgumentException("Token-Signatur ungueltig");
}
String[] payloadParts = payload.split("\\|");
if (payloadParts.length != 3) {
throw new IllegalArgumentException("Token-Inhalt ungueltig");
}
long expiresAt = Long.parseLong(payloadParts[2]);
if (Instant.now().getEpochSecond() > expiresAt) {
throw new IllegalArgumentException("Token abgelaufen");
}
return new AuthenticatedUser(payloadParts[0], "", UserRole.valueOf(payloadParts[1]));
}
private String sign(String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return encode(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Token-Signatur konnte nicht erzeugt werden", exception);
}
}
private String encode(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
}
private String encode(String value) {
return encode(value.getBytes(StandardCharsets.UTF_8));
}
private String decode(String value) {
return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.UserRole;
public record AuthenticatedUser(String id, String displayName, UserRole role) {
}

View File

@@ -0,0 +1,58 @@
package de.svencarstensen.muh.security;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.repository.AppUserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class BearerTokenAuthenticationFilter extends OncePerRequestFilter {
private final AuthTokenService authTokenService;
private final AppUserRepository appUserRepository;
public BearerTokenAuthenticationFilter(AuthTokenService authTokenService, AppUserRepository appUserRepository) {
this.authTokenService = authTokenService;
this.appUserRepository = appUserRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null || !authorization.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
try {
AuthenticatedUser tokenUser = authTokenService.parseToken(authorization.substring(7));
AppUser user = appUserRepository.findById(tokenUser.id())
.filter(AppUser::active)
.orElseThrow(() -> new IllegalArgumentException("Benutzer ungueltig"));
AuthenticatedUser principal = new AuthenticatedUser(user.id(), user.displayName(), user.role());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.role().name()))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (RuntimeException exception) {
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,34 @@
package de.svencarstensen.muh.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/api/session/password-login", "/api/session/register").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(bearerTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.cors(Customizer.withDefaults());
return http.build();
}
}

View File

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
public class SecuritySupport {
public AuthenticatedUser currentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthenticatedUser principal)) {
throw new IllegalStateException("Kein authentifizierter Benutzer vorhanden");
}
return principal;
}
}

View File

@@ -13,8 +13,13 @@ import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.FarmerRepository; import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository; import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository; import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import de.svencarstensen.muh.security.AuthTokenService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -22,13 +27,11 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -61,6 +64,8 @@ public class CatalogService {
private final PathogenCatalogRepository pathogenRepository; private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository; private final AntibioticCatalogRepository antibioticRepository;
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService( public CatalogService(
@@ -68,13 +73,17 @@ public class CatalogService {
MedicationCatalogRepository medicationRepository, MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository, PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository, AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository AppUserRepository appUserRepository,
MongoTemplate mongoTemplate,
AuthTokenService authTokenService
) { ) {
this.farmerRepository = farmerRepository; this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository; this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository; this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository; this.antibioticRepository = antibioticRepository;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService;
} }
public ActiveCatalogSummary activeCatalogSummary() { public ActiveCatalogSummary activeCatalogSummary() {
@@ -83,7 +92,7 @@ public class CatalogService {
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(), medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(), pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(), antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(),
activeQuickLoginUsers().stream().map(this::toUserOption).toList() List.of()
); );
} }
@@ -376,38 +385,51 @@ public class CatalogService {
return listAntibioticRows(); return listAntibioticRows();
} }
public List<UserRow> listUsers() { public List<UserRow> listUsers(String actorId) {
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
ensureDefaultUsers(); ensureDefaultUsers();
return appUserRepository.findAll().stream() List<AppUser> users = actor.role() == UserRole.ADMIN
? appUserRepository.findAll()
: appUserRepository.findByAccountIdOrderByDisplayNameAsc(resolveAccountId(actor));
return users.stream()
.map(this::toUserRow) .map(this::toUserRow)
.sorted(Comparator.comparing(UserRow::active).reversed().thenComparing(UserRow::displayName, String.CASE_INSENSITIVE_ORDER)) .sorted(Comparator.comparing(UserRow::primaryUser).reversed()
.thenComparing(Comparator.comparing(UserRow::active).reversed())
.thenComparing(UserRow::displayName, String.CASE_INSENSITIVE_ORDER))
.toList(); .toList();
} }
public UserRow createOrUpdateUser(String actorId, UserMutation mutation) { public UserRow createOrUpdateUser(String actorId, UserMutation mutation) {
requireAdminActor(actorId); AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
if (isBlank(mutation.displayName()) || isBlank(mutation.code())) { if (isBlank(mutation.displayName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername ist erforderlich");
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
validateUserMutation(mutation); validateUserMutation(actor, mutation);
if (isBlank(mutation.id())) { if (isBlank(mutation.id())) {
if (isBlank(mutation.email()) || isBlank(mutation.password())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
}
String userId = UUID.randomUUID().toString();
boolean adminManaged = actor.role() == UserRole.ADMIN;
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
null, userId,
mutation.code().trim().toUpperCase(), adminManaged ? userId : resolveAccountId(actor),
adminManaged,
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), adminManaged ? blankToNull(mutation.companyName()) : null,
blankToNull(mutation.address()), adminManaged
null, ? buildAddress(mutation.street(), mutation.houseNumber(), mutation.postalCode(), mutation.city())
null, : null,
null, adminManaged ? blankToNull(mutation.street()) : null,
null, adminManaged ? blankToNull(mutation.houseNumber()) : null,
adminManaged ? blankToNull(mutation.postalCode()) : null,
adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
null, adminManaged ? blankToNull(mutation.phoneNumber()) : null,
blankToNull(mutation.portalLogin()),
encodeIfPresent(mutation.password()), encodeIfPresent(mutation.password()),
mutation.active(), mutation.active(),
normalizeManagedRole(mutation.role()), adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
now, now,
now now
)); ));
@@ -416,41 +438,64 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt"); String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt");
AppUser existing = appUserRepository.findById(mutationId) AppUser existing = appUserRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, true);
AppUser saved = appUserRepository.save(new AppUser( AppUser saved = appUserRepository.save(new AppUser(
existing.id(), existing.id(),
mutation.code().trim().toUpperCase(), existing.accountId(),
isPrimaryUser(existing),
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.companyName()) : existing.companyName(),
blankToNull(mutation.address()), buildAddress(
existing.street(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.street() : existing.street(),
existing.houseNumber(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.houseNumber() : existing.houseNumber(),
existing.postalCode(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.postalCode() : existing.postalCode(),
existing.city(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.city() : existing.city()
),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.street()) : existing.street(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.houseNumber()) : existing.houseNumber(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.postalCode()) : existing.postalCode(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.city()) : existing.city(),
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
existing.phoneNumber(), isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(),
blankToNull(mutation.portalLogin()),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()), isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(), mutation.active(),
mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()), actor.role() == UserRole.ADMIN
? (mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()))
: normalizeStoredRole(existing.role()),
existing.createdAt(), existing.createdAt(),
now now
)); ));
return toUserRow(saved); return toUserRow(saved);
} }
public void deleteUser(String id) { public void deleteUser(String actorId, String id) {
appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt")); AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
AppUser existing = appUserRepository.findById(requireText(id, "Benutzer-ID fehlt"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, false);
if (isPrimaryUser(existing) && actor.role() != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann nicht geloescht werden");
}
appUserRepository.deleteById(existing.id());
} }
public void changePassword(String id, String newPassword) { public void changePassword(String actorId, String id, String newPassword) {
if (isBlank(newPassword)) { if (isBlank(newPassword)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwort darf nicht leer sein"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwort darf nicht leer sein");
} }
AppUser existing = appUserRepository.findById(requireText(id, "Benutzer-ID fehlt")) String targetUserId = requireText(id, "Benutzer-ID fehlt");
AppUser actor = requireActiveActor(actorId, "Nicht berechtigt");
AppUser existing = appUserRepository.findById(targetUserId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
if (actor.role() != UserRole.ADMIN
&& !actor.id().equals(existing.id())
&& !resolveAccountId(actor).equals(resolveAccountId(existing))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
appUserRepository.save(new AppUser( appUserRepository.save(new AppUser(
existing.id(), existing.id(),
existing.code(), existing.accountId(),
isPrimaryUser(existing),
existing.displayName(), existing.displayName(),
existing.companyName(), existing.companyName(),
existing.address(), existing.address(),
@@ -460,7 +505,6 @@ public class CatalogService {
existing.city(), existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(), existing.phoneNumber(),
existing.portalLogin(),
passwordEncoder.encode(newPassword), passwordEncoder.encode(newPassword),
existing.active(), existing.active(),
existing.role(), existing.role(),
@@ -469,27 +513,19 @@ public class CatalogService {
)); ));
} }
public UserOption loginByCode(String code) { public SessionResponse loginWithPassword(String email, String password) {
AppUser user = activeQuickLoginUsers().stream() if (isBlank(email) || isBlank(password)) {
.filter(candidate -> candidate.code().equalsIgnoreCase(code)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzerkürzel unbekannt"));
return toUserOption(user);
}
public UserOption loginWithPassword(String identifier, String password) {
if (isBlank(identifier) || isBlank(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername/E-Mail und Passwort sind erforderlich");
} }
AppUser user = resolvePasswordUser(identifier.trim()) AppUser user = resolvePasswordUser(email.trim())
.filter(AppUser::active) .filter(AppUser::active)
.filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash())) .filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen"));
return toUserOption(user); return toSessionResponse(user);
} }
public UserOption registerCustomer(RegistrationMutation mutation) { public SessionResponse registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) if (isBlank(mutation.companyName())
|| isBlank(mutation.street()) || isBlank(mutation.street())
|| isBlank(mutation.houseNumber()) || isBlank(mutation.houseNumber())
@@ -517,13 +553,12 @@ public class CatalogService {
String phoneNumber = mutation.phoneNumber().trim(); String phoneNumber = mutation.phoneNumber().trim();
String address = formatAddress(street, houseNumber, postalCode, city); String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName; String displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
AppUser created = appUserRepository.save(new AppUser( AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(),
null, null,
code, true,
displayName, displayName,
companyName, companyName,
address, address,
@@ -533,33 +568,47 @@ public class CatalogService {
city, city,
normalizedEmail, normalizedEmail,
phoneNumber, phoneNumber,
portalLogin,
passwordEncoder.encode(mutation.password()), passwordEncoder.encode(mutation.password()),
true, true,
UserRole.CUSTOMER, UserRole.CUSTOMER,
now, now,
now now
)); ));
return toUserOption(created); AppUser accountBound = appUserRepository.save(new AppUser(
created.id(),
created.id(),
true,
created.displayName(),
created.companyName(),
created.address(),
created.street(),
created.houseNumber(),
created.postalCode(),
created.city(),
created.email(),
created.phoneNumber(),
created.passwordHash(),
created.active(),
created.role(),
created.createdAt(),
created.updatedAt()
));
return toSessionResponse(accountBound);
} }
private List<AppUser> activeUsers() { public UserOption currentUser(String actorId) {
ensureDefaultUsers(); AppUser user = appUserRepository.findById(requireText(actorId, "Benutzer-ID fehlt"))
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc(); .filter(AppUser::active)
} .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Benutzer nicht authentifiziert"));
return toUserOption(user);
private List<AppUser> activeQuickLoginUsers() {
return activeUsers().stream()
.filter(user -> user.role() != UserRole.CUSTOMER)
.toList();
} }
public void ensureDefaultUsers() { public void ensureDefaultUsers() {
migrateLegacyAppUsers(); migrateLegacyAppUsers();
ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN); removeLegacyUserCodeField();
ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.CUSTOMER); backfillDefaultUserEmails();
ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.CUSTOMER); removeLegacyPortalLoginField();
ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.CUSTOMER); ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
} }
public Farmer requireActiveFarmer(String businessKey) { public Farmer requireActiveFarmer(String businessKey) {
@@ -632,13 +681,16 @@ public class CatalogService {
private UserRow toUserRow(AppUser user) { private UserRow toUserRow(AppUser user) {
return new UserRow( return new UserRow(
user.id(), user.id(),
user.code(), isPrimaryUser(user),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
resolveAddress(user), resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(), user.email(),
user.phoneNumber(), user.phoneNumber(),
user.portalLogin(),
user.active(), user.active(),
normalizeStoredRole(user.role()), normalizeStoredRole(user.role()),
user.updatedAt() user.updatedAt()
@@ -664,17 +716,24 @@ public class CatalogService {
private UserOption toUserOption(AppUser user) { private UserOption toUserOption(AppUser user) {
return new UserOption( return new UserOption(
user.id(), user.id(),
user.code(), isPrimaryUser(user),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
resolveAddress(user), resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(), user.email(),
user.phoneNumber(), user.phoneNumber(),
user.portalLogin(),
normalizeStoredRole(user.role()) normalizeStoredRole(user.role())
); );
} }
private SessionResponse toSessionResponse(AppUser user) {
return new SessionResponse(authTokenService.createToken(user), toUserOption(user));
}
private String encodeIfPresent(String password) { private String encodeIfPresent(String password) {
return isBlank(password) ? null : passwordEncoder.encode(password); return isBlank(password) ? null : passwordEncoder.encode(password);
} }
@@ -687,29 +746,16 @@ public class CatalogService {
return Objects.requireNonNull(sanitized); return Objects.requireNonNull(sanitized);
} }
private void validateUserMutation(UserMutation mutation) { private void validateUserMutation(AppUser actor, UserMutation mutation) {
String normalizedEmail = normalizeEmail(mutation.email()); String normalizedEmail = normalizeEmail(mutation.email());
String normalizedLogin = blankToNull(mutation.portalLogin());
String normalizedCode = mutation.code().trim().toUpperCase(Locale.ROOT);
appUserRepository.findAll().forEach(existing -> { appUserRepository.findAll().forEach(existing -> {
if (!safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.code() != null
&& existing.code().equalsIgnoreCase(normalizedCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieses Kürzel ist bereits vergeben");
}
if (normalizedEmail != null if (normalizedEmail != null
&& existing.email() != null && existing.email() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id())) && !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.email().equalsIgnoreCase(normalizedEmail)) { && existing.email().equalsIgnoreCase(normalizedEmail)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben");
} }
if (normalizedLogin != null
&& existing.portalLogin() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.portalLogin().equalsIgnoreCase(normalizedLogin)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieser Benutzername ist bereits vergeben");
}
}); });
} }
@@ -725,33 +771,44 @@ public class CatalogService {
return left == null ? right == null : left.equals(right); return left == null ? right == null : left.equals(right);
} }
private Optional<AppUser> resolvePasswordUser(String identifier) { private Optional<AppUser> resolvePasswordUser(String email) {
return appUserRepository.findByEmailIgnoreCase(identifier) return appUserRepository.findByEmailIgnoreCase(normalizeEmail(email));
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier));
} }
private void requireAdminActor(String actorId) { private AppUser requireActiveActor(String actorId, String message) {
if (isBlank(actorId)) { return appUserRepository.findById(requireText(actorId, message))
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen");
}
String actorIdValue = requireText(actorId, "Nur Administratoren koennen Benutzer anlegen");
AppUser actor = appUserRepository.findById(actorIdValue)
.filter(AppUser::active) .filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
}
if (actor.role() != UserRole.ADMIN) { private String resolveAccountId(AppUser user) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen"); return isBlank(user.accountId()) ? requireText(user.id(), "Benutzerkonto ungueltig") : user.accountId();
}
private boolean isPrimaryUser(AppUser user) {
return Boolean.TRUE.equals(user.primaryUser());
}
private void requireUserManagementAccess(AppUser actor, AppUser target, boolean allowPrimaryUserEdit) {
if (actor.role() == UserRole.ADMIN) {
return;
}
if (!resolveAccountId(actor).equals(resolveAccountId(target))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
if (!allowPrimaryUserEdit && isPrimaryUser(target)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Der Hauptbenutzer kann hier nicht bearbeitet werden");
} }
} }
private void migrateLegacyAppUsers() { private void migrateLegacyAppUsers() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream() appUserRepository.findAll().stream()
.filter(user -> user.role() == UserRole.APP) .filter(user -> user.role() == UserRole.APP || isBlank(user.accountId()) || user.primaryUser() == null)
.forEach(user -> appUserRepository.save(new AppUser( .forEach(user -> appUserRepository.save(new AppUser(
user.id(), user.id(),
user.code(), user.id(),
true,
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
user.address(), user.address(),
@@ -761,34 +818,31 @@ public class CatalogService {
user.city(), user.city(),
user.email(), user.email(),
user.phoneNumber(), user.phoneNumber(),
user.portalLogin(),
user.passwordHash(), user.passwordHash(),
user.active(), user.active(),
UserRole.CUSTOMER, normalizeStoredRole(user.role()),
user.createdAt(), user.createdAt(),
now now
))); )));
} }
private void ensureDefaultUser( private void ensureDefaultUser(
String code,
String displayName, String displayName,
String email, String email,
String portalLogin,
String rawPassword, String rawPassword,
UserRole role UserRole role
) { ) {
boolean exists = appUserRepository.findByCodeIgnoreCase(code).isPresent() boolean exists = appUserRepository.findByEmailIgnoreCase(email).isPresent();
|| appUserRepository.findByEmailIgnoreCase(email).isPresent()
|| appUserRepository.findByPortalLoginIgnoreCase(portalLogin).isPresent();
if (exists) { if (exists) {
return; return;
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String userId = UUID.randomUUID().toString();
appUserRepository.save(new AppUser( appUserRepository.save(new AppUser(
null, userId,
code, userId,
true,
displayName, displayName,
null, null,
null, null,
@@ -798,7 +852,6 @@ public class CatalogService {
null, null,
email, email,
null, null,
portalLogin,
passwordEncoder.encode(rawPassword), passwordEncoder.encode(rawPassword),
true, true,
role, role,
@@ -807,6 +860,41 @@ public class CatalogService {
)); ));
} }
private void removeLegacyUserCodeField() {
mongoTemplate.updateMulti(
new Query(Criteria.where("code").exists(true)),
new Update().unset("code"),
AppUser.class
);
}
private void removeLegacyPortalLoginField() {
mongoTemplate.updateMulti(
new Query(Criteria.where("portalLogin").exists(true)),
new Update().unset("portalLogin"),
AppUser.class
);
}
private void backfillDefaultUserEmails() {
backfillDefaultUserEmail("admin", "admin@muh.local");
}
private void backfillDefaultUserEmail(String legacyPortalLogin, String email) {
mongoTemplate.updateMulti(
new Query(new Criteria().andOperator(
new Criteria().orOperator(
Criteria.where("email").exists(false),
Criteria.where("email").is(null),
Criteria.where("email").is("")
),
Criteria.where("portalLogin").is(legacyPortalLogin)
)),
new Update().set("email", email),
AppUser.class
);
}
private String normalizeEmail(String email) { private String normalizeEmail(String email) {
return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT); return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT);
} }
@@ -829,6 +917,13 @@ public class CatalogService {
return formatAddress(user.street(), user.houseNumber(), user.postalCode(), user.city()); return formatAddress(user.street(), user.houseNumber(), user.postalCode(), user.city());
} }
private String buildAddress(String street, String houseNumber, String postalCode, String city) {
if (isBlank(street) && isBlank(houseNumber) && isBlank(postalCode) && isBlank(city)) {
return null;
}
return formatAddress(street, houseNumber, postalCode, city);
}
private String formatAddress(String street, String houseNumber, String postalCode, String city) { private String formatAddress(String street, String houseNumber, String postalCode, String city) {
String streetPart = joinParts(" ", street, houseNumber); String streetPart = joinParts(" ", street, houseNumber);
String cityPart = joinParts(" ", postalCode, city); String cityPart = joinParts(" ", postalCode, city);
@@ -842,54 +937,6 @@ public class CatalogService {
.collect(Collectors.joining(separator)); .collect(Collectors.joining(separator));
} }
private String localPart(String email) {
int separator = email.indexOf('@');
return separator >= 0 ? email.substring(0, separator) : email;
}
private String generateUniquePortalLogin(String seed) {
String base = seed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]", "");
if (base.isBlank()) {
base = "user";
}
String candidate = base;
int index = 2;
while (appUserRepository.findByPortalLoginIgnoreCase(candidate).isPresent()) {
candidate = base + index++;
}
return candidate;
}
private String generateUniqueCode(String seed) {
String compact = seed.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
if (compact.isBlank()) {
compact = "USR";
}
String base = compact.length() >= 3 ? compact.substring(0, Math.min(4, compact.length())) : (compact + "XXX").substring(0, 3);
Set<String> usedCodes = appUserRepository.findAll().stream()
.map(AppUser::code)
.filter(code -> code != null && !code.isBlank())
.map(code -> code.toUpperCase(Locale.ROOT))
.collect(Collectors.toCollection(HashSet::new));
String candidate = base.length() > 3 ? base.substring(0, 3) : base;
if (!usedCodes.contains(candidate)) {
return candidate;
}
String prefix = candidate.substring(0, Math.min(2, candidate.length()));
int index = 1;
while (true) {
String numbered = (prefix + index).toUpperCase(Locale.ROOT);
if (!usedCodes.contains(numbered)) {
return numbered;
}
index++;
}
}
public record ActiveCatalogSummary( public record ActiveCatalogSummary(
List<FarmerOption> farmers, List<FarmerOption> farmers,
List<MedicationOption> medications, List<MedicationOption> medications,
@@ -921,13 +968,16 @@ public class CatalogService {
public record UserOption( public record UserOption(
String id, String id,
String code, boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber, String phoneNumber,
String portalLogin,
UserRole role UserRole role
) { ) {
} }
@@ -987,13 +1037,16 @@ public class CatalogService {
public record UserRow( public record UserRow(
String id, String id,
String code, boolean primaryUser,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber, String phoneNumber,
String portalLogin,
boolean active, boolean active,
UserRole role, UserRole role,
LocalDateTime updatedAt LocalDateTime updatedAt
@@ -1002,12 +1055,15 @@ public class CatalogService {
public record UserMutation( public record UserMutation(
String id, String id,
String code,
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String portalLogin, String phoneNumber,
String password, String password,
boolean active, boolean active,
UserRole role UserRole role
@@ -1025,4 +1081,7 @@ public class CatalogService {
String password String password
) { ) {
} }
public record SessionResponse(String token, UserOption user) {
}
} }

View File

@@ -21,7 +21,15 @@ public class PortalService {
this.catalogService = catalogService; this.catalogService = catalogService;
} }
public PortalSnapshot snapshot(String farmerBusinessKey, String farmerQuery, String cowQuery, Long sampleNumber, LocalDate date) { public PortalSnapshot snapshot(
boolean includeUsers,
String actorId,
String farmerBusinessKey,
String farmerQuery,
String cowQuery,
Long sampleNumber,
LocalDate date
) {
List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream() List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream()
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT))) .filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList(); .toList();
@@ -51,7 +59,7 @@ public class PortalService {
matchingFarmers, matchingFarmers,
sampleRows, sampleRows,
reportService.reportCandidates(), reportService.reportCandidates(),
catalogService.listUsers() includeUsers ? catalogService.listUsers(actorId) : List.of()
); );
} }

View File

@@ -175,7 +175,7 @@ public class ReportService {
return defaultCustomerSignature(); return defaultCustomerSignature();
} }
String primaryName = firstNonBlank(actor.companyName(), actor.displayName(), actor.code(), "MUH App"); String primaryName = firstNonBlank(actor.companyName(), actor.displayName(), "MUH App");
String secondaryName = actor.companyName() != null String secondaryName = actor.companyName() != null
&& actor.displayName() != null && actor.displayName() != null
&& !actor.companyName().equalsIgnoreCase(actor.displayName()) && !actor.companyName().equalsIgnoreCase(actor.displayName())

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -17,9 +18,11 @@ import java.util.List;
public class CatalogController { public class CatalogController {
private final CatalogService catalogService; private final CatalogService catalogService;
private final SecuritySupport securitySupport;
public CatalogController(CatalogService catalogService) { public CatalogController(CatalogService catalogService, SecuritySupport securitySupport) {
this.catalogService = catalogService; this.catalogService = catalogService;
this.securitySupport = securitySupport;
} }
@GetMapping("/catalogs/summary") @GetMapping("/catalogs/summary")
@@ -54,25 +57,22 @@ public class CatalogController {
@GetMapping("/portal/users") @GetMapping("/portal/users")
public List<CatalogService.UserRow> users() { public List<CatalogService.UserRow> users() {
return catalogService.listUsers(); return catalogService.listUsers(securitySupport.currentUser().id());
} }
@PostMapping("/portal/users") @PostMapping("/portal/users")
public CatalogService.UserRow saveUser( public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) {
@RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId, return catalogService.createOrUpdateUser(securitySupport.currentUser().id(), mutation);
@RequestBody CatalogService.UserMutation mutation
) {
return catalogService.createOrUpdateUser(actorId, mutation);
} }
@DeleteMapping("/portal/users/{id}") @DeleteMapping("/portal/users/{id}")
public void deleteUser(@PathVariable String id) { public void deleteUser(@PathVariable String id) {
catalogService.deleteUser(id); catalogService.deleteUser(securitySupport.currentUser().id(), id);
} }
@PostMapping("/portal/users/{id}/password") @PostMapping("/portal/users/{id}/password")
public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) { public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) {
catalogService.changePassword(id, request.password()); catalogService.changePassword(securitySupport.currentUser().id(), id, request.password());
} }
public record PasswordChangeRequest(String password) { public record PasswordChangeRequest(String password) {

View File

@@ -3,6 +3,7 @@ package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.PortalService; import de.svencarstensen.muh.service.PortalService;
import de.svencarstensen.muh.service.ReportService; import de.svencarstensen.muh.service.ReportService;
import de.svencarstensen.muh.service.SampleService; import de.svencarstensen.muh.service.SampleService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -27,10 +28,12 @@ public class PortalController {
private final PortalService portalService; private final PortalService portalService;
private final ReportService reportService; private final ReportService reportService;
private final SecuritySupport securitySupport;
public PortalController(PortalService portalService, ReportService reportService) { public PortalController(PortalService portalService, ReportService reportService, SecuritySupport securitySupport) {
this.portalService = portalService; this.portalService = portalService;
this.reportService = reportService; this.reportService = reportService;
this.securitySupport = securitySupport;
} }
@GetMapping("/snapshot") @GetMapping("/snapshot")
@@ -41,7 +44,16 @@ public class PortalController {
@RequestParam(required = false) Long sampleNumber, @RequestParam(required = false) Long sampleNumber,
@RequestParam(required = false) LocalDate date @RequestParam(required = false) LocalDate date
) { ) {
return portalService.snapshot(farmerBusinessKey, farmerQuery, cowQuery, sampleNumber, date); var currentUser = securitySupport.currentUser();
return portalService.snapshot(
currentUser.role() == de.svencarstensen.muh.domain.UserRole.ADMIN,
currentUser.id(),
farmerBusinessKey,
farmerQuery,
cowQuery,
sampleNumber,
date
);
} }
@GetMapping("/reports") @GetMapping("/reports")
@@ -55,11 +67,8 @@ public class PortalController {
} }
@PostMapping("/reports/send") @PostMapping("/reports/send")
public ReportService.DispatchResult send( public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) {
@RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId, return reportService.sendReports(securitySupport.currentUser().id(), request.sampleIds());
@RequestBody ReportDispatchRequest request
) {
return reportService.sendReports(actorId, request.sampleIds());
} }
@PatchMapping("/reports/{sampleId}/block") @PatchMapping("/reports/{sampleId}/block")

View File

@@ -1,5 +1,7 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.security.AuthenticatedUser;
import de.svencarstensen.muh.security.SecuritySupport;
import de.svencarstensen.muh.service.SampleService; import de.svencarstensen.muh.service.SampleService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -14,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
public class SampleController { public class SampleController {
private final SampleService sampleService; private final SampleService sampleService;
private final SecuritySupport securitySupport;
public SampleController(SampleService sampleService) { public SampleController(SampleService sampleService, SecuritySupport securitySupport) {
this.sampleService = sampleService; this.sampleService = sampleService;
this.securitySupport = securitySupport;
} }
@GetMapping("/dashboard") @GetMapping("/dashboard")
@@ -41,7 +45,17 @@ public class SampleController {
@PostMapping("/samples") @PostMapping("/samples")
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) { public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) {
return sampleService.createSample(request); AuthenticatedUser user = securitySupport.currentUser();
return sampleService.createSample(new SampleService.RegistrationRequest(
request.farmerBusinessKey(),
request.cowNumber(),
request.cowName(),
request.sampleKind(),
request.samplingMode(),
request.flaggedQuarters(),
deriveUserLabel(user.displayName()),
user.displayName()
));
} }
@PutMapping("/samples/{id}/registration") @PutMapping("/samples/{id}/registration")
@@ -63,4 +77,15 @@ public class SampleController {
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) { public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) {
return sampleService.saveTherapy(id, request); return sampleService.saveTherapy(id, request);
} }
private String deriveUserLabel(String displayName) {
if (displayName == null || displayName.isBlank()) {
return "USR";
}
String compact = displayName.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
if (compact.isBlank()) {
return "USR";
}
return compact.substring(0, Math.min(4, compact.length()));
}
} }

View File

@@ -1,6 +1,7 @@
package de.svencarstensen.muh.web; package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -8,35 +9,31 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/session") @RequestMapping("/api/session")
public class SessionController { public class SessionController {
private final CatalogService catalogService; private final CatalogService catalogService;
private final SecuritySupport securitySupport;
public SessionController(CatalogService catalogService) { public SessionController(CatalogService catalogService, SecuritySupport securitySupport) {
this.catalogService = catalogService; this.catalogService = catalogService;
this.securitySupport = securitySupport;
} }
@GetMapping("/users") @GetMapping("/me")
public List<CatalogService.UserOption> activeUsers() { public CatalogService.UserOption currentUser() {
return catalogService.activeCatalogSummary().users(); return catalogService.currentUser(securitySupport.currentUser().id());
}
@PostMapping("/login")
public CatalogService.UserOption login(@RequestBody LoginRequest request) {
return catalogService.loginByCode(request.code());
} }
@PostMapping("/password-login") @PostMapping("/password-login")
public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) { public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.identifier(), request.password()); return catalogService.loginWithPassword(request.email(), request.password());
} }
@PostMapping("/register") @PostMapping("/register")
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) { public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation( return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(), request.companyName(),
request.street(), request.street(),
@@ -49,10 +46,7 @@ public class SessionController {
)); ));
} }
public record LoginRequest(@NotBlank String code) { public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
}
public record PasswordLoginRequest(@NotBlank String identifier, @NotBlank String password) {
} }
public record RegistrationRequest( public record RegistrationRequest(

View File

@@ -33,6 +33,9 @@ spring:
muh: muh:
cors: cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000} allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
security:
token-secret: ${MUH_TOKEN_SECRET:change-me-in-production}
token-validity-hours: ${MUH_TOKEN_VALIDITY_HOURS:12}
mongodb: mongodb:
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh} url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
username: ${MUH_MONGODB_USERNAME:} username: ${MUH_MONGODB_USERNAME:}

View File

@@ -12,9 +12,14 @@ import PortalPage from "./pages/PortalPage";
import SearchPage from "./pages/SearchPage"; import SearchPage from "./pages/SearchPage";
import SearchFarmerPage from "./pages/SearchFarmerPage"; import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage"; import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage";
function ProtectedRoutes() { function ProtectedRoutes() {
const { user } = useSession(); const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) { if (!user) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
@@ -30,6 +35,7 @@ function ProtectedRoutes() {
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} /> <Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} /> <Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
<Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} /> <Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} />
<Route path="/admin/benutzer" element={<UserManagementPage />} />
<Route path="/admin/landwirte" element={<AdministrationPage />} /> <Route path="/admin/landwirte" element={<AdministrationPage />} />
<Route path="/admin/medikamente" element={<AdministrationPage />} /> <Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} /> <Route path="/admin/erreger" element={<AdministrationPage />} />
@@ -46,7 +52,10 @@ function ProtectedRoutes() {
} }
function ApplicationRouter() { function ApplicationRouter() {
const { user } = useSession(); const { user, ready } = useSession();
if (!ready) {
return <div className="empty-state">Sitzung wird geladen ...</div>;
}
if (!user) { if (!user) {
return ( return (
<Routes> <Routes>

View File

@@ -23,6 +23,9 @@ function resolvePageTitle(pathname: string) {
if (pathname.startsWith("/admin/landwirte")) { if (pathname.startsWith("/admin/landwirte")) {
return "Verwaltung | Landwirte"; return "Verwaltung | Landwirte";
} }
if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) { if (pathname.startsWith("/admin/medikamente")) {
return "Verwaltung | Medikamente"; return "Verwaltung | Medikamente";
} }
@@ -45,7 +48,7 @@ function resolvePageTitle(pathname: string) {
} }
export default function AppShell() { export default function AppShell() {
const { user, setUser } = useSession(); const { user, setSession } = useSession();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -76,6 +79,9 @@ export default function AppShell() {
<div className="nav-group"> <div className="nav-group">
<div className="nav-group__label">Verwaltung</div> <div className="nav-group__label">Verwaltung</div>
<div className="nav-subnav"> <div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Benutzer
</NavLink>
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}> <NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirte Landwirte
</NavLink> </NavLink>
@@ -116,13 +122,13 @@ export default function AppShell() {
<div className="sidebar__footer"> <div className="sidebar__footer">
<div className="user-chip user-chip--stacked"> <div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span> <span>{user?.displayName}</span>
<small>{user?.code}</small> <small>{user?.email ?? user?.role}</small>
</div> </div>
<button <button
type="button" type="button"
className="ghost-button" className="ghost-button"
onClick={() => { onClick={() => {
setUser(null); setSession(null);
navigate("/"); navigate("/");
}} }}
> >

View File

@@ -1,4 +1,4 @@
import { USER_STORAGE_KEY } from "./storage"; import { AUTH_TOKEN_STORAGE_KEY } from "./storage";
const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api"); const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api");
@@ -30,22 +30,16 @@ async function readErrorMessage(response: Response): Promise<string> {
return `Anfrage fehlgeschlagen (${response.status})`; return `Anfrage fehlgeschlagen (${response.status})`;
} }
function actorHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return {}; return {};
} }
const rawUser = window.localStorage.getItem(USER_STORAGE_KEY); const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!rawUser) { if (!token) {
return {};
}
try {
const user = JSON.parse(rawUser) as { id?: string | null };
return user.id ? { "X-MUH-Actor-Id": user.id } : {};
} catch {
return {}; return {};
} }
return { Authorization: `Bearer ${token}` };
} }
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
@@ -55,13 +49,17 @@ async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T;
} }
return (await response.json()) as T; const text = await response.text();
if (!text.trim()) {
return undefined as T;
}
return JSON.parse(text) as T;
} }
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
headers: actorHeaders(), headers: authHeaders(),
}), }),
); );
} }
@@ -72,7 +70,7 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...actorHeaders(), ...authHeaders(),
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -85,7 +83,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...actorHeaders(), ...authHeaders(),
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -98,7 +96,7 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...actorHeaders(), ...authHeaders(),
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -109,7 +107,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>( await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "DELETE", method: "DELETE",
headers: actorHeaders(), headers: authHeaders(),
}), }),
); );
} }

View File

@@ -6,17 +6,20 @@ import {
useState, useState,
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
import { USER_STORAGE_KEY } from "./storage"; import { apiGet } from "./api";
import type { UserOption } from "./types"; import { AUTH_TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from "./storage";
import type { SessionResponse, UserOption } from "./types";
interface SessionContextValue { interface SessionContextValue {
user: UserOption | null; user: UserOption | null;
setUser: (user: UserOption | null) => void; ready: boolean;
setSession: (session: SessionResponse | null) => void;
} }
const SessionContext = createContext<SessionContextValue>({ const SessionContext = createContext<SessionContextValue>({
user: null, user: null,
setUser: () => undefined, ready: false,
setSession: () => undefined,
}); });
function loadStoredUser(): UserOption | null { function loadStoredUser(): UserOption | null {
@@ -33,6 +36,39 @@ function loadStoredUser(): UserOption | null {
export function SessionProvider({ children }: PropsWithChildren) { export function SessionProvider({ children }: PropsWithChildren) {
const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser()); const [user, setUserState] = useState<UserOption | null>(() => loadStoredUser());
const [ready, setReady] = useState(false);
useEffect(() => {
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
setReady(true);
return;
}
let cancelled = false;
void apiGet<UserOption>("/session/me")
.then((currentUser) => {
if (!cancelled) {
setUserState(currentUser);
}
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
}
})
.finally(() => {
if (!cancelled) {
setReady(true);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -42,12 +78,26 @@ export function SessionProvider({ children }: PropsWithChildren) {
window.localStorage.removeItem(USER_STORAGE_KEY); window.localStorage.removeItem(USER_STORAGE_KEY);
}, [user]); }, [user]);
function setSession(session: SessionResponse | null) {
if (session) {
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, session.token);
setUserState(session.user);
setReady(true);
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(USER_STORAGE_KEY);
setUserState(null);
setReady(true);
}
const value = useMemo( const value = useMemo(
() => ({ () => ({
user, user,
setUser: setUserState, ready,
setSession,
}), }),
[user], [ready, user],
); );
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

View File

@@ -1 +1,2 @@
export const USER_STORAGE_KEY = "muh.current-user"; export const USER_STORAGE_KEY = "muh.current-user";
export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token";

View File

@@ -45,16 +45,24 @@ export interface AntibioticOption {
export interface UserOption { export interface UserOption {
id: string; id: string;
code: string; primaryUser: boolean;
displayName: string; displayName: string;
companyName: string | null; companyName: string | null;
address: string | null; address: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null; email: string | null;
phoneNumber: string | null; phoneNumber: string | null;
portalLogin: string | null;
role: UserRole; role: UserRole;
} }
export interface SessionResponse {
token: string;
user: UserOption;
}
export interface UserRow extends UserOption { export interface UserRow extends UserOption {
active: boolean; active: boolean;
updatedAt: string; updatedAt: string;

View File

@@ -1,7 +1,7 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { apiPost } from "../lib/api"; import { apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types"; import type { SessionResponse } from "../lib/types";
type FeedbackState = type FeedbackState =
| { type: "error"; text: string } | { type: "error"; text: string }
@@ -10,7 +10,7 @@ type FeedbackState =
export default function LoginPage() { export default function LoginPage() {
const [showRegistration, setShowRegistration] = useState(false); const [showRegistration, setShowRegistration] = useState(false);
const [identifier, setIdentifier] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showLoginValidation, setShowLoginValidation] = useState(false); const [showLoginValidation, setShowLoginValidation] = useState(false);
const [showRegisterValidation, setShowRegisterValidation] = useState(false); const [showRegisterValidation, setShowRegisterValidation] = useState(false);
@@ -26,25 +26,25 @@ export default function LoginPage() {
passwordConfirmation: "", passwordConfirmation: "",
}); });
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession(); const { setSession } = useSession();
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) { async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setShowLoginValidation(true); setShowLoginValidation(true);
if (!identifier.trim() || !password.trim()) { if (!email.trim() || !password.trim()) {
setFeedback({ setFeedback({
type: "error", type: "error",
text: "Bitte E-Mail oder Benutzername und Passwort eingeben.", text: "Bitte E-Mail und Passwort eingeben.",
}); });
return; return;
} }
try { try {
setFeedback(null); setFeedback(null);
const response = await apiPost<UserOption>("/session/password-login", { const response = await apiPost<SessionResponse>("/session/password-login", {
identifier: identifier.trim(), email: email.trim(),
password, password,
}); });
setUser(response); setSession(response);
} catch (loginError) { } catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message }); setFeedback({ type: "error", text: (loginError as Error).message });
} }
@@ -77,12 +77,12 @@ export default function LoginPage() {
setFeedback(null); setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration; const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation; void passwordConfirmation;
const response = await apiPost<UserOption>("/session/register", registrationPayload); const response = await apiPost<SessionResponse>("/session/register", registrationPayload);
setFeedback({ setFeedback({
type: "success", type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`, text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
}); });
setUser(response); setSession(response);
} catch (registrationError) { } catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message }); setFeedback({ type: "error", text: (registrationError as Error).message });
} }
@@ -105,8 +105,7 @@ export default function LoginPage() {
<p className="eyebrow">Zugang</p> <p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2> <h2>Anmelden oder registrieren</h2>
<p className="muted-text"> <p className="muted-text">
Anmeldung per E-Mail oder Benutzername mit Passwort sowie direkte Anmeldung per E-Mail mit Passwort sowie direkte Kundenregistrierung.
Kundenregistrierung.
</p> </p>
{feedback ? ( {feedback ? (
@@ -119,11 +118,12 @@ export default function LoginPage() {
{!showRegistration ? ( {!showRegistration ? (
<form className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`} onSubmit={handlePasswordLogin}> <form className={`login-panel__section ${showLoginValidation ? "show-validation" : ""}`} onSubmit={handlePasswordLogin}>
<label className="field field--required"> <label className="field field--required">
<span>E-Mail / Benutzername</span> <span>E-Mail</span>
<input <input
value={identifier} type="email"
onChange={(event) => setIdentifier(event.target.value)} value={email}
placeholder="z. B. admin oder name@hof.de" onChange={(event) => setEmail(event.target.value)}
placeholder="z. B. name@hof.de"
required required
/> />
</label> </label>

View File

@@ -9,10 +9,8 @@ export default function PortalPage() {
const [selectedReports, setSelectedReports] = useState<string[]>([]); const [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({ const [userForm, setUserForm] = useState({
code: "",
displayName: "", displayName: "",
email: "", email: "",
portalLogin: "",
password: "", password: "",
role: "CUSTOMER" as UserRole, role: "CUSTOMER" as UserRole,
}); });
@@ -70,10 +68,8 @@ export default function PortalPage() {
active: true, active: true,
}); });
setUserForm({ setUserForm({
code: "",
displayName: "", displayName: "",
email: "", email: "",
portalLogin: "",
password: "", password: "",
role: "CUSTOMER", role: "CUSTOMER",
}); });
@@ -166,14 +162,6 @@ export default function PortalPage() {
<article className="section-card"> <article className="section-card">
<p className="eyebrow">Benutzerverwaltung</p> <p className="eyebrow">Benutzerverwaltung</p>
<form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}> <form className={`field-grid ${showUserValidation ? "show-validation" : ""}`} onSubmit={handleCreateUser}>
<label className="field field--required">
<span>Kuerzel</span>
<input
value={userForm.code}
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
required
/>
</label>
<label className="field field--required"> <label className="field field--required">
<span>Name</span> <span>Name</span>
<input <input
@@ -184,15 +172,6 @@ export default function PortalPage() {
required required
/> />
</label> </label>
<label className="field">
<span>Login</span>
<input
value={userForm.portalLogin}
onChange={(event) =>
setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
}
/>
</label>
<label className="field"> <label className="field">
<span>E-Mail</span> <span>E-Mail</span>
<input <input
@@ -230,10 +209,8 @@ export default function PortalPage() {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>Kuerzel</th>
<th>Name</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Login</th>
<th>Rolle</th> <th>Rolle</th>
<th>Passwort</th> <th>Passwort</th>
<th /> <th />
@@ -242,10 +219,8 @@ export default function PortalPage() {
<tbody> <tbody>
{snapshot.users.map((user) => ( {snapshot.users.map((user) => (
<tr key={user.id}> <tr key={user.id}>
<td>{user.code}</td>
<td>{user.displayName}</td> <td>{user.displayName}</td>
<td>{user.email ?? "-"}</td> <td>{user.email ?? "-"}</td>
<td>{user.portalLogin ?? "-"}</td>
<td>{user.role}</td> <td>{user.role}</td>
<td> <td>
<input <input

View File

@@ -99,7 +99,7 @@ export default function SampleRegistrationPage() {
sampleKind, sampleKind,
samplingMode, samplingMode,
flaggedQuarters, flaggedQuarters,
userCode: user.code, userCode: user.displayName,
userDisplayName: user.displayName, userDisplayName: user.displayName,
}; };

View File

@@ -0,0 +1,479 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types";
type UserDraft = UserRow & {
password: string;
passwordRepeat: string;
};
function toDraft(user: UserRow): UserDraft {
return {
...user,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
};
}
function emptyUser(): UserDraft {
return {
id: "",
primaryUser: false,
displayName: "",
companyName: "",
address: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordRepeat: "",
active: true,
role: "CUSTOMER",
updatedAt: new Date().toISOString(),
};
}
function toDraftFromSession(user: UserOption): UserDraft {
return {
id: user.id,
primaryUser: user.primaryUser,
displayName: user.displayName,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
active: true,
role: user.role,
updatedAt: new Date().toISOString(),
};
}
function isAccessDenied(error: unknown): boolean {
return error instanceof Error && error.message.trim().toLowerCase() === "access denied";
}
function toMutation(user: UserDraft) {
return {
id: user.id || null,
displayName: user.displayName,
companyName: user.companyName || null,
address: user.address || null,
street: user.street || null,
houseNumber: user.houseNumber || null,
postalCode: user.postalCode || null,
city: user.city || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,
password: user.password || null,
active: user.active,
role: user.role,
};
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
}
}
useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message));
}, []);
const primaryUser = useMemo(
() => users.find((entry) => entry.primaryUser) ?? null,
[users],
);
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser),
[users],
);
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)),
);
}
async function saveUser(draft: UserDraft) {
setShowValidation(true);
if (!draft.displayName.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft));
setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)),
);
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
} catch (error) {
setMessage((error as Error).message);
}
}
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
if (newUser.password !== newUser.passwordRepeat) {
setMessage("Die Passwoerter stimmen nicht ueberein.");
return;
}
try {
await apiPost<UserRow>("/portal/users", toMutation(newUser));
setNewUser(emptyUser());
setMessage("Benutzer angelegt.");
await loadUsers();
} catch (error) {
setMessage((error as Error).message);
}
}
async function removeUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setUsers((current) => current.filter((entry) => entry.id !== userId));
setMessage("Benutzer geloescht.");
void loadUsers().catch(() => undefined);
} catch (error) {
setMessage((error as Error).message);
}
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Benutzer und Stammdaten</h3>
<p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an.
</p>
</div>
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
</section>
{primaryUser ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptbenutzer</p>
<h3>Stammdaten bearbeiten</h3>
</div>
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div>
</div>
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}>
<label className="field field--required">
<span>Name</span>
<input
required
value={primaryUser.displayName}
onChange={(event) =>
updateExistingUser(primaryUser.id, { displayName: event.target.value })
}
/>
</label>
<label className="field">
<span>Firmenname</span>
<input
value={primaryUser.companyName ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { companyName: event.target.value })
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={primaryUser.email ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { email: event.target.value })}
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={primaryUser.street ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { street: event.target.value })}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={primaryUser.houseNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { houseNumber: event.target.value })
}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={primaryUser.postalCode ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { postalCode: event.target.value })
}
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={primaryUser.city ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { city: event.target.value })}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={primaryUser.phoneNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { phoneNumber: event.target.value })
}
/>
</label>
<label className="field field--wide">
<span>Neues Passwort</span>
<input
type="password"
value={primaryUser.password}
onChange={(event) => updateExistingUser(primaryUser.id, { password: event.target.value })}
/>
</label>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void saveUser(primaryUser)}>
Stammdaten speichern
</button>
</div>
</section>
) : null}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzer</p>
<h3>Benutzer anlegen</h3>
</div>
</div>
<form
className={`field-grid ${showValidation ? "show-validation" : ""}`}
onSubmit={createUser}
autoComplete="off"
>
<label className="field field--required">
<span>Name</span>
<input
required
value={newUser.displayName}
onChange={(event) =>
setNewUser((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
required
type="email"
value={newUser.email ?? ""}
autoComplete="off"
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
required
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(event) =>
setNewUser((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>Passwort wiederholen</span>
<input
required
type="password"
autoComplete="new-password"
className={
showValidation && newUser.password !== newUser.passwordRepeat ? "is-invalid" : ""
}
value={newUser.passwordRepeat}
onChange={(event) =>
setNewUser((current) => ({ ...current, passwordRepeat: event.target.value }))
}
/>
</label>
{isAdmin ? (
<label className="field">
<span>Rolle</span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as UserRole,
}))
}
>
<option value="CUSTOMER">CUSTOMER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzerliste</p>
<h3>Bereits angelegte Benutzer</h3>
</div>
</div>
{secondaryUsers.length === 0 ? (
<div className="empty-state">Noch keine weiteren Benutzer vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
<th>E-Mail</th>
<th>Passwort</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
{secondaryUsers.map((entry) => (
<tr key={entry.id}>
<td>
<input
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
</td>
<td>
<input
type="email"
value={entry.email ?? ""}
onChange={(event) => updateExistingUser(entry.id, { email: event.target.value })}
/>
</td>
<td>
<input
type="password"
value={entry.password}
onChange={(event) =>
updateExistingUser(entry.id, { password: event.target.value })
}
placeholder="Neues Passwort"
/>
</td>
<td>
<select
value={entry.active ? "true" : "false"}
onChange={(event) =>
updateExistingUser(entry.id, { active: event.target.value === "true" })
}
>
<option value="true">aktiv</option>
<option value="false">inaktiv</option>
</select>
</td>
<td className="table-actions">
<button
type="button"
className="table-link"
onClick={() => void saveUser(entry)}
>
Speichern
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}