feat(iam): add complete notification system with email and in-app notifications

Backend:
- Generalize MailService with send() method
- Add Notification entity, repository, service, controller
- Add NotificationListener for 6 domain events
- Add password reset flow (forgot-password, reset-password)
- Add configurable frontend URL in application.yml

Frontend:
- Add notification service and bell-icon component
- Add forgot-password and reset-password pages
- Add routes for new pages

Tests:
- Add NotificationServiceTest with 5 unit tests
- All 27 tests passing
This commit is contained in:
Bernhard Müller 2026-07-21 13:38:15 +02:00
parent a2025a55e4
commit 04be9a62ea
22 changed files with 865 additions and 11 deletions

View File

@ -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<List<Notification>> getNotifications(@CurrentUserId String userId) {
return ResponseEntity.ok(notificationService.getByUserId(UUID.fromString(userId)));
}
@GetMapping("/unread")
@PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')")
public ResponseEntity<Long> getUnreadCount(@CurrentUserId String userId) {
return ResponseEntity.ok(notificationService.getUnreadCount(UUID.fromString(userId)));
}
@PutMapping("/{id}/read")
@PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')")
public ResponseEntity<Void> markAsRead(@PathVariable UUID id) {
notificationService.markAsRead(id);
return ResponseEntity.ok().build();
}
@PutMapping("/read-all")
@PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')")
public ResponseEntity<Void> markAllAsRead(@CurrentUserId String userId) {
notificationService.markAllAsRead(UUID.fromString(userId));
return ResponseEntity.ok().build();
}
}

View File

@ -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();
}

View File

@ -0,0 +1,8 @@
package at.mueller.eeg.backend.common.domain;
public enum NotificationType {
INFO,
SUCCESS,
WARNING,
ERROR
}

View File

@ -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<MeteringPoint> findMeteringPointByAtNumber(String atNumber) {
return meteringPointRepository.findByAtNumber(atNumber);
}
}

View File

@ -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<Notification, UUID> {
List<Notification> 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);
}

View File

@ -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<Notification> 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);
}
}

View File

@ -3,6 +3,8 @@ package at.mueller.eeg.backend.iam.api;
import at.mueller.eeg.backend.common.security.CurrentUserId; 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.ChangeEmailRequest;
import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; 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.UpdateProfileRequest;
import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse;
import at.mueller.eeg.backend.iam.service.UserService; import at.mueller.eeg.backend.iam.service.UserService;
@ -14,20 +16,20 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "User Profile", description = "Profilverwaltung für eingeloggte User") @Tag(name = "User Profile", description = "Profilverwaltung und Passwort-Reset")
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
@GetMapping("/me") @GetMapping("/users/me")
@Operation(summary = "Eigenes Profil abrufen") @Operation(summary = "Eigenes Profil abrufen")
public ResponseEntity<UserProfileResponse> getOwnProfile(@CurrentUserId String userId) { public ResponseEntity<UserProfileResponse> getOwnProfile(@CurrentUserId String userId) {
return ResponseEntity.ok(userService.getProfile(java.util.UUID.fromString(userId))); return ResponseEntity.ok(userService.getProfile(java.util.UUID.fromString(userId)));
} }
@PutMapping("/me") @PutMapping("/users/me")
@Operation(summary = "Profil aktualisieren") @Operation(summary = "Profil aktualisieren")
public ResponseEntity<UserProfileResponse> updateOwnProfile( public ResponseEntity<UserProfileResponse> updateOwnProfile(
@CurrentUserId String userId, @CurrentUserId String userId,
@ -35,7 +37,7 @@ public class UserController {
return ResponseEntity.ok(userService.updateProfile(java.util.UUID.fromString(userId), request)); return ResponseEntity.ok(userService.updateProfile(java.util.UUID.fromString(userId), request));
} }
@PutMapping("/me/password") @PutMapping("/users/me/password")
@Operation(summary = "Passwort ändern") @Operation(summary = "Passwort ändern")
public ResponseEntity<Void> changePassword( public ResponseEntity<Void> changePassword(
@CurrentUserId String userId, @CurrentUserId String userId,
@ -44,7 +46,7 @@ public class UserController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@PutMapping("/me/email") @PutMapping("/users/me/email")
@Operation(summary = "E-Mail ändern (mit erneuter Verifizierung)") @Operation(summary = "E-Mail ändern (mit erneuter Verifizierung)")
public ResponseEntity<Void> changeEmail( public ResponseEntity<Void> changeEmail(
@CurrentUserId String userId, @CurrentUserId String userId,
@ -52,4 +54,18 @@ public class UserController {
userService.changeEmail(java.util.UUID.fromString(userId), request); userService.changeEmail(java.util.UUID.fromString(userId), request);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@PostMapping("/auth/forgot-password")
@Operation(summary = "Passwort-Reset anfordern")
public ResponseEntity<Void> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
userService.forgotPassword(request);
return ResponseEntity.ok().build();
}
@PostMapping("/auth/reset-password")
@Operation(summary = "Passwort zurücksetzen")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
userService.resetPassword(request);
return ResponseEntity.ok().build();
}
} }

View File

@ -82,4 +82,18 @@ public interface IamDtos {
@Email @NotBlank @Email @NotBlank
@Schema(description = "Neue E-Mail-Adresse", example = "neu@example.com") String newEmail @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
) {}
} }

View File

@ -9,6 +9,7 @@ import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDateTime;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -45,6 +46,12 @@ public class User implements UserDetails {
@Column(name = "verification_token") @Column(name = "verification_token")
private String verificationToken; private String verificationToken;
@Column(name = "password_reset_token")
private String passwordResetToken;
@Column(name = "password_reset_token_expiry")
private LocalDateTime passwordResetTokenExpiry;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private UserRole role = UserRole.MEMBER; private UserRole role = UserRole.MEMBER;

View File

@ -17,4 +17,6 @@ public interface UserRepository extends JpaRepository<User, UUID> {
long countByStatusAndEmailVerified(RegistrationStatus status, Boolean verified); long countByStatusAndEmailVerified(RegistrationStatus status, Boolean verified);
Optional<User> findByVerificationToken(String token); Optional<User> findByVerificationToken(String token);
Optional<User> findByPasswordResetToken(String token);
} }

View File

@ -1,6 +1,7 @@
package at.mueller.eeg.backend.iam.service; package at.mueller.eeg.backend.iam.service;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -9,14 +10,17 @@ import org.springframework.stereotype.Service;
@Slf4j @Slf4j
public class ConsoleMailService implements MailService { public class ConsoleMailService implements MailService {
@Value("${app.frontend-url:http://localhost:4200}")
private String frontendUrl;
@Override @Override
public void sendVerificationEmail(String toEmail, String token) { 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(""" log.info("""
======================================================================== ========================================================================
📧 SIMULIERTER E-MAIL-VERSAND (Profile: DEV) SIMULIERTER E-MAIL-VERSAND (Profile: DEV)
======================================================================== ========================================================================
An: {} An: {}
Betreff: Bitte bestätige deine E-Mail-Adresse für das EEG-Portal Betreff: Bitte bestätige deine E-Mail-Adresse für das EEG-Portal
@ -26,4 +30,19 @@ public class ConsoleMailService implements MailService {
======================================================================== ========================================================================
""", toEmail, verificationUrl); """, 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);
}
} }

View File

@ -2,4 +2,5 @@ package at.mueller.eeg.backend.iam.service;
public interface MailService { public interface MailService {
void sendVerificationEmail(String toEmail, String token); void sendVerificationEmail(String toEmail, String token);
void send(String to, String subject, String body);
} }

View File

@ -1,6 +1,7 @@
package at.mueller.eeg.backend.iam.service; package at.mueller.eeg.backend.iam.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
@ -13,9 +14,12 @@ public class SmtpMailService implements MailService {
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
@Value("${app.frontend-url:http://localhost:4200}")
private String frontendUrl;
@Override @Override
public void sendVerificationEmail(String toEmail, String token) { 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(); SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail); message.setTo(toEmail);
@ -24,4 +28,14 @@ public class SmtpMailService implements MailService {
mailSender.send(message); 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);
}
} }

View File

@ -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.ChangeEmailRequest;
import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; 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.UpdateProfileRequest;
import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse;
import at.mueller.eeg.backend.iam.domain.User; import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.repository.UserRepository; import at.mueller.eeg.backend.iam.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -19,6 +23,10 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final MailService mailService;
@Value("${app.frontend-url:http://localhost:4200}")
private String frontendUrl;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public UserProfileResponse getProfile(UUID userId) { public UserProfileResponse getProfile(UUID userId) {
@ -69,6 +77,39 @@ public class UserService {
userRepository.save(user); 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) { private UserProfileResponse toProfileResponse(User user) {
return new UserProfileResponse( return new UserProfileResponse(
user.getId(), user.getId(),

View File

@ -12,3 +12,6 @@ spring:
show-sql: true show-sql: true
jwt: jwt:
secret: MeinSuperGeheimesUndSehrLangesSecretFuerDasEnergiePortal2025! secret: MeinSuperGeheimesUndSehrLangesSecretFuerDasEnergiePortal2025!
app:
frontend-url: http://localhost:4200
backend-url: http://localhost:8080

View File

@ -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<Notification> 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);
}
}

View File

@ -3,9 +3,12 @@ package at.mueller.eeg.backend.iam.service;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class FakeMailService implements MailService{ public class FakeMailService implements MailService {
@Override @Override
public void sendVerificationEmail(String toEmail, String token) { public void sendVerificationEmail(String toEmail, String token) {
}
@Override
public void send(String to, String subject, String body) {
} }
} }

View File

@ -14,12 +14,16 @@ import {ProfileComponent} from './pages/profile/profile';
import {JoinCommunityComponent} from './pages/join-community/join-community'; import {JoinCommunityComponent} from './pages/join-community/join-community';
import {MyMembershipsComponent} from './pages/my-memberships/my-memberships'; import {MyMembershipsComponent} from './pages/my-memberships/my-memberships';
import {AdminMembershipsComponent} from './pages/admin-memberships/admin-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 = [ export const routes: Routes = [
{ path: '', component: LandingPage }, { path: '', component: LandingPage },
{ path: 'register', component: Register }, { path: 'register', component: Register },
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'verify-email', component: VerifyEmailComponent }, { path: 'verify-email', component: VerifyEmailComponent },
{ path: 'forgot-password', component: ForgotPasswordComponent },
{ path: 'reset-password', component: ResetPasswordComponent },
{ {
path: 'dashboard', path: 'dashboard',
component: DashboardLayout, component: DashboardLayout,

View File

@ -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: `
<div class="relative">
<button (click)="toggleDropdown()" class="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 8.165 6 10.153 6 12.5v2.158c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
@if (notificationService.unreadCount() > 0) {
<span class="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center">
{{ notificationService.unreadCount() > 99 ? '99+' : notificationService.unreadCount() }}
</span>
}
</button>
@if (isOpen()) {
<div class="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div class="p-3 border-b border-gray-200 flex justify-between items-center">
<h3 class="font-semibold text-gray-900">Benachrichtigungen</h3>
@if (notificationService.unreadCount() > 0) {
<button (click)="markAllAsRead()" class="text-sm text-blue-600 hover:text-blue-800">
Alle als gelesen markieren
</button>
}
</div>
<div class="max-h-96 overflow-y-auto">
@if (notifications().length === 0) {
<div class="p-4 text-center text-gray-500">Keine Benachrichtigungen</div>
} @else {
@for (notification of notifications(); track notification.id) {
<div (click)="markAsRead(notification)"
[class]="notification.read ? 'p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer' : 'p-3 border-b border-gray-100 bg-blue-50 hover:bg-blue-100 cursor-pointer'">
<div class="flex items-start">
<div [class]="getTypeClass(notification.type)" class="h-2 w-2 rounded-full mt-2 mr-2"></div>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">{{ notification.title }}</p>
<p class="text-sm text-gray-600 mt-1">{{ notification.message }}</p>
<p class="text-xs text-gray-400 mt-1">{{ formatDate(notification.createdAt) }}</p>
</div>
</div>
</div>
}
}
</div>
</div>
}
</div>
`
})
export class NotificationComponent implements OnInit {
notificationService = inject(NotificationService);
isOpen = signal(false);
notifications = signal<Notification[]>([]);
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');
}
}

View File

@ -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: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Passwort vergessen?
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen.
</p>
</div>
@if (isSuccess()) {
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-green-800 text-sm">Ein Link zum Zurücksetzen wurde an Ihre E-Mail-Adresse gesendet.</p>
</div>
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="mt-8 space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">E-Mail-Adresse</label>
<input id="email" type="email" formControlName="email"
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="max@example.com" />
@if (form.get('email')?.invalid && form.get('email')?.touched) {
<p class="mt-1 text-sm text-red-600">Bitte geben Sie eine gültige E-Mail-Adresse ein.</p>
}
</div>
<div>
<button type="submit" [disabled]="form.invalid || isLoading()"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
{{ isLoading() ? 'Sende...' : 'Link anfordern' }}
</button>
</div>
<div class="text-center">
<a routerLink="/login" class="font-medium text-blue-600 hover:text-blue-500">
Zurück zum Login
</a>
</div>
</form>
</div>
</div>
`
})
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.');
}
});
}
}

View File

@ -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: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Passwort zurücksetzen
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Geben Sie Ihr neues Passwort ein.
</p>
</div>
@if (isSuccess()) {
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-green-800 text-sm">Ihr Passwort wurde erfolgreich zurückgesetzt.</p>
<a routerLink="/login" class="mt-2 inline-block text-green-600 hover:text-green-800 font-medium">
Zum Login
</a>
</div>
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="mt-8 space-y-6">
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Neues Passwort</label>
<input id="password" type="password" formControlName="newPassword"
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Mindestens 8 Zeichen" />
@if (form.get('newPassword')?.invalid && form.get('newPassword')?.touched) {
<p class="mt-1 text-sm text-red-600">Passwort muss mindestens 8 Zeichen lang sein.</p>
}
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700">Passwort bestätigen</label>
<input id="confirmPassword" type="password" formControlName="confirmPassword"
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Passwort wiederholen" />
@if (form.get('confirmPassword')?.touched && form.get('confirmPassword')?.value !== form.get('newPassword')?.value) {
<p class="mt-1 text-sm text-red-600">Passwörter stimmen nicht überein.</p>
}
</div>
<div>
<button type="submit" [disabled]="form.invalid || isLoading()"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50">
{{ isLoading() ? 'Speichere...' : 'Passwort zurücksetzen' }}
</button>
</div>
</form>
</div>
</div>
`
})
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.');
}
});
}
}

View File

@ -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<Notification[]> {
return this.http.get<Notification[]>(this.apiUrl);
}
getUnreadCount(): Observable<number> {
return this.http.get<number>(`${this.apiUrl}/unread`).pipe(
tap(count => this.unreadCount.set(count))
);
}
markAsRead(id: string): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/${id}/read`, {}).pipe(
tap(() => this.unreadCount.update(c => Math.max(0, c - 1)))
);
}
markAllAsRead(): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/read-all`, {}).pipe(
tap(() => this.unreadCount.set(0))
);
}
}