diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java new file mode 100644 index 0000000..0e5f5b3 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/UserController.java @@ -0,0 +1,55 @@ +package at.mueller.eeg.backend.iam.api; + +import at.mueller.eeg.backend.common.security.CurrentUserId; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangeEmailRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UpdateProfileRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; +import at.mueller.eeg.backend.iam.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +@Tag(name = "User Profile", description = "Profilverwaltung für eingeloggte User") +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + @Operation(summary = "Eigenes Profil abrufen") + public ResponseEntity getOwnProfile(@CurrentUserId String userId) { + return ResponseEntity.ok(userService.getProfile(java.util.UUID.fromString(userId))); + } + + @PutMapping("/me") + @Operation(summary = "Profil aktualisieren") + public ResponseEntity updateOwnProfile( + @CurrentUserId String userId, + @Valid @RequestBody UpdateProfileRequest request) { + return ResponseEntity.ok(userService.updateProfile(java.util.UUID.fromString(userId), request)); + } + + @PutMapping("/me/password") + @Operation(summary = "Passwort ändern") + public ResponseEntity changePassword( + @CurrentUserId String userId, + @Valid @RequestBody ChangePasswordRequest request) { + userService.changePassword(java.util.UUID.fromString(userId), request); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/email") + @Operation(summary = "E-Mail ändern (mit erneuter Verifizierung)") + public ResponseEntity changeEmail( + @CurrentUserId String userId, + @Valid @RequestBody ChangeEmailRequest request) { + userService.changeEmail(java.util.UUID.fromString(userId), request); + return ResponseEntity.ok().build(); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java index c212ca1..854f38b 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/dto/IamDtos.java @@ -53,4 +53,33 @@ public interface IamDtos { ) {} record ErrorResponse(String message, int status) {} + + @Schema(description = "Payload für die Profilaktualisierung") + record UpdateProfileRequest( + @NotBlank + @Schema(description = "Vorname", example = "Max") + String firstName, + @NotBlank + @Schema(description = "Nachname", example = "Mustermann") + String lastName, + @Schema(description = "Nur bei juristischer Person: Firmen-, Vereins- oder Gemeindename") + String organizationName, + @NotNull + @Schema(description = "Privatperson oder juristische Person", example = "PRIVATE") + ParticipantType participantType + ) {} + + @Schema(description = "Payload für die Passwortänderung") + record ChangePasswordRequest( + @NotBlank + @Schema(description = "Aktuelles Passwort") String currentPassword, + @NotBlank @Size(min = 8) + @Schema(description = "Neues Passwort", example = "NeuesPasswort123!") String newPassword + ) {} + + @Schema(description = "Payload für die E-Mail-Änderung") + record ChangeEmailRequest( + @Email @NotBlank + @Schema(description = "Neue E-Mail-Adresse", example = "neu@example.com") String newEmail + ) {} } \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java new file mode 100644 index 0000000..2f2c00b --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/UserService.java @@ -0,0 +1,83 @@ +package at.mueller.eeg.backend.iam.service; + +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangeEmailRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UpdateProfileRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public UserProfileResponse getProfile(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden")); + return toProfileResponse(user); + } + + @Transactional + public UserProfileResponse updateProfile(UUID userId, UpdateProfileRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden")); + + user.setFirstName(request.firstName()); + user.setLastName(request.lastName()); + user.setOrganizationName(request.organizationName()); + user.setParticipantType(request.participantType()); + + User saved = userRepository.save(user); + return toProfileResponse(saved); + } + + @Transactional + public void changePassword(UUID userId, ChangePasswordRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden")); + + if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) { + throw new IllegalArgumentException("Aktuelles Passwort ist falsch"); + } + + user.setPasswordHash(passwordEncoder.encode(request.newPassword())); + userRepository.save(user); + } + + @Transactional + public void changeEmail(UUID userId, ChangeEmailRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden")); + + if (userRepository.findByEmail(request.newEmail()).isPresent()) { + throw new IllegalArgumentException("E-Mail-Adresse ist bereits vergeben"); + } + + user.setEmail(request.newEmail()); + user.setEmailVerified(false); + user.setVerificationToken(UUID.randomUUID().toString()); + userRepository.save(user); + } + + private UserProfileResponse toProfileResponse(User user) { + return new UserProfileResponse( + user.getId(), + user.getParticipantType(), + user.getFirstName(), + user.getLastName(), + user.getOrganizationName(), + user.getEmail(), + user.getStatus().name() + ); + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/UserServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/UserServiceTest.java new file mode 100644 index 0000000..acdbaa5 --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/UserServiceTest.java @@ -0,0 +1,149 @@ +package at.mueller.eeg.backend.iam.service; + +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangeEmailRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.ChangePasswordRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UpdateProfileRequest; +import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; +import at.mueller.eeg.backend.iam.domain.ParticipantType; +import at.mueller.eeg.backend.iam.domain.RegistrationStatus; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.domain.UserRole; +import at.mueller.eeg.backend.iam.repository.UserRepository; +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 org.springframework.security.crypto.password.PasswordEncoder; + +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + private User testUser; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = new User(); + testUser.setId(testUserId); + testUser.setEmail("test@example.com"); + testUser.setPasswordHash(passwordEncoder.encode("OldPass123!")); + testUser.setFirstName("Test"); + testUser.setLastName("Benutzer"); + testUser.setParticipantType(ParticipantType.PRIVATE); + testUser.setRole(UserRole.MEMBER); + testUser.setStatus(RegistrationStatus.APPROVED); + testUser.setEnabled(true); + testUser.setEmailVerified(true); + } + + @Test + void getProfile_returnsCorrectUser() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + + UserProfileResponse response = userService.getProfile(testUserId); + + assertEquals(testUserId, response.id()); + assertEquals("Test", response.firstName()); + assertEquals("Benutzer", response.lastName()); + assertEquals("test@example.com", response.email()); + assertEquals(ParticipantType.PRIVATE, response.participantType()); + } + + @Test + void getProfile_throwsForUnknownUser() { + when(userRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> userService.getProfile(UUID.randomUUID())); + } + + @Test + void updateProfile_updatesFields() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userRepository.save(any())).thenReturn(testUser); + + UpdateProfileRequest request = new UpdateProfileRequest( + "Max", "Mustermann", "Test GmbH", ParticipantType.COMPANY); + + UserProfileResponse response = userService.updateProfile(testUserId, request); + + assertEquals("Max", response.firstName()); + assertEquals("Mustermann", response.lastName()); + assertEquals("Test GmbH", response.organizationName()); + assertEquals(ParticipantType.COMPANY, response.participantType()); + verify(userRepository).save(any()); + } + + @Test + void changePassword_throwsForWrongCurrentPassword() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("WrongPass", testUser.getPasswordHash())).thenReturn(false); + + ChangePasswordRequest request = new ChangePasswordRequest("WrongPass", "NewPass123!"); + + assertThrows(IllegalArgumentException.class, () -> userService.changePassword(testUserId, request)); + verify(userRepository, never()).save(any()); + } + + @Test + void changePassword_updatesHash() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("OldPass123!", testUser.getPasswordHash())).thenReturn(true); + when(passwordEncoder.encode("NewPass123!")).thenReturn("encoded_new_password"); + + ChangePasswordRequest request = new ChangePasswordRequest("OldPass123!", "NewPass123!"); + + userService.changePassword(testUserId, request); + + verify(passwordEncoder).encode("NewPass123!"); + verify(userRepository).save(any()); + } + + @Test + void changeEmail_throwsForDuplicateEmail() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(new User())); + + ChangeEmailRequest request = new ChangeEmailRequest("taken@example.com"); + + assertThrows(IllegalArgumentException.class, () -> userService.changeEmail(testUserId, request)); + verify(userRepository, never()).save(any()); + } + + @Test + void changeEmail_setsVerificationToken() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenReturn(testUser); + + ChangeEmailRequest request = new ChangeEmailRequest("new@example.com"); + + userService.changeEmail(testUserId, request); + + verify(userRepository).save(argThat(user -> { + User u = (User) user; + return "new@example.com".equals(u.getEmail()) + && !u.isEmailVerified() + && u.getVerificationToken() != null; + })); + } +} diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index 520fd32..0e2835d 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -10,6 +10,7 @@ import {DashboardOverview} from './pages/dashboard-overview/dashboard-overview'; import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community'; import {VerifyEmailComponent} from './pages/verify-email/verify-email'; import {MeteringPointsComponent} from './pages/metering-points/metering-points'; +import {ProfileComponent} from './pages/profile/profile'; export const routes: Routes = [ { path: '', component: LandingPage }, @@ -36,6 +37,11 @@ export const routes: Routes = [ path: 'energy-communities', component: AdminEnergyCommunity, canActivate: [roleGuard(['ADMIN'])] + }, + { + path: 'profile', + component: ProfileComponent, + canActivate: [roleGuard(['ADMIN', 'MEMBER'])] } ] }, diff --git a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts index d64a9b5..b3f4dac 100644 --- a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts +++ b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts @@ -4,11 +4,10 @@ export interface NavItem { roles: string[]; } -// Neue Einträge einfach hinzufügen, z.B. für Member-spezifische Seiten: -// {label: 'Meine Zählpunkte', path: '/dashboard/metering-points', roles: ['MEMBER']} export const NAV_ITEMS: NavItem[] = [ {label: 'Übersicht', path: '/dashboard', roles: ['ADMIN', 'MEMBER']}, {label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']}, + {label: 'Mein Profil', path: '/dashboard/profile', roles: ['ADMIN', 'MEMBER']}, {label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']}, {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']} ]; diff --git a/eeg_frontend/src/app/pages/profile/profile.html b/eeg_frontend/src/app/pages/profile/profile.html new file mode 100644 index 0000000..b9464ef --- /dev/null +++ b/eeg_frontend/src/app/pages/profile/profile.html @@ -0,0 +1,106 @@ +
+

Mein Profil

+ + @if (isLoading()) { +
Lade Profil...
+ } @else if (profile()) { + + @if (successMessage()) { +
+ {{ successMessage() }} +
+ } + @if (errorMessage()) { +
+ {{ errorMessage() }} +
+ } + + +
+

Persönliche Daten

+ +
+
+ + +
+
+ + +
+
+ + +
+ @if (editParticipantType === 'COMPANY') { +
+ + +
+ } +
+ +
+ +
{{ profile()!.email }}
+
+ + +
+ + +
+

Passwort ändern

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

E-Mail-Adresse ändern

+ +
+ + +
+ + +
+ } +
diff --git a/eeg_frontend/src/app/pages/profile/profile.ts b/eeg_frontend/src/app/pages/profile/profile.ts new file mode 100644 index 0000000..25e0c97 --- /dev/null +++ b/eeg_frontend/src/app/pages/profile/profile.ts @@ -0,0 +1,122 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {UserService, UserProfile, UpdateProfileRequest} from '../../services/user'; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [FormsModule], + templateUrl: './profile.html' +}) +export class ProfileComponent implements OnInit { + private userService = inject(UserService); + + profile = signal(null); + isLoading = signal(true); + isSaving = signal(false); + successMessage = signal(''); + errorMessage = signal(''); + + editFirstName = ''; + editLastName = ''; + editOrganizationName = ''; + editParticipantType = 'PRIVATE'; + + currentPassword = ''; + newPassword = ''; + newPasswordConfirm = ''; + + newEmail = ''; + + ngOnInit() { + this.loadProfile(); + } + + loadProfile() { + this.userService.getProfile().subscribe({ + next: profile => { + this.profile.set(profile); + this.editFirstName = profile.firstName || ''; + this.editLastName = profile.lastName || ''; + this.editOrganizationName = profile.organizationName || ''; + this.editParticipantType = profile.participantType; + this.isLoading.set(false); + }, + error: () => this.isLoading.set(false) + }); + } + + updateProfile() { + if (this.isSaving()) return; + this.isSaving.set(true); + this.successMessage.set(''); + this.errorMessage.set(''); + + const request: UpdateProfileRequest = { + firstName: this.editFirstName, + lastName: this.editLastName, + organizationName: this.editOrganizationName || undefined, + participantType: this.editParticipantType as 'PRIVATE' | 'COMPANY' + }; + + this.userService.updateProfile(request).subscribe({ + next: profile => { + this.profile.set(profile); + this.isSaving.set(false); + this.successMessage.set('Profil erfolgreich aktualisiert.'); + }, + error: (err) => { + this.isSaving.set(false); + this.errorMessage.set(err.error?.message || 'Fehler beim Aktualisieren.'); + } + }); + } + + changePassword() { + if (this.isSaving() || !this.currentPassword || !this.newPassword) return; + if (this.newPassword !== this.newPasswordConfirm) { + this.errorMessage.set('Passwörter stimmen nicht überein.'); + return; + } + + this.isSaving.set(true); + this.successMessage.set(''); + this.errorMessage.set(''); + + this.userService.changePassword({ + currentPassword: this.currentPassword, + newPassword: this.newPassword + }).subscribe({ + next: () => { + this.isSaving.set(false); + this.successMessage.set('Passwort erfolgreich geändert.'); + this.currentPassword = ''; + this.newPassword = ''; + this.newPasswordConfirm = ''; + }, + error: (err) => { + this.isSaving.set(false); + this.errorMessage.set(err.error?.message || 'Fehler beim Ändern des Passworts.'); + } + }); + } + + changeEmail() { + if (this.isSaving() || !this.newEmail) return; + this.isSaving.set(true); + this.successMessage.set(''); + this.errorMessage.set(''); + + this.userService.changeEmail({newEmail: this.newEmail}).subscribe({ + next: () => { + this.isSaving.set(false); + this.successMessage.set('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.'); + this.newEmail = ''; + }, + error: (err) => { + this.isSaving.set(false); + this.errorMessage.set(err.error?.message || 'Fehler beim Ändern der E-Mail.'); + } + }); + } +} diff --git a/eeg_frontend/src/app/services/user.ts b/eeg_frontend/src/app/services/user.ts new file mode 100644 index 0000000..f3f145b --- /dev/null +++ b/eeg_frontend/src/app/services/user.ts @@ -0,0 +1,51 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; + +export interface UserProfile { + id: string; + participantType: string; + firstName: string; + lastName: string; + organizationName: string | null; + email: string; + status: string; +} + +export interface UpdateProfileRequest { + firstName: string; + lastName: string; + organizationName?: string; + participantType: 'PRIVATE' | 'COMPANY'; +} + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +export interface ChangeEmailRequest { + newEmail: string; +} + +@Injectable({providedIn: 'root'}) +export class UserService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8080/api'; + + getProfile(): Observable { + return this.http.get(`${this.apiUrl}/users/me`); + } + + updateProfile(request: UpdateProfileRequest): Observable { + return this.http.put(`${this.apiUrl}/users/me`, request); + } + + changePassword(request: ChangePasswordRequest): Observable { + return this.http.put(`${this.apiUrl}/users/me/password`, request); + } + + changeEmail(request: ChangeEmailRequest): Observable { + return this.http.put(`${this.apiUrl}/users/me/email`, request); + } +}