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>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>

View File

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

View File

@@ -9,9 +9,7 @@ import java.util.Optional;
public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
Optional<AppUser> findByCodeIgnoreCase(String code);
List<AppUser> findByAccountIdOrderByDisplayNameAsc(String accountId);
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.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import de.svencarstensen.muh.security.AuthTokenService;
import org.springframework.http.HttpStatus;
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.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@@ -22,13 +27,11 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -61,6 +64,8 @@ public class CatalogService {
private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository;
private final AppUserRepository appUserRepository;
private final MongoTemplate mongoTemplate;
private final AuthTokenService authTokenService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService(
@@ -68,13 +73,17 @@ public class CatalogService {
MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository
AppUserRepository appUserRepository,
MongoTemplate mongoTemplate,
AuthTokenService authTokenService
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository;
this.appUserRepository = appUserRepository;
this.mongoTemplate = mongoTemplate;
this.authTokenService = authTokenService;
}
public ActiveCatalogSummary activeCatalogSummary() {
@@ -83,7 +92,7 @@ public class CatalogService {
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).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();
}
public List<UserRow> listUsers() {
public List<UserRow> listUsers(String actorId) {
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
ensureDefaultUsers();
return appUserRepository.findAll().stream()
List<AppUser> users = actor.role() == UserRole.ADMIN
? appUserRepository.findAll()
: appUserRepository.findByAccountIdOrderByDisplayNameAsc(resolveAccountId(actor));
return users.stream()
.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();
}
public UserRow createOrUpdateUser(String actorId, UserMutation mutation) {
requireAdminActor(actorId);
if (isBlank(mutation.displayName()) || isBlank(mutation.code())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich");
AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt");
if (isBlank(mutation.displayName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername ist erforderlich");
}
LocalDateTime now = LocalDateTime.now();
validateUserMutation(mutation);
validateUserMutation(actor, mutation);
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(
null,
mutation.code().trim().toUpperCase(),
userId,
adminManaged ? userId : resolveAccountId(actor),
adminManaged,
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
null,
null,
null,
null,
adminManaged ? blankToNull(mutation.companyName()) : null,
adminManaged
? buildAddress(mutation.street(), mutation.houseNumber(), mutation.postalCode(), mutation.city())
: null,
adminManaged ? blankToNull(mutation.street()) : null,
adminManaged ? blankToNull(mutation.houseNumber()) : null,
adminManaged ? blankToNull(mutation.postalCode()) : null,
adminManaged ? blankToNull(mutation.city()) : null,
normalizeEmail(mutation.email()),
null,
blankToNull(mutation.portalLogin()),
adminManaged ? blankToNull(mutation.phoneNumber()) : null,
encodeIfPresent(mutation.password()),
mutation.active(),
normalizeManagedRole(mutation.role()),
adminManaged ? normalizeManagedRole(mutation.role()) : UserRole.CUSTOMER,
now,
now
));
@@ -416,41 +438,64 @@ public class CatalogService {
String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt");
AppUser existing = appUserRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
requireUserManagementAccess(actor, existing, true);
AppUser saved = appUserRepository.save(new AppUser(
existing.id(),
mutation.code().trim().toUpperCase(),
existing.accountId(),
isPrimaryUser(existing),
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.companyName()) : existing.companyName(),
buildAddress(
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.street() : existing.street(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.houseNumber() : existing.houseNumber(),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? mutation.postalCode() : existing.postalCode(),
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()),
existing.phoneNumber(),
blankToNull(mutation.portalLogin()),
isPrimaryUser(existing) || actor.role() == UserRole.ADMIN ? blankToNull(mutation.phoneNumber()) : existing.phoneNumber(),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
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(),
now
));
return toUserRow(saved);
}
public void deleteUser(String id) {
appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt"));
public void deleteUser(String actorId, String id) {
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)) {
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"));
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(
existing.id(),
existing.code(),
existing.accountId(),
isPrimaryUser(existing),
existing.displayName(),
existing.companyName(),
existing.address(),
@@ -460,7 +505,6 @@ public class CatalogService {
existing.city(),
existing.email(),
existing.phoneNumber(),
existing.portalLogin(),
passwordEncoder.encode(newPassword),
existing.active(),
existing.role(),
@@ -469,27 +513,19 @@ public class CatalogService {
));
}
public UserOption loginByCode(String code) {
AppUser user = activeQuickLoginUsers().stream()
.filter(candidate -> candidate.code().equalsIgnoreCase(code))
.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");
public SessionResponse loginWithPassword(String email, String password) {
if (isBlank(email) || isBlank(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "E-Mail und Passwort sind erforderlich");
}
AppUser user = resolvePasswordUser(identifier.trim())
AppUser user = resolvePasswordUser(email.trim())
.filter(AppUser::active)
.filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash()))
.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())
|| isBlank(mutation.street())
|| isBlank(mutation.houseNumber())
@@ -517,13 +553,12 @@ public class CatalogService {
String phoneNumber = mutation.phoneNumber().trim();
String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName);
LocalDateTime now = LocalDateTime.now();
AppUser created = appUserRepository.save(new AppUser(
UUID.randomUUID().toString(),
null,
code,
true,
displayName,
companyName,
address,
@@ -533,33 +568,47 @@ public class CatalogService {
city,
normalizedEmail,
phoneNumber,
portalLogin,
passwordEncoder.encode(mutation.password()),
true,
UserRole.CUSTOMER,
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() {
ensureDefaultUsers();
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc();
}
private List<AppUser> activeQuickLoginUsers() {
return activeUsers().stream()
.filter(user -> user.role() != UserRole.CUSTOMER)
.toList();
public UserOption currentUser(String actorId) {
AppUser user = appUserRepository.findById(requireText(actorId, "Benutzer-ID fehlt"))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Benutzer nicht authentifiziert"));
return toUserOption(user);
}
public void ensureDefaultUsers() {
migrateLegacyAppUsers();
ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN);
ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.CUSTOMER);
ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.CUSTOMER);
ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.CUSTOMER);
removeLegacyUserCodeField();
backfillDefaultUserEmails();
removeLegacyPortalLoginField();
ensureDefaultUser("Administrator", "admin@muh.local", "Admin123!", UserRole.ADMIN);
}
public Farmer requireActiveFarmer(String businessKey) {
@@ -632,13 +681,16 @@ public class CatalogService {
private UserRow toUserRow(AppUser user) {
return new UserRow(
user.id(),
user.code(),
isPrimaryUser(user),
user.displayName(),
user.companyName(),
resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.portalLogin(),
user.active(),
normalizeStoredRole(user.role()),
user.updatedAt()
@@ -664,17 +716,24 @@ public class CatalogService {
private UserOption toUserOption(AppUser user) {
return new UserOption(
user.id(),
user.code(),
isPrimaryUser(user),
user.displayName(),
user.companyName(),
resolveAddress(user),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.portalLogin(),
normalizeStoredRole(user.role())
);
}
private SessionResponse toSessionResponse(AppUser user) {
return new SessionResponse(authTokenService.createToken(user), toUserOption(user));
}
private String encodeIfPresent(String password) {
return isBlank(password) ? null : passwordEncoder.encode(password);
}
@@ -687,29 +746,16 @@ public class CatalogService {
return Objects.requireNonNull(sanitized);
}
private void validateUserMutation(UserMutation mutation) {
private void validateUserMutation(AppUser actor, UserMutation mutation) {
String normalizedEmail = normalizeEmail(mutation.email());
String normalizedLogin = blankToNull(mutation.portalLogin());
String normalizedCode = mutation.code().trim().toUpperCase(Locale.ROOT);
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
&& existing.email() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.email().equalsIgnoreCase(normalizedEmail)) {
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);
}
private Optional<AppUser> resolvePasswordUser(String identifier) {
return appUserRepository.findByEmailIgnoreCase(identifier)
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier));
private Optional<AppUser> resolvePasswordUser(String email) {
return appUserRepository.findByEmailIgnoreCase(normalizeEmail(email));
}
private void requireAdminActor(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen");
}
String actorIdValue = requireText(actorId, "Nur Administratoren koennen Benutzer anlegen");
AppUser actor = appUserRepository.findById(actorIdValue)
private AppUser requireActiveActor(String actorId, String message) {
return appUserRepository.findById(requireText(actorId, message))
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen"));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
}
if (actor.role() != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen");
private String resolveAccountId(AppUser user) {
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() {
LocalDateTime now = LocalDateTime.now();
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(
user.id(),
user.code(),
user.id(),
true,
user.displayName(),
user.companyName(),
user.address(),
@@ -761,34 +818,31 @@ public class CatalogService {
user.city(),
user.email(),
user.phoneNumber(),
user.portalLogin(),
user.passwordHash(),
user.active(),
UserRole.CUSTOMER,
normalizeStoredRole(user.role()),
user.createdAt(),
now
)));
}
private void ensureDefaultUser(
String code,
String displayName,
String email,
String portalLogin,
String rawPassword,
UserRole role
) {
boolean exists = appUserRepository.findByCodeIgnoreCase(code).isPresent()
|| appUserRepository.findByEmailIgnoreCase(email).isPresent()
|| appUserRepository.findByPortalLoginIgnoreCase(portalLogin).isPresent();
boolean exists = appUserRepository.findByEmailIgnoreCase(email).isPresent();
if (exists) {
return;
}
LocalDateTime now = LocalDateTime.now();
String userId = UUID.randomUUID().toString();
appUserRepository.save(new AppUser(
null,
code,
userId,
userId,
true,
displayName,
null,
null,
@@ -798,7 +852,6 @@ public class CatalogService {
null,
email,
null,
portalLogin,
passwordEncoder.encode(rawPassword),
true,
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) {
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());
}
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) {
String streetPart = joinParts(" ", street, houseNumber);
String cityPart = joinParts(" ", postalCode, city);
@@ -842,54 +937,6 @@ public class CatalogService {
.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(
List<FarmerOption> farmers,
List<MedicationOption> medications,
@@ -921,13 +968,16 @@ public class CatalogService {
public record UserOption(
String id,
String code,
boolean primaryUser,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
String portalLogin,
UserRole role
) {
}
@@ -987,13 +1037,16 @@ public class CatalogService {
public record UserRow(
String id,
String code,
boolean primaryUser,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String phoneNumber,
String portalLogin,
boolean active,
UserRole role,
LocalDateTime updatedAt
@@ -1002,12 +1055,15 @@ public class CatalogService {
public record UserMutation(
String id,
String code,
String displayName,
String companyName,
String address,
String street,
String houseNumber,
String postalCode,
String city,
String email,
String portalLogin,
String phoneNumber,
String password,
boolean active,
UserRole role
@@ -1025,4 +1081,7 @@ public class CatalogService {
String password
) {
}
public record SessionResponse(String token, UserOption user) {
}
}

View File

@@ -21,7 +21,15 @@ public class PortalService {
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()
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList();
@@ -51,7 +59,7 @@ public class PortalService {
matchingFarmers,
sampleRows,
reportService.reportCandidates(),
catalogService.listUsers()
includeUsers ? catalogService.listUsers(actorId) : List.of()
);
}

View File

@@ -175,7 +175,7 @@ public class ReportService {
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
&& actor.displayName() != null
&& !actor.companyName().equalsIgnoreCase(actor.displayName())

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.security.AuthenticatedUser;
import de.svencarstensen.muh.security.SecuritySupport;
import de.svencarstensen.muh.service.SampleService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -14,9 +16,11 @@ import org.springframework.web.bind.annotation.RestController;
public class SampleController {
private final SampleService sampleService;
private final SecuritySupport securitySupport;
public SampleController(SampleService sampleService) {
public SampleController(SampleService sampleService, SecuritySupport securitySupport) {
this.sampleService = sampleService;
this.securitySupport = securitySupport;
}
@GetMapping("/dashboard")
@@ -41,7 +45,17 @@ public class SampleController {
@PostMapping("/samples")
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")
@@ -63,4 +77,15 @@ public class SampleController {
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest 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;
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.PostMapping;
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 jakarta.validation.constraints.NotBlank;
import java.util.List;
@RestController
@RequestMapping("/api/session")
public class SessionController {
private final CatalogService catalogService;
private final SecuritySupport securitySupport;
public SessionController(CatalogService catalogService) {
public SessionController(CatalogService catalogService, SecuritySupport securitySupport) {
this.catalogService = catalogService;
this.securitySupport = securitySupport;
}
@GetMapping("/users")
public List<CatalogService.UserOption> activeUsers() {
return catalogService.activeCatalogSummary().users();
}
@PostMapping("/login")
public CatalogService.UserOption login(@RequestBody LoginRequest request) {
return catalogService.loginByCode(request.code());
@GetMapping("/me")
public CatalogService.UserOption currentUser() {
return catalogService.currentUser(securitySupport.currentUser().id());
}
@PostMapping("/password-login")
public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.identifier(), request.password());
public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.email(), request.password());
}
@PostMapping("/register")
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) {
public CatalogService.SessionResponse register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(),
request.street(),
@@ -49,10 +46,7 @@ public class SessionController {
));
}
public record LoginRequest(@NotBlank String code) {
}
public record PasswordLoginRequest(@NotBlank String identifier, @NotBlank String password) {
public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
}
public record RegistrationRequest(

View File

@@ -33,6 +33,9 @@ spring:
muh:
cors:
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:
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
username: ${MUH_MONGODB_USERNAME:}

View File

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

View File

@@ -23,6 +23,9 @@ function resolvePageTitle(pathname: string) {
if (pathname.startsWith("/admin/landwirte")) {
return "Verwaltung | Landwirte";
}
if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) {
return "Verwaltung | Medikamente";
}
@@ -45,7 +48,7 @@ function resolvePageTitle(pathname: string) {
}
export default function AppShell() {
const { user, setUser } = useSession();
const { user, setSession } = useSession();
const location = useLocation();
const navigate = useNavigate();
@@ -76,6 +79,9 @@ export default function AppShell() {
<div className="nav-group">
<div className="nav-group__label">Verwaltung</div>
<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" : ""}`}>
Landwirte
</NavLink>
@@ -116,13 +122,13 @@ export default function AppShell() {
<div className="sidebar__footer">
<div className="user-chip user-chip--stacked">
<span>{user?.displayName}</span>
<small>{user?.code}</small>
<small>{user?.email ?? user?.role}</small>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setUser(null);
setSession(null);
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");
@@ -30,22 +30,16 @@ async function readErrorMessage(response: Response): Promise<string> {
return `Anfrage fehlgeschlagen (${response.status})`;
}
function actorHeaders(): Record<string, string> {
function authHeaders(): Record<string, string> {
if (typeof window === "undefined") {
return {};
}
const rawUser = window.localStorage.getItem(USER_STORAGE_KEY);
if (!rawUser) {
return {};
}
try {
const user = JSON.parse(rawUser) as { id?: string | null };
return user.id ? { "X-MUH-Actor-Id": user.id } : {};
} catch {
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!token) {
return {};
}
return { Authorization: `Bearer ${token}` };
}
async function handleResponse<T>(response: Response): Promise<T> {
@@ -55,13 +49,17 @@ async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
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> {
return handleResponse<T>(
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",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -85,7 +83,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
method: "PUT",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -98,7 +96,7 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...actorHeaders(),
...authHeaders(),
},
body: JSON.stringify(body),
}),
@@ -109,7 +107,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, {
method: "DELETE",
headers: actorHeaders(),
headers: authHeaders(),
}),
);
}

View File

@@ -6,17 +6,20 @@ import {
useState,
type PropsWithChildren,
} from "react";
import { USER_STORAGE_KEY } from "./storage";
import type { UserOption } from "./types";
import { apiGet } from "./api";
import { AUTH_TOKEN_STORAGE_KEY, USER_STORAGE_KEY } from "./storage";
import type { SessionResponse, UserOption } from "./types";
interface SessionContextValue {
user: UserOption | null;
setUser: (user: UserOption | null) => void;
ready: boolean;
setSession: (session: SessionResponse | null) => void;
}
const SessionContext = createContext<SessionContextValue>({
user: null,
setUser: () => undefined,
ready: false,
setSession: () => undefined,
});
function loadStoredUser(): UserOption | null {
@@ -33,6 +36,39 @@ function loadStoredUser(): UserOption | null {
export function SessionProvider({ children }: PropsWithChildren) {
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(() => {
if (user) {
@@ -42,12 +78,26 @@ export function SessionProvider({ children }: PropsWithChildren) {
window.localStorage.removeItem(USER_STORAGE_KEY);
}, [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(
() => ({
user,
setUser: setUserState,
ready,
setSession,
}),
[user],
[ready, user],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;

View File

@@ -1 +1,2 @@
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 {
id: string;
code: string;
primaryUser: boolean;
displayName: string;
companyName: string | null;
address: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
email: string | null;
phoneNumber: string | null;
portalLogin: string | null;
role: UserRole;
}
export interface SessionResponse {
token: string;
user: UserOption;
}
export interface UserRow extends UserOption {
active: boolean;
updatedAt: string;

View File

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

View File

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

View File

@@ -99,7 +99,7 @@ export default function SampleRegistrationPage() {
sampleKind,
samplingMode,
flaggedQuarters,
userCode: user.code,
userCode: 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>
);
}