diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/api/NotificationController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/api/NotificationController.java new file mode 100644 index 0000000..eeccc4f --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/api/NotificationController.java @@ -0,0 +1,46 @@ +package at.mueller.eeg.backend.common.api; + +import at.mueller.eeg.backend.common.domain.Notification; +import at.mueller.eeg.backend.common.service.NotificationService; +import at.mueller.eeg.backend.common.security.CurrentUserId; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + @PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") + public ResponseEntity> getNotifications(@CurrentUserId String userId) { + return ResponseEntity.ok(notificationService.getByUserId(UUID.fromString(userId))); + } + + @GetMapping("/unread") + @PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") + public ResponseEntity getUnreadCount(@CurrentUserId String userId) { + return ResponseEntity.ok(notificationService.getUnreadCount(UUID.fromString(userId))); + } + + @PutMapping("/{id}/read") + @PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") + public ResponseEntity markAsRead(@PathVariable UUID id) { + notificationService.markAsRead(id); + return ResponseEntity.ok().build(); + } + + @PutMapping("/read-all") + @PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") + public ResponseEntity markAllAsRead(@CurrentUserId String userId) { + notificationService.markAllAsRead(UUID.fromString(userId)); + return ResponseEntity.ok().build(); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/Notification.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/Notification.java new file mode 100644 index 0000000..1187a04 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/Notification.java @@ -0,0 +1,38 @@ +package at.mueller.eeg.backend.common.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "notifications") +@Getter +@Setter +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 1000) + private String message; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @Column(nullable = false) + private boolean read = false; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/NotificationType.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/NotificationType.java new file mode 100644 index 0000000..280d15d --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/domain/NotificationType.java @@ -0,0 +1,8 @@ +package at.mueller.eeg.backend.common.domain; + +public enum NotificationType { + INFO, + SUCCESS, + WARNING, + ERROR +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/listener/NotificationListener.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/listener/NotificationListener.java new file mode 100644 index 0000000..97ad6dd --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/listener/NotificationListener.java @@ -0,0 +1,104 @@ +package at.mueller.eeg.backend.common.event.listener; + +import at.mueller.eeg.backend.common.domain.NotificationType; +import at.mueller.eeg.backend.common.service.NotificationService; +import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import at.mueller.eeg.backend.common.event.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationListener { + + private final NotificationService notificationService; + private final MeteringPointRepository meteringPointRepository; + + @EventListener + @Transactional + public void handleUserApproved(UserApprovedEvent event) { + notificationService.create( + event.userId(), + "Account freigeschaltet", + "Ihr Account wurde vom Administrator freigeschaltet. Sie können sich jetzt anmelden.", + NotificationType.SUCCESS + ); + log.info("Benachrichtigung für User {}: Account freigeschaltet", event.userId()); + } + + @EventListener + @Transactional + public void handleUserRejected(UserRejectedEvent event) { + notificationService.create( + event.userId(), + "Registrierungsantrag abgelehnt", + "Ihr Registrierungsantrag wurde vom Administrator abgelehnt.", + NotificationType.ERROR + ); + log.info("Benachrichtigung für User {}: Registrierung abgelehnt", event.userId()); + } + + @EventListener + @Transactional + public void handleMakoConsensApproved(MakoConsensApprovedEvent event) { + findMeteringPointByAtNumber(event.atNumber()).ifPresent(point -> { + notificationService.create( + point.getUserId(), + "Consent erteilt", + "Der Consent für Ihren Zählpunkt " + event.atNumber() + " wurde erteilt.", + NotificationType.SUCCESS + ); + log.info("Benachrichtigung für AT {}: Consent erteilt", event.atNumber()); + }); + } + + @EventListener + @Transactional + public void handleMakoConsensRejected(MakoConsensRejectedEvent event) { + findMeteringPointByAtNumber(event.atNumber()).ifPresent(point -> { + notificationService.create( + point.getUserId(), + "Consent abgelehnt", + "Der Consent für Ihren Zählpunkt " + event.atNumber() + " wurde abgelehnt. Grund: " + event.reason(), + NotificationType.ERROR + ); + log.info("Benachrichtigung für AT {}: Consent abgelehnt", event.atNumber()); + }); + } + + @EventListener + @Transactional + public void handleEdaTopologyCompleted(EdaTopologyCompletedEvent event) { + String status = event.isEligible() ? "erfolgreich abgeschlossen" : "nicht bestanden"; + notificationService.create( + event.userId(), + "Topologie-Check " + status, + "Der Topologie-Check für Ihren Zählpunkt wurde " + status + ".", + event.isEligible() ? NotificationType.SUCCESS : NotificationType.WARNING + ); + log.info("Benachrichtigung für User {}: Topologie-Check {}", event.userId(), status); + } + + @EventListener + @Transactional + public void handleEdaTopologyFailed(EdaTopologyFailedEvent event) { + notificationService.create( + event.userId(), + "Topologie-Check fehlgeschlagen", + "Der Topologie-Check für Ihren Zählpunkt ist fehlgeschlagen. Fehler: " + event.errorMessage(), + NotificationType.ERROR + ); + log.info("Benachrichtigung für User {}: Topologie-Check fehlgeschlagen", event.userId()); + } + + private java.util.Optional findMeteringPointByAtNumber(String atNumber) { + return meteringPointRepository.findByAtNumber(atNumber); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/repository/NotificationRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/repository/NotificationRepository.java new file mode 100644 index 0000000..a9a29a0 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/repository/NotificationRepository.java @@ -0,0 +1,23 @@ +package at.mueller.eeg.backend.common.repository; + +import at.mueller.eeg.backend.common.domain.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +public interface NotificationRepository extends JpaRepository { + + List findByUserIdOrderByCreatedAtDesc(UUID userId); + + long countByUserIdAndReadFalse(UUID userId); + + @Modifying + @Transactional + @Query("UPDATE Notification n SET n.read = true WHERE n.userId = :userId AND n.read = false") + void markAllAsReadByUserId(@Param("userId") UUID userId); +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/service/NotificationService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/service/NotificationService.java new file mode 100644 index 0000000..52c4416 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/service/NotificationService.java @@ -0,0 +1,51 @@ +package at.mueller.eeg.backend.common.service; + +import at.mueller.eeg.backend.common.domain.Notification; +import at.mueller.eeg.backend.common.domain.NotificationType; +import at.mueller.eeg.backend.common.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + + @Transactional + public Notification create(UUID userId, String title, String message, NotificationType type) { + Notification notification = new Notification(); + notification.setUserId(userId); + notification.setTitle(title); + notification.setMessage(message); + notification.setType(type); + return notificationRepository.save(notification); + } + + @Transactional(readOnly = true) + public List getByUserId(UUID userId) { + return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + @Transactional(readOnly = true) + public long getUnreadCount(UUID userId) { + return notificationRepository.countByUserIdAndReadFalse(userId); + } + + @Transactional + public void markAsRead(UUID id) { + notificationRepository.findById(id).ifPresent(notification -> { + notification.setRead(true); + notificationRepository.save(notification); + }); + } + + @Transactional + public void markAllAsRead(UUID userId) { + notificationRepository.markAllAsReadByUserId(userId); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java index 0e5f5b3..a6e9631 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java @@ -3,6 +3,8 @@ package at.mueller.eeg.backend.iam.api; import at.mueller.eeg.backend.common.security.CurrentUserId; import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangeEmailRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ForgotPasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ResetPasswordRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UpdateProfileRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; import at.mueller.eeg.backend.iam.service.UserService; @@ -14,20 +16,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/users") +@RequestMapping("/api") @RequiredArgsConstructor -@Tag(name = "User Profile", description = "Profilverwaltung für eingeloggte User") +@Tag(name = "User Profile", description = "Profilverwaltung und Passwort-Reset") public class UserController { private final UserService userService; - @GetMapping("/me") + @GetMapping("/users/me") @Operation(summary = "Eigenes Profil abrufen") public ResponseEntity getOwnProfile(@CurrentUserId String userId) { return ResponseEntity.ok(userService.getProfile(java.util.UUID.fromString(userId))); } - @PutMapping("/me") + @PutMapping("/users/me") @Operation(summary = "Profil aktualisieren") public ResponseEntity updateOwnProfile( @CurrentUserId String userId, @@ -35,7 +37,7 @@ public class UserController { return ResponseEntity.ok(userService.updateProfile(java.util.UUID.fromString(userId), request)); } - @PutMapping("/me/password") + @PutMapping("/users/me/password") @Operation(summary = "Passwort ändern") public ResponseEntity changePassword( @CurrentUserId String userId, @@ -44,7 +46,7 @@ public class UserController { return ResponseEntity.ok().build(); } - @PutMapping("/me/email") + @PutMapping("/users/me/email") @Operation(summary = "E-Mail ändern (mit erneuter Verifizierung)") public ResponseEntity changeEmail( @CurrentUserId String userId, @@ -52,4 +54,18 @@ public class UserController { userService.changeEmail(java.util.UUID.fromString(userId), request); return ResponseEntity.ok().build(); } + + @PostMapping("/auth/forgot-password") + @Operation(summary = "Passwort-Reset anfordern") + public ResponseEntity forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + userService.forgotPassword(request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/auth/reset-password") + @Operation(summary = "Passwort zurücksetzen") + public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + userService.resetPassword(request); + return ResponseEntity.ok().build(); + } } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java index 854f38b..9cd45c8 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java @@ -82,4 +82,18 @@ public interface IamDtos { @Email @NotBlank @Schema(description = "Neue E-Mail-Adresse", example = "neu@example.com") String newEmail ) {} + + @Schema(description = "Payload für Passwort-Reset anfordern") + record ForgotPasswordRequest( + @Email @NotBlank + @Schema(description = "E-Mail-Adresse", example = "max@example.com") String email + ) {} + + @Schema(description = "Payload für Passwort zurücksetzen") + record ResetPasswordRequest( + @NotBlank + @Schema(description = "Reset-Token") String token, + @NotBlank @Size(min = 8) + @Schema(description = "Neues Passwort", example = "NeuesPasswort123!") String newPassword + ) {} } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java index 95570c4..4645312 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java @@ -9,6 +9,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.UUID; @@ -45,6 +46,12 @@ public class User implements UserDetails { @Column(name = "verification_token") private String verificationToken; + @Column(name = "password_reset_token") + private String passwordResetToken; + + @Column(name = "password_reset_token_expiry") + private LocalDateTime passwordResetTokenExpiry; + @Enumerated(EnumType.STRING) private UserRole role = UserRole.MEMBER; diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/repository/UserRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/repository/UserRepository.java index 0861240..4b4bb1b 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/repository/UserRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/repository/UserRepository.java @@ -17,4 +17,6 @@ public interface UserRepository extends JpaRepository { long countByStatusAndEmailVerified(RegistrationStatus status, Boolean verified); Optional findByVerificationToken(String token); + + Optional findByPasswordResetToken(String token); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/ConsoleMailService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/ConsoleMailService.java index 61e3369..331978c 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/ConsoleMailService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/ConsoleMailService.java @@ -1,6 +1,7 @@ package at.mueller.eeg.backend.iam.service; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -9,14 +10,17 @@ import org.springframework.stereotype.Service; @Slf4j public class ConsoleMailService implements MailService { + @Value("${app.frontend-url:http://localhost:4200}") + private String frontendUrl; + @Override public void sendVerificationEmail(String toEmail, String token) { - String verificationUrl = "http://localhost:4200/verify-email?token=" + token; + String verificationUrl = frontendUrl + "/verify-email?token=" + token; log.info(""" ======================================================================== - 📧 SIMULIERTER E-MAIL-VERSAND (Profile: DEV) + SIMULIERTER E-MAIL-VERSAND (Profile: DEV) ======================================================================== An: {} Betreff: Bitte bestätige deine E-Mail-Adresse für das EEG-Portal @@ -26,4 +30,19 @@ public class ConsoleMailService implements MailService { ======================================================================== """, toEmail, verificationUrl); } + + @Override + public void send(String to, String subject, String body) { + log.info(""" + + ======================================================================== + SIMULIERTER E-MAIL-VERSAND (Profile: DEV) + ======================================================================== + An: {} + Betreff: {} + + {} + ======================================================================== + """, to, subject, body); + } } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/MailService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/MailService.java index a9a977f..918383f 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/MailService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/MailService.java @@ -2,4 +2,5 @@ package at.mueller.eeg.backend.iam.service; public interface MailService { void sendVerificationEmail(String toEmail, String token); + void send(String to, String subject, String body); } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/SmtpMailService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/SmtpMailService.java index 0dda31f..ae0cc8c 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/SmtpMailService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/SmtpMailService.java @@ -1,6 +1,7 @@ package at.mueller.eeg.backend.iam.service; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @@ -13,9 +14,12 @@ public class SmtpMailService implements MailService { private final JavaMailSender mailSender; + @Value("${app.frontend-url:http://localhost:4200}") + private String frontendUrl; + @Override public void sendVerificationEmail(String toEmail, String token) { - String verificationUrl = "http://localhost:4200/verify-email?token=" + token; + String verificationUrl = frontendUrl + "/verify-email?token=" + token; SimpleMailMessage message = new SimpleMailMessage(); message.setTo(toEmail); @@ -24,4 +28,14 @@ public class SmtpMailService implements MailService { mailSender.send(message); } + + @Override + public void send(String to, String subject, String body) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + + mailSender.send(message); + } } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java index 2f2c00b..dbefb6a 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java @@ -2,15 +2,19 @@ package at.mueller.eeg.backend.iam.service; import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangeEmailRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ForgotPasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ResetPasswordRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UpdateProfileRequest; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; import at.mueller.eeg.backend.iam.domain.User; import at.mueller.eeg.backend.iam.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.UUID; @Service @@ -19,6 +23,10 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final MailService mailService; + + @Value("${app.frontend-url:http://localhost:4200}") + private String frontendUrl; @Transactional(readOnly = true) public UserProfileResponse getProfile(UUID userId) { @@ -69,6 +77,39 @@ public class UserService { userRepository.save(user); } + @Transactional + public void forgotPassword(ForgotPasswordRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new IllegalArgumentException("Kein Account mit dieser E-Mail-Adresse gefunden")); + + String token = UUID.randomUUID().toString(); + user.setPasswordResetToken(token); + user.setPasswordResetTokenExpiry(LocalDateTime.now().plusHours(24)); + userRepository.save(user); + + String resetUrl = frontendUrl + "/reset-password?token=" + token; + mailService.send( + user.getEmail(), + "Passwort zurücksetzen - EEG-Portal", + "Sie haben einen Passwort-Reset angefordert.\n\nKlicken Sie auf den folgenden Link:\n" + resetUrl + "\n\nDer Link ist 24 Stunden gültig." + ); + } + + @Transactional + public void resetPassword(ResetPasswordRequest request) { + User user = userRepository.findByPasswordResetToken(request.token()) + .orElseThrow(() -> new IllegalArgumentException("Ungültiger oder abgelaufener Token")); + + if (user.getPasswordResetTokenExpiry().isBefore(LocalDateTime.now())) { + throw new IllegalArgumentException("Der Reset-Token ist abgelaufen"); + } + + user.setPasswordHash(passwordEncoder.encode(request.newPassword())); + user.setPasswordResetToken(null); + user.setPasswordResetTokenExpiry(null); + userRepository.save(user); + } + private UserProfileResponse toProfileResponse(User user) { return new UserProfileResponse( user.getId(), diff --git a/eeg_backend/src/main/resources/application.yml b/eeg_backend/src/main/resources/application.yml index 83a0ca3..13b516a 100644 --- a/eeg_backend/src/main/resources/application.yml +++ b/eeg_backend/src/main/resources/application.yml @@ -11,4 +11,7 @@ spring: ddl-auto: update show-sql: true jwt: - secret: MeinSuperGeheimesUndSehrLangesSecretFuerDasEnergiePortal2025! \ No newline at end of file + secret: MeinSuperGeheimesUndSehrLangesSecretFuerDasEnergiePortal2025! +app: + frontend-url: http://localhost:4200 + backend-url: http://localhost:8080 \ No newline at end of file diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/common/service/NotificationServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/common/service/NotificationServiceTest.java new file mode 100644 index 0000000..4182caf --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/common/service/NotificationServiceTest.java @@ -0,0 +1,97 @@ +package at.mueller.eeg.backend.common.service; + +import at.mueller.eeg.backend.common.domain.Notification; +import at.mueller.eeg.backend.common.domain.NotificationType; +import at.mueller.eeg.backend.common.repository.NotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationService notificationService; + + private UUID testUserId; + private Notification testNotification; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testNotification = new Notification(); + testNotification.setId(UUID.randomUUID()); + testNotification.setUserId(testUserId); + testNotification.setTitle("Test Title"); + testNotification.setMessage("Test Message"); + testNotification.setType(NotificationType.INFO); + testNotification.setRead(false); + } + + @Test + void create_savesNotification() { + when(notificationRepository.save(any())).thenReturn(testNotification); + + Notification result = notificationService.create( + testUserId, "Test Title", "Test Message", NotificationType.INFO + ); + + assertNotNull(result); + assertEquals(testUserId, result.getUserId()); + assertEquals("Test Title", result.getTitle()); + verify(notificationRepository).save(any()); + } + + @Test + void getByUserId_returnsUserNotifications() { + when(notificationRepository.findByUserIdOrderByCreatedAtDesc(testUserId)) + .thenReturn(List.of(testNotification)); + + List result = notificationService.getByUserId(testUserId); + + assertEquals(1, result.size()); + assertEquals(testUserId, result.get(0).getUserId()); + } + + @Test + void getUnreadCount_returnsCorrectCount() { + when(notificationRepository.countByUserIdAndReadFalse(testUserId)).thenReturn(3L); + + long count = notificationService.getUnreadCount(testUserId); + + assertEquals(3, count); + } + + @Test + void markAsRead_setsReadToTrue() { + when(notificationRepository.findById(testNotification.getId())) + .thenReturn(Optional.of(testNotification)); + when(notificationRepository.save(any())).thenReturn(testNotification); + + notificationService.markAsRead(testNotification.getId()); + + assertTrue(testNotification.isRead()); + verify(notificationRepository).save(testNotification); + } + + @Test + void markAllAsRead_callsRepository() { + notificationService.markAllAsRead(testUserId); + + verify(notificationRepository).markAllAsReadByUserId(testUserId); + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/FakeMailService.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/FakeMailService.java index d70ce6a..30b69c4 100644 --- a/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/FakeMailService.java +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/FakeMailService.java @@ -3,9 +3,12 @@ package at.mueller.eeg.backend.iam.service; import org.springframework.stereotype.Service; @Service -public class FakeMailService implements MailService{ +public class FakeMailService implements MailService { @Override public void sendVerificationEmail(String toEmail, String token) { + } + @Override + public void send(String to, String subject, String body) { } } diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index 328776d..88b82fc 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -14,12 +14,16 @@ import {ProfileComponent} from './pages/profile/profile'; import {JoinCommunityComponent} from './pages/join-community/join-community'; import {MyMembershipsComponent} from './pages/my-memberships/my-memberships'; import {AdminMembershipsComponent} from './pages/admin-memberships/admin-memberships'; +import {ForgotPasswordComponent} from './pages/forgot-password/forgot-password'; +import {ResetPasswordComponent} from './pages/reset-password/reset-password'; export const routes: Routes = [ { path: '', component: LandingPage }, { path: 'register', component: Register }, { path: 'login', component: LoginComponent }, { path: 'verify-email', component: VerifyEmailComponent }, + { path: 'forgot-password', component: ForgotPasswordComponent }, + { path: 'reset-password', component: ResetPasswordComponent }, { path: 'dashboard', component: DashboardLayout, diff --git a/eeg_frontend/src/app/components/notification/notification.ts b/eeg_frontend/src/app/components/notification/notification.ts new file mode 100644 index 0000000..de1b428 --- /dev/null +++ b/eeg_frontend/src/app/components/notification/notification.ts @@ -0,0 +1,120 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {NotificationService, Notification} from '../../services/notification'; + +@Component({ + selector: 'app-notification', + standalone: true, + template: ` +
+ + + @if (isOpen()) { +
+
+

Benachrichtigungen

+ @if (notificationService.unreadCount() > 0) { + + } +
+
+ @if (notifications().length === 0) { +
Keine Benachrichtigungen
+ } @else { + @for (notification of notifications(); track notification.id) { +
+
+
+
+

{{ notification.title }}

+

{{ notification.message }}

+

{{ formatDate(notification.createdAt) }}

+
+
+
+ } + } +
+
+ } +
+ ` +}) +export class NotificationComponent implements OnInit { + notificationService = inject(NotificationService); + + isOpen = signal(false); + notifications = signal([]); + + ngOnInit() { + this.loadNotifications(); + this.notificationService.getUnreadCount().subscribe(); + } + + toggleDropdown() { + this.isOpen.update(v => !v); + if (this.isOpen()) { + this.loadNotifications(); + } + } + + loadNotifications() { + this.notificationService.getNotifications().subscribe({ + next: notifications => this.notifications.set(notifications) + }); + } + + markAsRead(notification: Notification) { + if (!notification.read) { + this.notificationService.markAsRead(notification.id).subscribe({ + next: () => { + this.notifications.update(ns => + ns.map(n => n.id === notification.id ? {...n, read: true} : n) + ); + } + }); + } + } + + markAllAsRead() { + this.notificationService.markAllAsRead().subscribe({ + next: () => { + this.notifications.update(ns => + ns.map(n => ({...n, read: true})) + ); + } + }); + } + + getTypeClass(type: string): string { + switch (type) { + case 'SUCCESS': return 'bg-green-500'; + case 'ERROR': return 'bg-red-500'; + case 'WARNING': return 'bg-yellow-500'; + default: return 'bg-blue-500'; + } + } + + formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Gerade eben'; + if (diffMins < 60) return `Vor ${diffMins} Min.`; + if (diffMins < 1440) return `Vor ${Math.floor(diffMins / 60)} Std.`; + return date.toLocaleDateString('de-DE'); + } +} diff --git a/eeg_frontend/src/app/pages/forgot-password/forgot-password.ts b/eeg_frontend/src/app/pages/forgot-password/forgot-password.ts new file mode 100644 index 0000000..7eafedb --- /dev/null +++ b/eeg_frontend/src/app/pages/forgot-password/forgot-password.ts @@ -0,0 +1,86 @@ +import {Component, inject, signal} from '@angular/core'; +import {ReactiveFormsModule, FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {Router, RouterLink} from '@angular/router'; +import {HttpClient} from '@angular/common/http'; +import {ToastService} from '../../services/toast'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + template: ` +
+
+
+

+ Passwort vergessen? +

+

+ Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen. +

+
+ + @if (isSuccess()) { +
+

Ein Link zum Zurücksetzen wurde an Ihre E-Mail-Adresse gesendet.

+
+ } + +
+
+ + + @if (form.get('email')?.invalid && form.get('email')?.touched) { +

Bitte geben Sie eine gültige E-Mail-Adresse ein.

+ } +
+ +
+ +
+ + +
+
+
+ ` +}) +export class ForgotPasswordComponent { + private fb = inject(FormBuilder); + private http = inject(HttpClient); + private toastService = inject(ToastService); + + form: FormGroup = this.fb.group({ + email: ['', [Validators.required, Validators.email]] + }); + + isLoading = signal(false); + isSuccess = signal(false); + + onSubmit() { + if (this.form.invalid || this.isLoading()) return; + + this.isLoading.set(true); + + this.http.post('http://localhost:8080/api/auth/forgot-password', this.form.value).subscribe({ + next: () => { + this.isLoading.set(false); + this.isSuccess.set(true); + this.toastService.success('Ein Link zum Zurücksetzen wurde gesendet.'); + }, + error: (err) => { + this.isLoading.set(false); + this.toastService.error(err.error?.message || 'Fehler beim Senden des Links.'); + } + }); + } +} diff --git a/eeg_frontend/src/app/pages/reset-password/reset-password.ts b/eeg_frontend/src/app/pages/reset-password/reset-password.ts new file mode 100644 index 0000000..bfce196 --- /dev/null +++ b/eeg_frontend/src/app/pages/reset-password/reset-password.ts @@ -0,0 +1,113 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {ReactiveFormsModule, FormBuilder, FormGroup, Validators} from '@angular/forms'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {HttpClient} from '@angular/common/http'; +import {ToastService} from '../../services/toast'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + template: ` +
+
+
+

+ Passwort zurücksetzen +

+

+ Geben Sie Ihr neues Passwort ein. +

+
+ + @if (isSuccess()) { +
+

Ihr Passwort wurde erfolgreich zurückgesetzt.

+ + Zum Login → + +
+ } + +
+
+ + + @if (form.get('newPassword')?.invalid && form.get('newPassword')?.touched) { +

Passwort muss mindestens 8 Zeichen lang sein.

+ } +
+ +
+ + + @if (form.get('confirmPassword')?.touched && form.get('confirmPassword')?.value !== form.get('newPassword')?.value) { +

Passwörter stimmen nicht überein.

+ } +
+ +
+ +
+
+
+
+ ` +}) +export class ResetPasswordComponent implements OnInit { + private fb = inject(FormBuilder); + private http = inject(HttpClient); + private route = inject(ActivatedRoute); + private router = inject(Router); + private toastService = inject(ToastService); + + form: FormGroup = this.fb.group({ + newPassword: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', [Validators.required]] + }); + + isLoading = signal(false); + isSuccess = signal(false); + token = ''; + + ngOnInit() { + this.token = this.route.snapshot.queryParams['token'] || ''; + if (!this.token) { + this.toastService.error('Kein gültiger Reset-Token.'); + this.router.navigate(['/forgot-password']); + } + } + + onSubmit() { + if (this.form.invalid || this.isLoading()) return; + + if (this.form.value.newPassword !== this.form.value.confirmPassword) { + this.toastService.warning('Passwörter stimmen nicht überein.'); + return; + } + + this.isLoading.set(true); + + this.http.post('http://localhost:8080/api/auth/reset-password', { + token: this.token, + newPassword: this.form.value.newPassword + }).subscribe({ + next: () => { + this.isLoading.set(false); + this.isSuccess.set(true); + this.toastService.success('Passwort erfolgreich zurückgesetzt.'); + }, + error: (err) => { + this.isLoading.set(false); + this.toastService.error(err.error?.message || 'Fehler beim Zurücksetzen des Passworts.'); + } + }); + } +} diff --git a/eeg_frontend/src/app/services/notification.ts b/eeg_frontend/src/app/services/notification.ts new file mode 100644 index 0000000..27ff5a8 --- /dev/null +++ b/eeg_frontend/src/app/services/notification.ts @@ -0,0 +1,44 @@ +import {inject, Injectable, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; + +export interface Notification { + id: string; + userId: string; + title: string; + message: string; + type: string; + read: boolean; + createdAt: string; +} + +@Injectable({providedIn: 'root'}) +export class NotificationService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8080/api/notifications'; + + unreadCount = signal(0); + + getNotifications(): Observable { + return this.http.get(this.apiUrl); + } + + getUnreadCount(): Observable { + return this.http.get(`${this.apiUrl}/unread`).pipe( + tap(count => this.unreadCount.set(count)) + ); + } + + markAsRead(id: string): Observable { + return this.http.put(`${this.apiUrl}/${id}/read`, {}).pipe( + tap(() => this.unreadCount.update(c => Math.max(0, c - 1))) + ); + } + + markAllAsRead(): Observable { + return this.http.put(`${this.apiUrl}/read-all`, {}).pipe( + tap(() => this.unreadCount.set(0)) + ); + } +}