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:
parent
a2025a55e4
commit
04be9a62ea
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package at.mueller.eeg.backend.common.domain;
|
||||||
|
|
||||||
|
public enum NotificationType {
|
||||||
|
INFO,
|
||||||
|
SUCCESS,
|
||||||
|
WARNING,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,9 @@ import org.springframework.stereotype.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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
120
eeg_frontend/src/app/components/notification/notification.ts
Normal file
120
eeg_frontend/src/app/components/notification/notification.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
113
eeg_frontend/src/app/pages/reset-password/reset-password.ts
Normal file
113
eeg_frontend/src/app/pages/reset-password/reset-password.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
eeg_frontend/src/app/services/notification.ts
Normal file
44
eeg_frontend/src/app/services/notification.ts
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user