diff --git a/backend/pom.xml b/backend/pom.xml index 15bfaca..65efa8e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -37,6 +37,10 @@ org.springframework.boot spring-boot-starter-mail + + org.springframework.boot + spring-boot-starter-security + org.springframework.security spring-security-crypto diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java index 09c4607..72c5449 100644 --- a/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java +++ b/backend/src/main/java/de/svencarstensen/muh/domain/AppUser.java @@ -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, diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java index a32cfc9..06f475c 100644 --- a/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java +++ b/backend/src/main/java/de/svencarstensen/muh/repository/AppUserRepository.java @@ -9,9 +9,7 @@ import java.util.Optional; public interface AppUserRepository extends MongoRepository { List findByActiveTrueOrderByDisplayNameAsc(); - Optional findByCodeIgnoreCase(String code); + List findByAccountIdOrderByDisplayNameAsc(String accountId); Optional findByEmailIgnoreCase(String email); - - Optional findByPortalLoginIgnoreCase(String portalLogin); } diff --git a/backend/src/main/java/de/svencarstensen/muh/security/AuthTokenService.java b/backend/src/main/java/de/svencarstensen/muh/security/AuthTokenService.java new file mode 100644 index 0000000..faccb1a --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/security/AuthTokenService.java @@ -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); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/security/AuthenticatedUser.java b/backend/src/main/java/de/svencarstensen/muh/security/AuthenticatedUser.java new file mode 100644 index 0000000..35bd4f7 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/security/AuthenticatedUser.java @@ -0,0 +1,6 @@ +package de.svencarstensen.muh.security; + +import de.svencarstensen.muh.domain.UserRole; + +public record AuthenticatedUser(String id, String displayName, UserRole role) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java new file mode 100644 index 0000000..6d75ca7 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/security/BearerTokenAuthenticationFilter.java @@ -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); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java b/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java new file mode 100644 index 0000000..2b9c6ce --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/security/SecurityConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/security/SecuritySupport.java b/backend/src/main/java/de/svencarstensen/muh/security/SecuritySupport.java new file mode 100644 index 0000000..006f9e1 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/security/SecuritySupport.java @@ -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; + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java index ef70667..97a1d27 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/CatalogService.java @@ -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 listUsers() { + public List listUsers(String actorId) { + AppUser actor = requireActiveActor(actorId, "Benutzer nicht berechtigt"); ensureDefaultUsers(); - return appUserRepository.findAll().stream() + List 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 activeUsers() { - ensureDefaultUsers(); - return appUserRepository.findByActiveTrueOrderByDisplayNameAsc(); - } - - private List 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 resolvePasswordUser(String identifier) { - return appUserRepository.findByEmailIgnoreCase(identifier) - .or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier)); + private Optional 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 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 farmers, List 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) { + } } diff --git a/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java index f81d8ab..4f32846 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/PortalService.java @@ -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 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() ); } diff --git a/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java index f553711..1bf5e0d 100644 --- a/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java +++ b/backend/src/main/java/de/svencarstensen/muh/service/ReportService.java @@ -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()) diff --git a/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java index df38fdb..0a1cce4 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/CatalogController.java @@ -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 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) { diff --git a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java index 2d36743..01f5ed4 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/PortalController.java @@ -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") diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java b/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java index 6f89bcb..b33ca87 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/SampleController.java @@ -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())); + } } diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java index 737f2d5..90aea77 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/SessionController.java @@ -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 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( diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ea916e8..9a953c3 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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:} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5fc4ae7..e77a182 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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
Sitzung wird geladen ...
; + } if (!user) { return ; @@ -30,6 +35,7 @@ function ProtectedRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -46,7 +52,10 @@ function ProtectedRoutes() { } function ApplicationRouter() { - const { user } = useSession(); + const { user, ready } = useSession(); + if (!ready) { + return
Sitzung wird geladen ...
; + } if (!user) { return ( diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index 3bbe1bc..b82e1db 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -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() {
Verwaltung
+ `nav-sublink ${isActive ? "is-active" : ""}`}> + Benutzer + `nav-sublink ${isActive ? "is-active" : ""}`}> Landwirte @@ -116,13 +122,13 @@ export default function AppShell() {
{user?.displayName} - {user?.code} + {user?.email ?? user?.role}
+
+ + ) : null} + +
+
+
+

Benutzer

+

Benutzer anlegen

+
+
+ + + + + + + {isAdmin ? ( + + ) : null} +
+ +
+ + +
+ +
+
+
+

Benutzerliste

+

Bereits angelegte Benutzer

+
+
+ + {secondaryUsers.length === 0 ? ( +
Noch keine weiteren Benutzer vorhanden.
+ ) : ( +
+ + + + + + + + + + + {secondaryUsers.map((entry) => ( + + + + + + + + ))} + +
NameE-MailPasswortAktiv +
+ + updateExistingUser(entry.id, { displayName: event.target.value }) + } + /> + + updateExistingUser(entry.id, { email: event.target.value })} + /> + + + updateExistingUser(entry.id, { password: event.target.value }) + } + placeholder="Neues Passwort" + /> + + + + + +
+
+ )} +
+
+ ); +}