add user profile management

- Add UserController with GET/PUT /me, PUT /me/password, PUT /me/email
- Add UserService with profile update, password change, email change
- Add UserServiceTest with 7 unit tests
- Add IamDtos: UpdateProfileRequest, ChangePasswordRequest, ChangeEmailRequest
- Add Angular profile page with edit profile, change password, change email
- Add Angular UserService for profile API calls
- Add profile route and navigation item
This commit is contained in:
Bernhard Müller 2026-07-21 11:12:20 +02:00
parent 8f74dbde35
commit 52c6641b3a
9 changed files with 602 additions and 2 deletions

View File

@ -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<UserProfileResponse> getOwnProfile(@CurrentUserId String userId) {
return ResponseEntity.ok(userService.getProfile(java.util.UUID.fromString(userId)));
}
@PutMapping("/me")
@Operation(summary = "Profil aktualisieren")
public ResponseEntity<UserProfileResponse> 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<Void> 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<Void> changeEmail(
@CurrentUserId String userId,
@Valid @RequestBody ChangeEmailRequest request) {
userService.changeEmail(java.util.UUID.fromString(userId), request);
return ResponseEntity.ok().build();
}
}

View File

@ -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
) {}
}

View File

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

View File

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

View File

@ -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'])]
}
]
},

View File

@ -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']}
];

View File

@ -0,0 +1,106 @@
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Mein Profil</h1>
@if (isLoading()) {
<div class="text-center py-8 text-gray-500">Lade Profil...</div>
} @else if (profile()) {
<!-- Erfolg/Fehler-Meldungen -->
@if (successMessage()) {
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ successMessage() }}
</div>
}
@if (errorMessage()) {
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ errorMessage() }}
</div>
}
<!-- Profil-Informationen -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Vorname</label>
<input type="text" [(ngModel)]="editFirstName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nachname</label>
<input type="text" [(ngModel)]="editLastName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Teilnehmertyp</label>
<select [(ngModel)]="editParticipantType"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="PRIVATE">Privatperson</option>
<option value="COMPANY">Juristische Person</option>
</select>
</div>
@if (editParticipantType === 'COMPANY') {
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label>
<input type="text" [(ngModel)]="editOrganizationName"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
}
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<div class="text-gray-900">{{ profile()!.email }}</div>
</div>
<button (click)="updateProfile()" [disabled]="isSaving()"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Speichere...' : 'Profil speichern' }}
</button>
</div>
<!-- Passwort ändern -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label>
<input type="password" [(ngModel)]="currentPassword"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort</label>
<input type="password" [(ngModel)]="newPassword"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort bestätigen</label>
<input type="password" [(ngModel)]="newPasswordConfirm"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</div>
<button (click)="changePassword()" [disabled]="isSaving()"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Ändere...' : 'Passwort ändern' }}
</button>
</div>
<!-- E-Mail ändern -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse</label>
<input type="email" [(ngModel)]="newEmail" placeholder="neu@example.com"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<button (click)="changeEmail()" [disabled]="isSaving()"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }}
</button>
</div>
}
</div>

View File

@ -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<UserProfile | null>(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.');
}
});
}
}

View File

@ -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<UserProfile> {
return this.http.get<UserProfile>(`${this.apiUrl}/users/me`);
}
updateProfile(request: UpdateProfileRequest): Observable<UserProfile> {
return this.http.put<UserProfile>(`${this.apiUrl}/users/me`, request);
}
changePassword(request: ChangePasswordRequest): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/users/me/password`, request);
}
changeEmail(request: ChangeEmailRequest): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/users/me/email`, request);
}
}