Add customer registration and deployment updates

This commit is contained in:
2026-03-12 16:02:16 +01:00
parent fb8e3c8ef6
commit 8ebb4d06e5
23 changed files with 851 additions and 425 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.DS_Store
.vscode
backend/target
frontend/dist
frontend/node_modules

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ backend/target/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
.DS_Store .DS_Store
.env

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:20-alpine AS frontend-build
WORKDIR /build/frontend
ARG VITE_API_URL=/api
ENV VITE_API_URL=${VITE_API_URL}
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM maven:3.9.11-eclipse-temurin-17 AS backend-build
WORKDIR /build/backend
COPY backend/pom.xml ./
RUN mvn -B -q -DskipTests dependency:go-offline
COPY backend/ ./
COPY --from=frontend-build /build/frontend/dist ./src/main/resources/static
RUN mvn -B -q -DskipTests package
FROM eclipse-temurin:17-jre-alpine AS runtime
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=backend-build /build/backend/target/muh-backend-0.0.1-SNAPSHOT.jar /app/app.jar
USER spring:spring
EXPOSE 8090
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

View File

@@ -10,18 +10,26 @@ Therapieempfehlungen sowie Verwaltungs- und Portalaufgaben.
## Konfiguration ## Konfiguration
MongoDB ist bereits im Backend vorkonfiguriert: Die Anwendung liest Konfigurationswerte aus einer `.env` im Projektverzeichnis
oder aus Umgebungsvariablen.
- `mongodb://192.168.180.25:27017/muh` Die lokale `.env` ist in `.gitignore` eingetragen und sollte nicht mit echten Zugangsdaten committed werden.
MongoDB:
- `MUH_MONGODB_URL`
- `MUH_MONGODB_USERNAME`
- `MUH_MONGODB_PASSWORD`
Optional fuer echten Mailversand im Portal: Optional fuer echten Mailversand im Portal:
- `MUH_MAIL_ENABLED=true` - `MUH_MAIL_ENABLED=true`
- `MUH_MAIL_FROM=...`
- `MUH_MAIL_HOST=...` - `MUH_MAIL_HOST=...`
- `MUH_MAIL_PORT=587` - `MUH_MAIL_PORT=587`
- `MUH_MAIL_USERNAME=...` - `MUH_MAIL_USERNAME=...`
- `MUH_MAIL_PASSWORD=...` - `MUH_MAIL_PASSWORD=...`
- `MUH_MAIL_FROM=...` - `MUH_MAIL_PROTOCOL=smtp`
- `MUH_MAIL_AUTH=true` - `MUH_MAIL_AUTH=true`
- `MUH_MAIL_STARTTLS=true` - `MUH_MAIL_STARTTLS=true`
@@ -52,13 +60,34 @@ Frontend-URL:
Optional kann die API-URL im Frontend ueber `VITE_API_URL` gesetzt werden. Optional kann die API-URL im Frontend ueber `VITE_API_URL` gesetzt werden.
## Docker Deployment
Produktions-Image bauen:
```bash
docker build -t muh-app .
```
Container starten:
```bash
docker run --rm --env-file .env -p 8090:8090 muh-app
```
Die Anwendung ist danach unter `http://localhost:8090` erreichbar.
Hinweis:
- Das Dockerfile baut das React-Frontend und das Spring-Boot-Backend in einem Image.
- Das Frontend wird im Container direkt ueber Spring Boot ausgeliefert.
- API-Aufrufe laufen in Produktion relativ ueber `/api`.
## Anmeldung ## Anmeldung
Es gibt jetzt drei Varianten: Es gibt jetzt zwei Varianten:
- Schnelllogin ueber Benutzerkuerzel
- Login ueber E-Mail oder Benutzername plus Passwort - Login ueber E-Mail oder Benutzername plus Passwort
- Registrierung eines neuen Kundenkontos ueber Firmenname, Adresse, E-Mail und Passwort - Registrierung eines neuen Kundenkontos ueber Firmenname, Strasse, Hausnummer, PLZ, Ort, E-Mail und Passwort
Vordefinierter Admin: Vordefinierter Admin:

View File

@@ -0,0 +1,37 @@
package de.svencarstensen.muh.config;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.MongoCredential;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
@Configuration
public class MongoConfig {
@Bean
MongoClient mongoClient(
@Value("${muh.mongodb.url}") String mongoUrl,
@Value("${muh.mongodb.username:}") String username,
@Value("${muh.mongodb.password:}") String password
) {
ConnectionString connectionString = new ConnectionString(mongoUrl);
MongoClientSettings.Builder builder = MongoClientSettings.builder()
.applyConnectionString(connectionString);
if (StringUtils.hasText(username)) {
String authDatabase = connectionString.getDatabase() == null ? "admin" : connectionString.getDatabase();
builder.credential(MongoCredential.createCredential(
username.trim(),
authDatabase,
password == null ? new char[0] : password.toCharArray()
));
}
return MongoClients.create(builder.build());
}
}

View File

@@ -12,7 +12,12 @@ public record AppUser(
String displayName, String displayName,
String companyName, String companyName,
String address, String address,
String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
String portalLogin, String portalLogin,
String passwordHash, String passwordHash,
boolean active, boolean active,

View File

@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -383,7 +384,8 @@ public class CatalogService {
.toList(); .toList();
} }
public UserRow createOrUpdateUser(UserMutation mutation) { public UserRow createOrUpdateUser(String actorId, UserMutation mutation) {
requireAdminActor(actorId);
if (isBlank(mutation.displayName()) || isBlank(mutation.code())) { if (isBlank(mutation.displayName()) || isBlank(mutation.code())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich");
} }
@@ -396,11 +398,16 @@ public class CatalogService {
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), blankToNull(mutation.companyName()),
blankToNull(mutation.address()), blankToNull(mutation.address()),
null,
null,
null,
null,
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
null,
blankToNull(mutation.portalLogin()), blankToNull(mutation.portalLogin()),
encodeIfPresent(mutation.password()), encodeIfPresent(mutation.password()),
mutation.active(), mutation.active(),
Optional.ofNullable(mutation.role()).orElse(UserRole.APP), normalizeManagedRole(mutation.role()),
now, now,
now now
)); ));
@@ -415,11 +422,16 @@ public class CatalogService {
mutation.displayName().trim(), mutation.displayName().trim(),
blankToNull(mutation.companyName()), blankToNull(mutation.companyName()),
blankToNull(mutation.address()), blankToNull(mutation.address()),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
normalizeEmail(mutation.email()), normalizeEmail(mutation.email()),
existing.phoneNumber(),
blankToNull(mutation.portalLogin()), blankToNull(mutation.portalLogin()),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()), isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(), mutation.active(),
Optional.ofNullable(mutation.role()).orElse(existing.role()), mutation.role() == null ? normalizeStoredRole(existing.role()) : normalizeManagedRole(mutation.role()),
existing.createdAt(), existing.createdAt(),
now now
)); ));
@@ -442,7 +454,12 @@ public class CatalogService {
existing.displayName(), existing.displayName(),
existing.companyName(), existing.companyName(),
existing.address(), existing.address(),
existing.street(),
existing.houseNumber(),
existing.postalCode(),
existing.city(),
existing.email(), existing.email(),
existing.phoneNumber(),
existing.portalLogin(), existing.portalLogin(),
passwordEncoder.encode(newPassword), passwordEncoder.encode(newPassword),
existing.active(), existing.active(),
@@ -473,8 +490,18 @@ public class CatalogService {
} }
public UserOption registerCustomer(RegistrationMutation mutation) { public UserOption registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) || isBlank(mutation.address()) || isBlank(mutation.email()) || isBlank(mutation.password())) { if (isBlank(mutation.companyName())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Firmenname, Adresse, E-Mail und Passwort sind erforderlich"); || isBlank(mutation.street())
|| isBlank(mutation.houseNumber())
|| isBlank(mutation.postalCode())
|| isBlank(mutation.city())
|| isBlank(mutation.email())
|| isBlank(mutation.phoneNumber())
|| isBlank(mutation.password())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Firmenname, Strasse, Hausnummer, PLZ, Ort, E-Mail, Telefonnummer und Passwort sind erforderlich"
);
} }
String normalizedEmail = normalizeEmail(mutation.email()); String normalizedEmail = normalizeEmail(mutation.email());
@@ -483,7 +510,12 @@ public class CatalogService {
} }
String companyName = mutation.companyName().trim(); String companyName = mutation.companyName().trim();
String address = mutation.address().trim(); String street = mutation.street().trim();
String houseNumber = mutation.houseNumber().trim();
String postalCode = mutation.postalCode().trim();
String city = mutation.city().trim();
String phoneNumber = mutation.phoneNumber().trim();
String address = formatAddress(street, houseNumber, postalCode, city);
String displayName = companyName; String displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail)); String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName); String code = generateUniqueCode("K" + companyName);
@@ -495,7 +527,12 @@ public class CatalogService {
displayName, displayName,
companyName, companyName,
address, address,
street,
houseNumber,
postalCode,
city,
normalizedEmail, normalizedEmail,
phoneNumber,
portalLogin, portalLogin,
passwordEncoder.encode(mutation.password()), passwordEncoder.encode(mutation.password()),
true, true,
@@ -518,10 +555,11 @@ public class CatalogService {
} }
public void ensureDefaultUsers() { public void ensureDefaultUsers() {
migrateLegacyAppUsers();
ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN); ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN);
ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.APP); ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.CUSTOMER);
ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.APP); ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.CUSTOMER);
ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.APP); ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.CUSTOMER);
} }
public Farmer requireActiveFarmer(String businessKey) { public Farmer requireActiveFarmer(String businessKey) {
@@ -597,11 +635,12 @@ public class CatalogService {
user.code(), user.code(),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
user.address(), resolveAddress(user),
user.email(), user.email(),
user.phoneNumber(),
user.portalLogin(), user.portalLogin(),
user.active(), user.active(),
user.role(), normalizeStoredRole(user.role()),
user.updatedAt() user.updatedAt()
); );
} }
@@ -628,10 +667,11 @@ public class CatalogService {
user.code(), user.code(),
user.displayName(), user.displayName(),
user.companyName(), user.companyName(),
user.address(), resolveAddress(user),
user.email(), user.email(),
user.phoneNumber(),
user.portalLogin(), user.portalLogin(),
user.role() normalizeStoredRole(user.role())
); );
} }
@@ -690,6 +730,46 @@ public class CatalogService {
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier)); .or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier));
} }
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)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen"));
if (actor.role() != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nur Administratoren koennen Benutzer anlegen");
}
}
private void migrateLegacyAppUsers() {
LocalDateTime now = LocalDateTime.now();
appUserRepository.findAll().stream()
.filter(user -> user.role() == UserRole.APP)
.forEach(user -> appUserRepository.save(new AppUser(
user.id(),
user.code(),
user.displayName(),
user.companyName(),
user.address(),
user.street(),
user.houseNumber(),
user.postalCode(),
user.city(),
user.email(),
user.phoneNumber(),
user.portalLogin(),
user.passwordHash(),
user.active(),
UserRole.CUSTOMER,
user.createdAt(),
now
)));
}
private void ensureDefaultUser( private void ensureDefaultUser(
String code, String code,
String displayName, String displayName,
@@ -712,7 +792,12 @@ public class CatalogService {
displayName, displayName,
null, null,
null, null,
null,
null,
null,
null,
email, email,
null,
portalLogin, portalLogin,
passwordEncoder.encode(rawPassword), passwordEncoder.encode(rawPassword),
true, true,
@@ -726,6 +811,37 @@ public class CatalogService {
return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT); return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT);
} }
private UserRole normalizeStoredRole(UserRole role) {
return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
}
private UserRole normalizeManagedRole(UserRole role) {
return role == null || role == UserRole.APP ? UserRole.CUSTOMER : role;
}
private String resolveAddress(AppUser user) {
if (!isBlank(user.address())) {
return user.address();
}
if (isBlank(user.street()) && isBlank(user.houseNumber()) && isBlank(user.postalCode()) && isBlank(user.city())) {
return null;
}
return formatAddress(user.street(), user.houseNumber(), user.postalCode(), user.city());
}
private String formatAddress(String street, String houseNumber, String postalCode, String city) {
String streetPart = joinParts(" ", street, houseNumber);
String cityPart = joinParts(" ", postalCode, city);
return joinParts(", ", streetPart, cityPart);
}
private String joinParts(String separator, String... parts) {
return Arrays.stream(parts)
.filter(part -> !isBlank(part))
.map(String::trim)
.collect(Collectors.joining(separator));
}
private String localPart(String email) { private String localPart(String email) {
int separator = email.indexOf('@'); int separator = email.indexOf('@');
return separator >= 0 ? email.substring(0, separator) : email; return separator >= 0 ? email.substring(0, separator) : email;
@@ -810,6 +926,7 @@ public class CatalogService {
String companyName, String companyName,
String address, String address,
String email, String email,
String phoneNumber,
String portalLogin, String portalLogin,
UserRole role UserRole role
) { ) {
@@ -875,6 +992,7 @@ public class CatalogService {
String companyName, String companyName,
String address, String address,
String email, String email,
String phoneNumber,
String portalLogin, String portalLogin,
boolean active, boolean active,
UserRole role, UserRole role,
@@ -898,8 +1016,12 @@ public class CatalogService {
public record RegistrationMutation( public record RegistrationMutation(
String companyName, String companyName,
String address, String street,
String houseNumber,
String postalCode,
String city,
String email, String email,
String phoneNumber,
String password String password
) { ) {
} }

View File

@@ -1,42 +1,56 @@
package de.svencarstensen.muh.service; package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.Sample; import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.repository.AppUserRepository;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service @Service
public class ReportService { public class ReportService {
private final SampleService sampleService; private final SampleService sampleService;
private final AppUserRepository appUserRepository;
private final ObjectProvider<JavaMailSender> mailSenderProvider; private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final boolean mailEnabled; private final boolean mailEnabled;
private final String mailFrom; private final String mailFrom;
private final String reportMailTemplate;
public ReportService( public ReportService(
SampleService sampleService, SampleService sampleService,
AppUserRepository appUserRepository,
ObjectProvider<JavaMailSender> mailSenderProvider, ObjectProvider<JavaMailSender> mailSenderProvider,
@Value("classpath:mail/report-mail-template.txt") Resource reportMailTemplateResource,
@Value("${muh.mail.enabled:false}") boolean mailEnabled, @Value("${muh.mail.enabled:false}") boolean mailEnabled,
@Value("${muh.mail.from:no-reply@muh.local}") String mailFrom @Value("${muh.mail.from:no-reply@muh.local}") String mailFrom
) { ) {
this.sampleService = sampleService; this.sampleService = sampleService;
this.appUserRepository = appUserRepository;
this.mailSenderProvider = mailSenderProvider; this.mailSenderProvider = mailSenderProvider;
this.mailEnabled = mailEnabled; this.mailEnabled = mailEnabled;
this.mailFrom = mailFrom; this.mailFrom = mailFrom;
this.reportMailTemplate = loadTemplate(reportMailTemplateResource);
} }
public List<ReportCandidate> reportCandidates() { public List<ReportCandidate> reportCandidates() {
@@ -46,7 +60,8 @@ public class ReportService {
.toList(); .toList();
} }
public DispatchResult sendReports(List<String> sampleIds) { public DispatchResult sendReports(String actorId, List<String> sampleIds) {
String customerSignature = buildCustomerSignature(actorId);
List<ReportCandidate> sent = new ArrayList<>(); List<ReportCandidate> sent = new ArrayList<>();
List<ReportCandidate> skipped = new ArrayList<>(); List<ReportCandidate> skipped = new ArrayList<>();
@@ -59,7 +74,7 @@ public class ReportService {
byte[] pdf = buildPdf(sample); byte[] pdf = buildPdf(sample);
if (mailEnabled && mailSenderProvider.getIfAvailable() != null) { if (mailEnabled && mailSenderProvider.getIfAvailable() != null) {
sendMail(sample, pdf); sendMail(sample, pdf, customerSignature);
} }
Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now()); Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now());
sent.add(toCandidate(updated)); sent.add(toCandidate(updated));
@@ -75,7 +90,7 @@ public class ReportService {
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id()); return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id());
} }
private void sendMail(Sample sample, byte[] pdf) { private void sendMail(Sample sample, byte[] pdf, String customerSignature) {
try { try {
JavaMailSender sender = mailSenderProvider.getIfAvailable(); JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) { if (sender == null) {
@@ -86,7 +101,7 @@ public class ReportService {
helper.setFrom(requireText(mailFrom, "Absender fehlt")); helper.setFrom(requireText(mailFrom, "Absender fehlt"));
helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt")); helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt"));
helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber()); helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber());
helper.setText("Im Anhang befindet sich der Bericht zur Probe " + sample.sampleNumber() + ".", false); helper.setText(buildMailBody(sample, customerSignature), false);
helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf))); helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf)));
sender.send(message); sender.send(message);
} catch (Exception exception) { } catch (Exception exception) {
@@ -136,6 +151,78 @@ public class ReportService {
return value == null || value.isBlank() ? "-" : value; return value == null || value.isBlank() ? "-" : value;
} }
private String buildMailBody(Sample sample, String customerSignature) {
return reportMailTemplate
.replace("{{sampleNumber}}", String.valueOf(sample.sampleNumber()))
.replace("{{cowLabel}}", resolveCowLabel(sample))
.replace("{{customerSignature}}", customerSignature);
}
private String resolveCowLabel(Sample sample) {
if (sample.cowName() == null || sample.cowName().isBlank()) {
return sample.cowNumber();
}
return sample.cowNumber() + " / " + sample.cowName();
}
private String buildCustomerSignature(String actorId) {
if (actorId == null || actorId.isBlank()) {
return defaultCustomerSignature();
}
AppUser actor = appUserRepository.findById(actorId.trim()).orElse(null);
if (actor == null) {
return defaultCustomerSignature();
}
String primaryName = firstNonBlank(actor.companyName(), actor.displayName(), actor.code(), "MUH App");
String secondaryName = actor.companyName() != null
&& actor.displayName() != null
&& !actor.companyName().equalsIgnoreCase(actor.displayName())
? actor.displayName()
: null;
String streetLine = joinParts(" ", actor.street(), actor.houseNumber());
String cityLine = joinParts(" ", actor.postalCode(), actor.city());
return Stream.of(
primaryName,
secondaryName,
streetLine,
cityLine,
actor.email(),
actor.phoneNumber()
)
.filter(part -> part != null && !part.isBlank())
.collect(Collectors.joining(System.lineSeparator()));
}
private String defaultCustomerSignature() {
return firstNonBlank(mailFrom, "MUH App");
}
private String joinParts(String separator, String... values) {
return Stream.of(values)
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.collect(Collectors.joining(separator));
}
private String firstNonBlank(String... values) {
return Stream.of(values)
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.findFirst()
.orElse("");
}
private String loadTemplate(Resource resource) {
try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
} catch (IOException exception) {
throw new IllegalStateException("Mail-Template konnte nicht geladen werden", exception);
}
}
private @NonNull String requireText(String value, String message) { private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);

View File

@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -57,8 +58,11 @@ public class CatalogController {
} }
@PostMapping("/portal/users") @PostMapping("/portal/users")
public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) { public CatalogService.UserRow saveUser(
return catalogService.createOrUpdateUser(mutation); @RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId,
@RequestBody CatalogService.UserMutation mutation
) {
return catalogService.createOrUpdateUser(actorId, mutation);
} }
@DeleteMapping("/portal/users/{id}") @DeleteMapping("/portal/users/{id}")

View File

@@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -49,8 +50,11 @@ public class PortalController {
} }
@PostMapping("/reports/send") @PostMapping("/reports/send")
public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) { public ReportService.DispatchResult send(
return reportService.sendReports(request.sampleIds()); @RequestHeader(value = "X-MUH-Actor-Id", required = false) String actorId,
@RequestBody ReportDispatchRequest request
) {
return reportService.sendReports(actorId, request.sampleIds());
} }
@PatchMapping("/reports/{sampleId}/block") @PatchMapping("/reports/{sampleId}/block")

View File

@@ -39,8 +39,12 @@ public class SessionController {
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) { public CatalogService.UserOption register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation( return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(), request.companyName(),
request.address(), request.street(),
request.houseNumber(),
request.postalCode(),
request.city(),
request.email(), request.email(),
request.phoneNumber(),
request.password() request.password()
)); ));
} }
@@ -53,8 +57,12 @@ public class SessionController {
public record RegistrationRequest( public record RegistrationRequest(
@NotBlank String companyName, @NotBlank String companyName,
@NotBlank String address, @NotBlank String street,
@NotBlank String houseNumber,
@NotBlank String postalCode,
@NotBlank String city,
@NotBlank String email, @NotBlank String email,
@NotBlank String phoneNumber,
@NotBlank String password @NotBlank String password
) { ) {
} }

View File

@@ -0,0 +1,18 @@
package de.svencarstensen.muh.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SpaForwardController {
@GetMapping({
"/",
"/{path:^(?!api$)[^.]+}",
"/{path:^(?!api$)[^.]+}/{segment:[^.]+}",
"/{path:^(?!api$)[^.]+}/{segment:[^.]+}/{leaf:[^.]+}"
})
public String forward() {
return "forward:/index.html";
}
}

View File

@@ -1,12 +1,18 @@
server: server:
port: 8090 port: 8090
error:
include-message: always
spring: spring:
config:
import:
- optional:file:./.env[.properties]
- optional:file:../.env[.properties]
application: application:
name: muh-backend name: muh-backend
data: data:
mongodb: mongodb:
uri: mongodb://192.168.180.25:27017/muh uri: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
jackson: jackson:
time-zone: Europe/Berlin time-zone: Europe/Berlin
mail: mail:
@@ -14,8 +20,11 @@ spring:
port: ${MUH_MAIL_PORT:587} port: ${MUH_MAIL_PORT:587}
username: ${MUH_MAIL_USERNAME:} username: ${MUH_MAIL_USERNAME:}
password: ${MUH_MAIL_PASSWORD:} password: ${MUH_MAIL_PASSWORD:}
protocol: ${MUH_MAIL_PROTOCOL:smtp}
properties: properties:
mail: mail:
transport:
protocol: ${MUH_MAIL_PROTOCOL:smtp}
smtp: smtp:
auth: ${MUH_MAIL_AUTH:false} auth: ${MUH_MAIL_AUTH:false}
starttls: starttls:
@@ -24,6 +33,10 @@ spring:
muh: muh:
cors: cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000} allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
mongodb:
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
username: ${MUH_MONGODB_USERNAME:}
password: ${MUH_MONGODB_PASSWORD:}
mail: mail:
enabled: ${MUH_MAIL_ENABLED:false} enabled: ${MUH_MAIL_ENABLED:false}
from: ${MUH_MAIL_FROM:no-reply@muh.local} from: ${MUH_MAIL_FROM:no-reply@muh.local}

View File

@@ -0,0 +1,39 @@
Anbei finden Sie die Milchprobenauswertung Nr. {{sampleNumber}} fuer Ihre Kuh {{cowLabel}}.
Dies ist eine automatisch generierte E-Mail von einer System-E-Mail-Adresse, bitte nicht antworten.
Mit freundlichen Gruessen
{{customerSignature}}
Liste der Handelsnamen und Wirkstoffe
Laktation
Penicillin: Mastinject, Revozyn RTU, Penicillin Injektoren, Ingel Mamyzin
Amoxicillin Clavulansaeure: Synulox RTU/LC, Noroclav
Sulfadoxin Trimetoprim: Diatrim, Sulphix
Lincomycin Neomycin: Albiotic
Tylosin: Pharmasin
Cefquinom: Cobactan
Cefoperacon: Peracef
Enrofloxacin: Powerflox
Cefalexin/Kanamycin: Ubrolexin
Ampicillin/Cloxacillin: Gelstamp
Trockensteher
Framycetin: Benestermycin
Cefalonium: Cepravin
Penicillin: Mastinject, Revozyn RTU, Penicillin Injektoren, Naphpenzal T
Cloxacillin: Orbenin und Cloxacillin
Amoxicillin Clavulansaeure: Synulox RTU/LC
Sulfadoxin Trimetoprim: Diatrim
Lincomycin Neomycin: Albiotic
Tylosin: Pharmasin
Cefquinom: Cobactan
Cefoperacon: Peracef
Enrofloxacin: Powerflox

View File

@@ -1,13 +1,6 @@
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
const NAV_ITEMS = [
{ to: "/home", label: "Start" },
{ to: "/samples/new", label: "Neue Probe" },
{ to: "/admin", label: "Verwaltung" },
{ to: "/portal", label: "Portal" },
];
const PAGE_TITLES: Record<string, string> = { const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite", "/home": "Startseite",
"/samples/new": "Neuanlage einer Probe", "/samples/new": "Neuanlage einer Probe",
@@ -35,6 +28,14 @@ export default function AppShell() {
const { user, setUser } = useSession(); const { user, setUser } = useSession();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const navItems = user?.role === "ADMIN"
? [{ to: "/portal", label: "Benutzerverwaltung" }]
: [
{ to: "/home", label: "Start" },
{ to: "/samples/new", label: "Neue Probe" },
{ to: "/admin", label: "Verwaltung" },
{ to: "/portal", label: "Portal" },
];
return ( return (
<div className="app-shell"> <div className="app-shell">
@@ -44,7 +45,7 @@ export default function AppShell() {
</div> </div>
<nav className="sidebar__nav"> <nav className="sidebar__nav">
{NAV_ITEMS.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -78,12 +79,6 @@ export default function AppShell() {
<div className="topbar__headline"> <div className="topbar__headline">
<h2>{resolvePageTitle(location.pathname)}</h2> <h2>{resolvePageTitle(location.pathname)}</h2>
</div> </div>
<div className="topbar__actions">
<button type="button" className="accent-button" onClick={() => navigate("/samples/new")}>
Neuanlage
</button>
</div>
</header> </header>
<main className="content-area"> <main className="content-area">

View File

@@ -1,9 +1,56 @@
const API_ROOT = import.meta.env.VITE_API_URL ?? "http://localhost:8090/api"; import { USER_STORAGE_KEY } from "./storage";
const API_ROOT = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? "http://localhost:8090/api" : "/api");
type ApiErrorPayload = {
message?: string;
error?: string;
detail?: string;
};
async function readErrorMessage(response: Response): Promise<string> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const payload = (await response.json()) as ApiErrorPayload;
const message = payload.message?.trim() || payload.detail?.trim();
if (message) {
return message;
}
if (payload.error?.trim()) {
return `Anfrage fehlgeschlagen: ${payload.error.trim()}`;
}
}
const text = (await response.text()).trim();
if (text) {
return text;
}
return `Anfrage fehlgeschlagen (${response.status})`;
}
function actorHeaders(): 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 {
return {};
}
}
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); throw new Error(await readErrorMessage(response));
throw new Error(text || "Unbekannter API-Fehler");
} }
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T;
@@ -12,14 +59,21 @@ async function handleResponse<T>(response: Response): Promise<T> {
} }
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
return handleResponse<T>(await fetch(`${API_ROOT}${path}`)); return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, {
headers: actorHeaders(),
}),
);
} }
export async function apiPost<T>(path: string, body: unknown): Promise<T> { export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...actorHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -29,7 +83,10 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...actorHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -39,7 +96,10 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
return handleResponse<T>( return handleResponse<T>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...actorHeaders(),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
); );
@@ -49,6 +109,7 @@ export async function apiDelete(path: string): Promise<void> {
await handleResponse<void>( await handleResponse<void>(
await fetch(`${API_ROOT}${path}`, { await fetch(`${API_ROOT}${path}`, {
method: "DELETE", method: "DELETE",
headers: actorHeaders(),
}), }),
); );
} }

View File

@@ -16,7 +16,7 @@ export type MedicationCategory =
| "SYSTEMIC_PAIN" | "SYSTEMIC_PAIN"
| "DRY_SEALER" | "DRY_SEALER"
| "DRY_ANTIBIOTIC"; | "DRY_ANTIBIOTIC";
export type UserRole = "APP" | "ADMIN" | "CUSTOMER"; export type UserRole = "ADMIN" | "CUSTOMER";
export interface FarmerOption { export interface FarmerOption {
businessKey: string; businessKey: string;
@@ -50,6 +50,7 @@ export interface UserOption {
companyName: string | null; companyName: string | null;
address: string | null; address: string | null;
email: string | null; email: string | null;
phoneNumber: string | null;
portalLogin: string | null; portalLogin: string | null;
role: UserRole; role: UserRole;
} }

View File

@@ -83,10 +83,7 @@ export default function HomePage() {
/> />
</label> </label>
<button type="submit" className="accent-button"> <button type="submit" className="accent-button">
Probe oeffnen Probe öffnen
</button>
<button type="button" className="secondary-button" onClick={() => navigate("/samples/new")}>
Neuanlage einer Probe
</button> </button>
</form> </form>
@@ -162,7 +159,7 @@ export default function HomePage() {
) )
} }
> >
Oeffnen Öffnen
</button> </button>
</td> </td>
</tr> </tr>

View File

@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useState } from "react";
import { apiGet, apiPost } from "../lib/api"; import { apiPost } from "../lib/api";
import { useSession } from "../lib/session"; import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types"; import type { UserOption } from "../lib/types";
@@ -9,56 +9,36 @@ type FeedbackState =
| null; | null;
export default function LoginPage() { export default function LoginPage() {
const [users, setUsers] = useState<UserOption[]>([]); const [showRegistration, setShowRegistration] = useState(false);
const [manualCode, setManualCode] = useState("");
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [registration, setRegistration] = useState({ const [registration, setRegistration] = useState({
companyName: "", companyName: "",
address: "", street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "", email: "",
phoneNumber: "",
password: "", password: "",
passwordConfirmation: "",
}); });
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setUser } = useSession(); const { setUser } = useSession();
async function loadUsers() { async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
setLoading(true); event.preventDefault();
setFeedback(null); if (!identifier.trim() || !password.trim()) {
try { setFeedback({
const response = await apiGet<UserOption[]>("/session/users"); type: "error",
setUsers(response); text: "Bitte E-Mail oder Benutzername und Passwort eingeben.",
} catch (loadError) { });
setFeedback({ type: "error", text: (loadError as Error).message });
setUsers([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers();
}, []);
async function handleCodeLogin(code: string) {
if (!code.trim()) {
setFeedback({ type: "error", text: "Bitte ein Benutzerkuerzel eingeben oder auswaehlen." });
return; return;
} }
try { try {
const response = await apiPost<UserOption>("/session/login", { code }); setFeedback(null);
setUser(response);
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
}
async function handlePasswordLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
const response = await apiPost<UserOption>("/session/password-login", { const response = await apiPost<UserOption>("/session/password-login", {
identifier, identifier: identifier.trim(),
password, password,
}); });
setUser(response); setUser(response);
@@ -69,8 +49,31 @@ export default function LoginPage() {
async function handleRegister(event: FormEvent<HTMLFormElement>) { async function handleRegister(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (
!registration.companyName.trim()
|| !registration.street.trim()
|| !registration.houseNumber.trim()
|| !registration.postalCode.trim()
|| !registration.city.trim()
|| !registration.email.trim()
|| !registration.phoneNumber.trim()
|| !registration.password.trim()
) {
setFeedback({
type: "error",
text: "Bitte alle Pflichtfelder inklusive Telefonnummer fuer die Registrierung ausfuellen.",
});
return;
}
if (registration.password !== registration.passwordConfirmation) {
setFeedback({ type: "error", text: "Die beiden Passwoerter stimmen nicht ueberein." });
return;
}
try { try {
const response = await apiPost<UserOption>("/session/register", registration); setFeedback(null);
const { passwordConfirmation, ...registrationPayload } = registration;
void passwordConfirmation;
const response = await apiPost<UserOption>("/session/register", registrationPayload);
setFeedback({ setFeedback({
type: "success", type: "success",
text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`, text: `Registrierung erfolgreich. Willkommen ${response.companyName ?? response.displayName}.`,
@@ -98,8 +101,8 @@ export default function LoginPage() {
<p className="eyebrow">Zugang</p> <p className="eyebrow">Zugang</p>
<h2>Anmelden oder registrieren</h2> <h2>Anmelden oder registrieren</h2>
<p className="muted-text"> <p className="muted-text">
Weiterhin moeglich: Direktanmeldung per Benutzerkuerzel. Neu: Login mit Anmeldung per E-Mail oder Benutzername mit Passwort sowie direkte
E-Mail/Benutzername und Passwort. Kundenregistrierung.
</p> </p>
{feedback ? ( {feedback ? (
@@ -108,146 +111,156 @@ export default function LoginPage() {
</div> </div>
) : null} ) : null}
<div className="login-panel__section"> <div className="auth-grid">
<div className="section-card__header"> {!showRegistration ? (
<div> <form className="login-panel__section" onSubmit={handlePasswordLogin}>
<p className="eyebrow">Schnelllogin</p>
<h3>Benutzerkuerzel</h3>
</div>
<button type="button" className="secondary-button" onClick={() => void loadUsers()}>
Neu laden
</button>
</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen ...</div>
) : users.length ? (
<div className="user-grid">
{users.map((user) => (
<button
key={user.id}
type="button"
className="user-card"
onClick={() => void handleCodeLogin(user.code)}
>
<span className="user-card__code">{user.code}</span>
<strong>{user.displayName}</strong>
<small>
{user.role === "ADMIN"
? "Admin"
: user.role === "CUSTOMER"
? "Kunde"
: "App"}
</small>
</button>
))}
</div>
) : (
<div className="page-stack">
<div className="empty-state">
Es wurden keine aktiven Benutzer geladen. Das Kuersel kann trotzdem direkt
eingegeben werden.
</div>
<label className="field"> <label className="field">
<span>Benutzerkuerzel</span> <span>E-Mail / Benutzername</span>
<input <input
value={manualCode} value={identifier}
onChange={(event) => setManualCode(event.target.value.toUpperCase())} onChange={(event) => setIdentifier(event.target.value)}
placeholder="z. B. SV" placeholder="z. B. admin oder name@hof.de"
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/> />
</label> </label>
<div className="page-actions"> <div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
<button <button
type="button" type="button"
className="accent-button" className="secondary-button"
onClick={() => void handleCodeLogin(manualCode)} onClick={() => {
setFeedback(null);
setShowRegistration(true);
}}
> >
Mit Kuerzel anmelden Registrieren
</button> </button>
</div> </div>
</div> </form>
) : (
<form className="login-panel__section" onSubmit={handleRegister}>
<div className="field-grid">
<label className="field field--wide">
<span>Firmenname</span>
<input
value={registration.companyName}
onChange={(event) =>
setRegistration((current) => ({ ...current, companyName: event.target.value }))
}
placeholder="z. B. Muster Agrar GmbH"
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={registration.street}
onChange={(event) =>
setRegistration((current) => ({ ...current, street: event.target.value }))
}
placeholder="z. B. Dorfstrasse"
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={registration.houseNumber}
onChange={(event) =>
setRegistration((current) => ({ ...current, houseNumber: event.target.value }))
}
placeholder="z. B. 12a"
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={registration.postalCode}
onChange={(event) =>
setRegistration((current) => ({ ...current, postalCode: event.target.value }))
}
placeholder="z. B. 12345"
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={registration.city}
onChange={(event) =>
setRegistration((current) => ({ ...current, city: event.target.value }))
}
placeholder="z. B. Musterstadt"
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={registration.email}
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={registration.phoneNumber}
onChange={(event) =>
setRegistration((current) => ({ ...current, phoneNumber: event.target.value }))
}
placeholder="z. B. 04531 181424"
/>
</label>
<label className="field field--wide">
<span>Passwort</span>
<input
type="password"
value={registration.password}
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--wide">
<span>Passwort wiederholen</span>
<input
type="password"
value={registration.passwordConfirmation}
onChange={(event) =>
setRegistration((current) => ({
...current,
passwordConfirmation: event.target.value,
}))
}
/>
</label>
</div>
<div className="page-actions">
<button type="submit" className="accent-button">
Registrieren
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setFeedback(null);
setShowRegistration(false);
}}
>
Zurück
</button>
</div>
</form>
)} )}
</div> </div>
<div className="divider-label">oder mit Passwort</div>
<div className="auth-grid">
<form className="login-panel__section" onSubmit={handlePasswordLogin}>
<p className="eyebrow">Login</p>
<h3>E-Mail oder Benutzername</h3>
<label className="field">
<span>E-Mail / Benutzername</span>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder="z. B. admin oder name@hof.de"
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Mit Passwort anmelden
</button>
</div>
</form>
<form className="login-panel__section" onSubmit={handleRegister}>
<p className="eyebrow">Kundenregistrierung</p>
<h3>Neues Kundenkonto anlegen</h3>
<label className="field">
<span>Firmenname</span>
<input
value={registration.companyName}
onChange={(event) =>
setRegistration((current) => ({ ...current, companyName: event.target.value }))
}
placeholder="z. B. Muster Agrar GmbH"
/>
</label>
<label className="field">
<span>Adresse</span>
<textarea
value={registration.address}
onChange={(event) =>
setRegistration((current) => ({ ...current, address: event.target.value }))
}
placeholder="Strasse, Hausnummer, PLZ Ort"
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={registration.email}
onChange={(event) =>
setRegistration((current) => ({ ...current, email: event.target.value }))
}
/>
</label>
<label className="field">
<span>Passwort</span>
<input
type="password"
value={registration.password}
onChange={(event) =>
setRegistration((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<div className="page-actions">
<button type="submit" className="accent-button">
Registrieren
</button>
</div>
</form>
</div>
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api"; import { apiDelete, apiGet, apiPatch, apiPost, pdfUrl } from "../lib/api";
import { useSession } from "../lib/session";
import type { PortalSnapshot, UserRole } from "../lib/types"; import type { PortalSnapshot, UserRole } from "../lib/types";
function formatDate(value: string | null) { function formatDate(value: string | null) {
@@ -13,12 +14,8 @@ function formatDate(value: string | null) {
} }
export default function PortalPage() { export default function PortalPage() {
const { user } = useSession();
const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null); const [snapshot, setSnapshot] = useState<PortalSnapshot | null>(null);
const [selectedFarmer, setSelectedFarmer] = useState("");
const [farmerQuery, setFarmerQuery] = useState("");
const [cowQuery, setCowQuery] = useState("");
const [sampleNumberQuery, setSampleNumberQuery] = useState("");
const [dateQuery, setDateQuery] = useState("");
const [selectedReports, setSelectedReports] = useState<string[]>([]); const [selectedReports, setSelectedReports] = useState<string[]>([]);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [userForm, setUserForm] = useState({ const [userForm, setUserForm] = useState({
@@ -27,29 +24,13 @@ export default function PortalPage() {
email: "", email: "",
portalLogin: "", portalLogin: "",
password: "", password: "",
role: "APP" as UserRole, role: "CUSTOMER" as UserRole,
}); });
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({}); const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const isAdmin = user?.role === "ADMIN";
async function loadSnapshot() { async function loadSnapshot() {
const params = new URLSearchParams(); const response = await apiGet<PortalSnapshot>("/portal/snapshot");
if (selectedFarmer) {
params.set("farmerBusinessKey", selectedFarmer);
}
if (farmerQuery) {
params.set("farmerQuery", farmerQuery);
}
if (cowQuery) {
params.set("cowQuery", cowQuery);
}
if (sampleNumberQuery) {
params.set("sampleNumber", sampleNumberQuery);
}
if (dateQuery) {
params.set("date", dateQuery);
}
const response = await apiGet<PortalSnapshot>(`/portal/snapshot?${params.toString()}`);
setSnapshot(response); setSnapshot(response);
setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId)); setSelectedReports(response.reportCandidates.map((candidate) => candidate.sampleId));
} }
@@ -69,16 +50,6 @@ export default function PortalPage() {
); );
} }
async function handleSearch(event?: FormEvent) {
event?.preventDefault();
try {
setMessage(null);
await loadSnapshot();
} catch (loadError) {
setMessage((loadError as Error).message);
}
}
async function handleDispatchReports() { async function handleDispatchReports() {
try { try {
const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", { const response = await apiPost<{ mailDeliveryActive: boolean }>("/portal/reports/send", {
@@ -97,6 +68,10 @@ export default function PortalPage() {
async function handleCreateUser(event: FormEvent<HTMLFormElement>) { async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!isAdmin) {
setMessage("Nur Administratoren koennen Benutzer anlegen.");
return;
}
try { try {
await apiPost("/portal/users", { await apiPost("/portal/users", {
...userForm, ...userForm,
@@ -108,7 +83,7 @@ export default function PortalPage() {
email: "", email: "",
portalLogin: "", portalLogin: "",
password: "", password: "",
role: "APP", role: "CUSTOMER",
}); });
setMessage("Benutzer gespeichert."); setMessage("Benutzer gespeichert.");
await loadSnapshot(); await loadSnapshot();
@@ -203,163 +178,114 @@ export default function PortalPage() {
</div> </div>
</article> </article>
<article className="section-card"> {isAdmin ? (
<p className="eyebrow">Benutzerverwaltung</p> <article className="section-card">
<form className="field-grid" onSubmit={handleCreateUser}> <p className="eyebrow">Benutzerverwaltung</p>
<label className="field"> <form className="field-grid" onSubmit={handleCreateUser}>
<span>Kuerzel</span> <label className="field">
<input <span>Kuerzel</span>
value={userForm.code} <input
onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))} value={userForm.code}
/> onChange={(event) => setUserForm((current) => ({ ...current, code: event.target.value }))}
</label> />
<label className="field"> </label>
<span>Name</span> <label className="field">
<input <span>Name</span>
value={userForm.displayName} <input
onChange={(event) => value={userForm.displayName}
setUserForm((current) => ({ ...current, displayName: event.target.value })) onChange={(event) =>
} setUserForm((current) => ({ ...current, displayName: event.target.value }))
/> }
</label> />
<label className="field"> </label>
<span>Login</span> <label className="field">
<input <span>Login</span>
value={userForm.portalLogin} <input
onChange={(event) => value={userForm.portalLogin}
setUserForm((current) => ({ ...current, portalLogin: event.target.value })) onChange={(event) =>
} setUserForm((current) => ({ ...current, portalLogin: event.target.value }))
/> }
</label> />
<label className="field"> </label>
<span>E-Mail</span> <label className="field">
<input <span>E-Mail</span>
type="email" <input
value={userForm.email} type="email"
onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))} value={userForm.email}
/> onChange={(event) => setUserForm((current) => ({ ...current, email: event.target.value }))}
</label> />
<label className="field"> </label>
<span>Passwort</span> <label className="field">
<input <span>Passwort</span>
value={userForm.password} <input
onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))} value={userForm.password}
type="password" onChange={(event) => setUserForm((current) => ({ ...current, password: event.target.value }))}
/> type="password"
</label> />
<label className="field"> </label>
<span>Rolle</span> <label className="field">
<select <span>Rolle</span>
value={userForm.role} <select
onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))} value={userForm.role}
> onChange={(event) => setUserForm((current) => ({ ...current, role: event.target.value as UserRole }))}
<option value="APP">APP</option> >
<option value="ADMIN">ADMIN</option> <option value="CUSTOMER">CUSTOMER</option>
</select> <option value="ADMIN">ADMIN</option>
</label> </select>
<div className="page-actions"> </label>
<button type="submit" className="accent-button"> <div className="page-actions">
Benutzer anlegen <button type="submit" className="accent-button">
</button> Benutzer anlegen
</div> </button>
</form> </div>
</form>
<div className="table-shell"> <div className="table-shell">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>Kuerzel</th> <th>Kuerzel</th>
<th>Name</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Login</th> <th>Login</th>
<th>Rolle</th> <th>Rolle</th>
<th>Passwort</th> <th>Passwort</th>
<th /> <th />
</tr>
</thead>
<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
type="password"
value={passwordDrafts[user.id] ?? ""}
onChange={(event) =>
setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
}
placeholder="Neues Passwort"
/>
</td>
<td className="table-actions">
<button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
Speichern
</button>
<button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
Loeschen
</button>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {snapshot.users.map((user) => (
</div> <tr key={user.id}>
</article> <td>{user.code}</td>
</section> <td>{user.displayName}</td>
<td>{user.email ?? "-"}</td>
<section className="section-card"> <td>{user.portalLogin ?? "-"}</td>
<form className="field-grid" onSubmit={handleSearch}> <td>{user.role}</td>
<label className="field"> <td>
<span>Landwirt suchen</span> <input
<input value={farmerQuery} onChange={(event) => setFarmerQuery(event.target.value)} /> type="password"
</label> value={passwordDrafts[user.id] ?? ""}
<label className="field"> onChange={(event) =>
<span>Gefundener Landwirt</span> setPasswordDrafts((current) => ({ ...current, [user.id]: event.target.value }))
<select value={selectedFarmer} onChange={(event) => setSelectedFarmer(event.target.value)}> }
<option value="">alle / noch keiner</option> placeholder="Neues Passwort"
{snapshot.farmers.map((farmer) => ( />
<option key={farmer.businessKey} value={farmer.businessKey}> </td>
{farmer.name} <td className="table-actions">
</option> <button type="button" className="table-link" onClick={() => void handlePasswordChange(user.id)}>
))} Speichern
</select> </button>
</label> <button type="button" className="table-link table-link--danger" onClick={() => void handleDeleteUser(user.id)}>
<label className="field"> Loeschen
<span>Kuh</span> </button>
<input value={cowQuery} onChange={(event) => setCowQuery(event.target.value)} /> </td>
</label> </tr>
<label className="field"> ))}
<span>Probe-Nr.</span> </tbody>
<input value={sampleNumberQuery} onChange={(event) => setSampleNumberQuery(event.target.value)} /> </table>
</label> </div>
<label className="field"> </article>
<span>Datum</span> ) : null}
<input type="date" value={dateQuery} onChange={(event) => setDateQuery(event.target.value)} />
</label>
<div className="page-actions page-actions--align-end">
<button type="submit" className="accent-button">
Suche starten
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
setSelectedFarmer("");
setFarmerQuery("");
setCowQuery("");
setSampleNumberQuery("");
setDateQuery("");
void handleSearch();
}}
>
Zuruecksetzen
</button>
</div>
</form>
</section> </section>
<section className="section-card"> <section className="section-card">

View File

@@ -138,7 +138,7 @@ export default function SampleRegistrationPage() {
{message ? <div className="alert alert--error">{message}</div> : null} {message ? <div className="alert alert--error">{message}</div> : null}
</section> </section>
<section className="form-grid"> <section className="form-grid form-grid--stacked">
<article className="section-card"> <article className="section-card">
<p className="eyebrow">Stammdaten</p> <p className="eyebrow">Stammdaten</p>
<div className="field-grid"> <div className="field-grid">

View File

@@ -57,16 +57,21 @@ a {
display: grid; display: grid;
grid-template-columns: minmax(228px, 280px) minmax(0, 1fr); grid-template-columns: minmax(228px, 280px) minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
align-items: start;
} }
.sidebar { .sidebar {
position: sticky;
top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
padding: 28px 24px; padding: 28px 24px;
min-height: 100vh;
height: 100vh;
background: rgba(23, 34, 41, 0.92); background: rgba(23, 34, 41, 0.92);
color: #f8f3ed; color: #f8f3ed;
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
overflow: hidden;
} }
.sidebar__brand { .sidebar__brand {
@@ -89,8 +94,8 @@ a {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
min-height: 108px; min-height: 54px;
padding: 16px 24px; padding: 8px 24px;
border-radius: 28px; border-radius: 28px;
background: linear-gradient(135deg, #1d9485, #0f5b53); background: linear-gradient(135deg, #1d9485, #0f5b53);
font-weight: 700; font-weight: 700;
@@ -100,7 +105,9 @@ a {
.sidebar__nav { .sidebar__nav {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 32px 0 auto; margin: 32px 0 0;
flex: 1 1 auto;
align-content: start;
} }
.nav-link { .nav-link {
@@ -121,6 +128,8 @@ a {
.sidebar__footer { .sidebar__footer {
display: grid; display: grid;
gap: 12px; gap: 12px;
margin-top: auto;
padding-top: 24px;
} }
.user-chip { .user-chip {
@@ -263,6 +272,14 @@ a {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.form-grid--stacked {
grid-template-columns: 1fr;
}
.portal-grid > :only-child {
grid-column: 1 / -1;
}
.metric-card { .metric-card {
padding: 22px 24px; padding: 22px 24px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -307,6 +324,10 @@ a {
gap: 8px; gap: 8px;
} }
.field--wide {
grid-column: 1 / -1;
}
.field span { .field span {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
@@ -399,7 +420,7 @@ a {
} }
.auth-grid { .auth-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr;
margin-top: 18px; margin-top: 18px;
} }
@@ -582,10 +603,10 @@ a {
.login-hero { .login-hero {
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1fr;
gap: 28px; gap: 28px;
align-items: stretch; align-items: stretch;
width: min(1240px, 100%); width: min(960px, 100%);
} }
.login-hero__copy { .login-hero__copy {

View File

@@ -1,4 +1,5 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly DEV: boolean;
readonly VITE_API_URL?: string; readonly VITE_API_URL?: string;
} }