generalized dashboard for member and admin
This commit is contained in:
parent
caf05d3103
commit
59a0b6b9b7
38 changed files with 528 additions and 93 deletions
|
|
@ -8,7 +8,7 @@
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jdk21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -37,6 +37,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package at.mueller.eeg.backend.dashboard.api;
|
||||||
|
|
||||||
|
import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto;
|
||||||
|
import at.mueller.eeg.backend.dashboard.service.DashboardService;
|
||||||
|
import at.mueller.eeg.backend.iam.domain.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/dashboard")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
private final DashboardService dashboardService;
|
||||||
|
|
||||||
|
@GetMapping("/admin")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<DashboardStatsDto.AdminStats> getAdminDashboard() {
|
||||||
|
return ResponseEntity.ok(dashboardService.getAdminStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/user")
|
||||||
|
@PreAuthorize("hasRole('MEMBER')")
|
||||||
|
public ResponseEntity<DashboardStatsDto.UserStats> getUserDashboard(@AuthenticationPrincipal User user) {
|
||||||
|
// user.getId() funktioniert, da UserDetails implementiert ist
|
||||||
|
return ResponseEntity.ok(dashboardService.getUserStats(user.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package at.mueller.eeg.backend.dashboard.api.dto;
|
||||||
|
|
||||||
|
public interface DashboardStatsDto {
|
||||||
|
record AdminStats(
|
||||||
|
long totalCommunities,
|
||||||
|
long pendingUsers,
|
||||||
|
long totalActiveMemberships
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record UserStats(
|
||||||
|
long totalMeteringPoints,
|
||||||
|
long activeMemberships,
|
||||||
|
long pendingMemberships
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package at.mueller.eeg.backend.dashboard.service;
|
||||||
|
|
||||||
|
import at.mueller.eeg.backend.community.domain.MembershipStatus;
|
||||||
|
import at.mueller.eeg.backend.community.repository.EnergyCommunityRepository;
|
||||||
|
import at.mueller.eeg.backend.community.repository.MembershipRepository;
|
||||||
|
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
|
||||||
|
import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto;
|
||||||
|
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
|
||||||
|
import at.mueller.eeg.backend.iam.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class DashboardService {
|
||||||
|
|
||||||
|
private final EnergyCommunityRepository energyCommunityRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final MembershipRepository membershipRepository;
|
||||||
|
private final MeteringPointRepository meteringPointRepository;
|
||||||
|
|
||||||
|
public DashboardStatsDto.AdminStats getAdminStats() {
|
||||||
|
long communities = energyCommunityRepository.count();
|
||||||
|
long pendingUsers = userRepository.findAllByStatusAndEmailVerified(RegistrationStatus.PENDING, true).size();
|
||||||
|
|
||||||
|
// Zählt alle aktiven Mitgliedschaften
|
||||||
|
long activeMemberships = membershipRepository.findAll().stream()
|
||||||
|
.filter(m -> m.getStatus() == MembershipStatus.ACTIVE)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
return new DashboardStatsDto.AdminStats(communities, pendingUsers, activeMemberships);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardStatsDto.UserStats getUserStats(UUID userId) {
|
||||||
|
long meteringPoints = meteringPointRepository.findAllByUserId(userId).size();
|
||||||
|
|
||||||
|
// Zählt die Mitgliedschaften der Zählpunkte des Users
|
||||||
|
long activeMembers = 0; // Hier idealerweise eine angepasste Query im Repository nutzen
|
||||||
|
long pendingMembers = 0;
|
||||||
|
|
||||||
|
return new DashboardStatsDto.UserStats(meteringPoints, activeMembers, pendingMembers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,7 @@ import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
|
|
@ -41,4 +38,10 @@ public class AuthController {
|
||||||
return ResponseEntity.ok(authService.login(request));
|
return ResponseEntity.ok(authService.login(request));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/verify-email")
|
||||||
|
public ResponseEntity<Void> verifyEmail(@RequestParam String token) {
|
||||||
|
authService.verifyEmail(token);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ public class User implements UserDetails {
|
||||||
|
|
||||||
private String iban;
|
private String iban;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean emailVerified = false;
|
||||||
|
|
||||||
|
@Column(name = "verification_token")
|
||||||
|
private String verificationToken;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private UserRole role = UserRole.MEMBER;
|
private UserRole role = UserRole.MEMBER;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ public interface UserMapper {
|
||||||
@Mapping(target = "iban", ignore = true)
|
@Mapping(target = "iban", ignore = true)
|
||||||
@Mapping(target = "enabled", constant = "false")
|
@Mapping(target = "enabled", constant = "false")
|
||||||
@Mapping(target = "authorities", ignore = true)
|
@Mapping(target = "authorities", ignore = true)
|
||||||
|
@Mapping(target = "emailVerified", ignore = true)
|
||||||
|
@Mapping(target = "verificationToken", ignore = true)
|
||||||
User mapToEntity(IamDtos.RegistrationRequest request, @MappingTarget User user);
|
User mapToEntity(IamDtos.RegistrationRequest request, @MappingTarget User user);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@ public interface UserRepository extends JpaRepository<User, UUID> {
|
||||||
|
|
||||||
Optional<User> findByEmail(String email);
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
List<User> findAllByStatus(RegistrationStatus status);
|
List<User> findAllByStatusAndEmailVerified(RegistrationStatus status, Boolean verified);
|
||||||
|
|
||||||
|
Optional<User> findByVerificationToken(String token);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ public class AdminIamService {
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public List<User> getPendingUsers() {
|
public List<User> getPendingUsers() {
|
||||||
return userRepository.findAllByStatus(RegistrationStatus.PENDING);
|
return userRepository.findAllByStatusAndEmailVerified(RegistrationStatus.PENDING, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package at.mueller.eeg.backend.iam.service;
|
package at.mueller.eeg.backend.iam.service;
|
||||||
|
|
||||||
import at.mueller.eeg.backend.common.event.UserRegisteredEvent;
|
|
||||||
import at.mueller.eeg.backend.iam.domain.User;
|
import at.mueller.eeg.backend.iam.domain.User;
|
||||||
import at.mueller.eeg.backend.iam.mapper.UserMapper;
|
import at.mueller.eeg.backend.iam.mapper.UserMapper;
|
||||||
import at.mueller.eeg.backend.iam.repository.UserRepository;
|
import at.mueller.eeg.backend.iam.repository.UserRepository;
|
||||||
|
|
@ -20,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static at.mueller.eeg.backend.iam.api.dto.IamDtos.*;
|
import static at.mueller.eeg.backend.iam.api.dto.IamDtos.*;
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ public class AuthService {
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final MailService mailService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verarbeitet den Login und gibt den JWT-Token als String zurück.
|
* Verarbeitet den Login und gibt den JWT-Token als String zurück.
|
||||||
|
|
@ -62,9 +63,23 @@ public class AuthService {
|
||||||
});
|
});
|
||||||
|
|
||||||
User newUser = userMapper.mapToEntity(request, new User());
|
User newUser = userMapper.mapToEntity(request, new User());
|
||||||
|
String token = UUID.randomUUID().toString();
|
||||||
|
newUser.setVerificationToken(token);
|
||||||
|
|
||||||
userRepository.save(newUser);
|
userRepository.save(newUser);
|
||||||
|
|
||||||
eventPublisher.publishEvent(new UserRegisteredEvent(newUser.getId(), request.atNumber()));
|
mailService.sendVerificationEmail(newUser.getEmail(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void verifyEmail(String token) {
|
||||||
|
User user = userRepository.findByVerificationToken(token)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Ungültiger oder abgelaufener Token"));
|
||||||
|
|
||||||
|
user.setEmailVerified(true);
|
||||||
|
user.setVerificationToken(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package at.mueller.eeg.backend.iam.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Profile("dev")
|
||||||
|
@Slf4j
|
||||||
|
public class ConsoleMailService implements MailService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendVerificationEmail(String toEmail, String token) {
|
||||||
|
String verificationUrl = "http://localhost:4200/verify-email?token=" + token;
|
||||||
|
|
||||||
|
log.info("""
|
||||||
|
|
||||||
|
========================================================================
|
||||||
|
📧 SIMULIERTER E-MAIL-VERSAND (Profile: DEV)
|
||||||
|
========================================================================
|
||||||
|
An: {}
|
||||||
|
Betreff: Bitte bestätige deine E-Mail-Adresse für das EEG-Portal
|
||||||
|
|
||||||
|
Klicke auf den folgenden Link, um die Registrierung abzuschließen:
|
||||||
|
{}
|
||||||
|
========================================================================
|
||||||
|
""", toEmail, verificationUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package at.mueller.eeg.backend.iam.service;
|
||||||
|
|
||||||
|
public interface MailService {
|
||||||
|
void sendVerificationEmail(String toEmail, String token);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package at.mueller.eeg.backend.iam.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Profile("prod")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SmtpMailService implements MailService {
|
||||||
|
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendVerificationEmail(String toEmail, String token) {
|
||||||
|
String verificationUrl = "http://localhost:4200/verify-email?token=" + token;
|
||||||
|
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setTo(toEmail);
|
||||||
|
message.setSubject("Bitte bestätige deine E-Mail-Adresse für das EEG-Portal");
|
||||||
|
message.setText("Willkommen!\n\nBitte klicke auf den folgenden Link:\n" + verificationUrl);
|
||||||
|
|
||||||
|
mailSender.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<!-- Der Header bleibt immer fix oben stehen -->
|
@if (!isDashboardRoute) {
|
||||||
@if (!isAdminRoute) {
|
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
}
|
}
|
||||||
<!-- Hier drin werden je nach Route die Login-, Register- oder Dashboard-Seiten geladen -->
|
|
||||||
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
|
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,33 @@ import {LandingPage} from './pages/landing-page/landing-page';
|
||||||
import {Register} from './pages/register/register';
|
import {Register} from './pages/register/register';
|
||||||
import {LoginComponent} from './pages/login/login';
|
import {LoginComponent} from './pages/login/login';
|
||||||
import {AdminUserApprovalComponent} from './pages/admin-user-approval/admin-user-approval';
|
import {AdminUserApprovalComponent} from './pages/admin-user-approval/admin-user-approval';
|
||||||
import {adminAuthGuard} from './guards/admin-auth-guard';
|
import {authGuard} from './guards/auth-guard';
|
||||||
import {AppLayout} from './layout/app-layout/app-layout';
|
import {roleGuard} from './guards/role-guard';
|
||||||
|
import {DashboardLayout} from './layout/dashboard-layout/dashboard-layout';
|
||||||
|
import {DashboardOverview} from './pages/dashboard-overview/dashboard-overview';
|
||||||
import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community';
|
import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community';
|
||||||
|
import {VerifyEmailComponent} from './pages/verify-email/verify-email';
|
||||||
|
|
||||||
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: 'admin',
|
path: 'dashboard',
|
||||||
component: AppLayout,
|
component: DashboardLayout,
|
||||||
canActivate: [adminAuthGuard],
|
canActivate: [authGuard],
|
||||||
children: [
|
children: [
|
||||||
{
|
{ path: '', component: DashboardOverview },
|
||||||
path: '',
|
|
||||||
redirectTo: 'approvals',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'approvals',
|
path: 'approvals',
|
||||||
component: AdminUserApprovalComponent
|
component: AdminUserApprovalComponent,
|
||||||
|
canActivate: [roleGuard(['ADMIN'])]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'energy-communities',
|
path: 'energy-communities',
|
||||||
component: AdminEnergyCommunity
|
component: AdminEnergyCommunity,
|
||||||
|
canActivate: [roleGuard(['ADMIN'])]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@ import {HeaderComponent} from './layout/header/header';
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('eeg_frontend');
|
protected readonly title = signal('eeg_frontend');
|
||||||
|
|
||||||
isAdminRoute = false;
|
isDashboardRoute = false;
|
||||||
|
|
||||||
constructor(private router: Router) {
|
constructor(private router: Router) {
|
||||||
this.router.events.subscribe(event => {
|
this.router.events.subscribe(event => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
this.isAdminRoute = event.urlAfterRedirects.startsWith('/admin');
|
this.isDashboardRoute = event.urlAfterRedirects.startsWith('/dashboard');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { TestBed } from '@angular/core/testing';
|
import {TestBed} from '@angular/core/testing';
|
||||||
import { CanActivateFn } from '@angular/router';
|
import {CanActivateFn} from '@angular/router';
|
||||||
|
|
||||||
import { adminAuthGuard } from './admin-auth-guard';
|
import {authGuard} from './auth-guard';
|
||||||
|
|
||||||
describe('adminAuthGuard', () => {
|
describe('authGuard', () => {
|
||||||
const executeGuard: CanActivateFn = (...guardParameters) =>
|
const executeGuard: CanActivateFn = (...guardParameters) =>
|
||||||
TestBed.runInInjectionContext(() => adminAuthGuard(...guardParameters));
|
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import {inject} from '@angular/core';
|
|
||||||
import {CanActivateFn, Router} from '@angular/router';
|
|
||||||
import {AuthService} from '../services/auth';
|
|
||||||
|
|
||||||
export const adminAuthGuard: CanActivateFn = (route, state) => {
|
|
||||||
const authService = inject(AuthService);
|
|
||||||
const router = inject(Router);
|
|
||||||
|
|
||||||
// Replace these with your actual authentication and role-checking methods
|
|
||||||
const isAuthenticated = authService.isLoggedIn();
|
|
||||||
const isAdmin = authService.currentUserRole() === 'ADMIN';
|
|
||||||
if (isAuthenticated && isAdmin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Redirect to a generic 'unauthorized' page or back to the landing page/login
|
|
||||||
console.warn('Access denied: Admin privileges required.');
|
|
||||||
return router.createUrlTree(['/login']);
|
|
||||||
};
|
|
||||||
15
eeg_frontend/src/app/guards/auth-guard.ts
Normal file
15
eeg_frontend/src/app/guards/auth-guard.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import {inject} from '@angular/core';
|
||||||
|
import {CanActivateFn, Router} from '@angular/router';
|
||||||
|
import {AuthService} from '../services/auth';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (authService.isLoggedIn()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Zugriff verweigert: Login erforderlich.');
|
||||||
|
return router.createUrlTree(['/login']);
|
||||||
|
};
|
||||||
18
eeg_frontend/src/app/guards/role-guard.ts
Normal file
18
eeg_frontend/src/app/guards/role-guard.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import {inject} from '@angular/core';
|
||||||
|
import {CanActivateFn, Router} from '@angular/router';
|
||||||
|
import {AuthService} from '../services/auth';
|
||||||
|
|
||||||
|
export function roleGuard(allowedRoles: string[]): CanActivateFn {
|
||||||
|
return () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
const role = authService.currentUserRole();
|
||||||
|
if (role && allowedRoles.includes(role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Zugriff verweigert: benötigt eine der Rollen [${allowedRoles.join(', ')}].`);
|
||||||
|
return router.createUrlTree(['/dashboard']);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import {Component} from '@angular/core';
|
|
||||||
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-admin-layout',
|
|
||||||
standalone: true,
|
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
|
||||||
templateUrl: './app-layout.html',
|
|
||||||
styleUrl: './app-layout.scss'
|
|
||||||
})
|
|
||||||
export class AppLayout {}
|
|
||||||
|
|
@ -13,28 +13,30 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||||
<a routerLink="/admin/approvals" routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
@for (item of visibleNavItems(); track item.path) {
|
||||||
|
<a [routerLink]="item.path"
|
||||||
|
routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
||||||
|
[routerLinkActiveOptions]="{exact: item.path === '/dashboard'}"
|
||||||
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
||||||
Benutzerverwaltung
|
{{ item.label }}
|
||||||
</a>
|
|
||||||
<a routerLink="/admin/energy-communities" routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
|
||||||
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
|
||||||
Energiegemeinschaften
|
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-8 justify-between shrink-0">
|
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-8 justify-between shrink-0">
|
||||||
<h1 class="text-lg font-semibold text-slate-800">Verwaltung</h1>
|
<h1 class="text-lg font-semibold text-slate-800">
|
||||||
|
{{ authService.currentUserRole() === 'ADMIN' ? 'Verwaltung' : 'Mein Bereich' }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-200">
|
<div class="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 px-3 py-1.5 rounded-full border border-slate-200">
|
||||||
<span class="relative flex h-2.5 w-2.5">
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||||
</span>
|
</span>
|
||||||
Admin
|
{{ authService.currentUserRole() }}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
|
||||||
import {AppLayout} from './app-layout';
|
import {DashboardLayout} from './dashboard-layout';
|
||||||
|
|
||||||
describe('AdminLayout', () => {
|
describe('DashboardLayout', () => {
|
||||||
let component: AppLayout;
|
let component: DashboardLayout;
|
||||||
let fixture: ComponentFixture<AppLayout>;
|
let fixture: ComponentFixture<DashboardLayout>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppLayout],
|
imports: [DashboardLayout],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AppLayout);
|
fixture = TestBed.createComponent(DashboardLayout);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {Component, computed, inject} from '@angular/core';
|
||||||
|
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
||||||
|
import {AuthService} from '../../services/auth';
|
||||||
|
import {NAV_ITEMS} from './dashboard-nav-items';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard-layout',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
|
templateUrl: './dashboard-layout.html',
|
||||||
|
styleUrl: './dashboard-layout.scss'
|
||||||
|
})
|
||||||
|
export class DashboardLayout {
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
visibleNavItems = computed(() => {
|
||||||
|
const role = this.authService.currentUserRole();
|
||||||
|
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface NavItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
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: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
|
||||||
|
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
@if (isLoading()) {
|
||||||
|
<div class="text-slate-500">Lade Daten...</div>
|
||||||
|
} @else if (role === 'ADMIN' && adminStats(); as stats) {
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Energiegemeinschaften</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.totalCommunities }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Offene Freigaben</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.pendingUsers }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Aktive Mitgliedschaften</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.totalActiveMemberships }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (userStats(); as stats) {
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Meine Zählpunkte</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.totalMeteringPoints }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Aktive Mitgliedschaften</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.activeMemberships }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<p class="text-sm text-slate-500">Offene Anfragen</p>
|
||||||
|
<p class="text-3xl font-bold text-slate-800 mt-2">{{ stats.pendingMemberships }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DashboardOverview } from './dashboard-overview';
|
||||||
|
|
||||||
|
describe('DashboardOverview', () => {
|
||||||
|
let component: DashboardOverview;
|
||||||
|
let fixture: ComponentFixture<DashboardOverview>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DashboardOverview],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DashboardOverview);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import {Component, inject, OnInit, signal} from '@angular/core';
|
||||||
|
import {AuthService} from '../../services/auth';
|
||||||
|
import {AdminStats, DashboardService, UserStats} from '../../services/dashboard';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard-overview',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './dashboard-overview.html'
|
||||||
|
})
|
||||||
|
export class DashboardOverview implements OnInit {
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private dashboardService = inject(DashboardService);
|
||||||
|
|
||||||
|
role = this.authService.currentUserRole();
|
||||||
|
adminStats = signal<AdminStats | null>(null);
|
||||||
|
userStats = signal<UserStats | null>(null);
|
||||||
|
isLoading = signal(true);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.role === 'ADMIN') {
|
||||||
|
this.dashboardService.getAdminStats().subscribe({
|
||||||
|
next: stats => { this.adminStats.set(stats); this.isLoading.set(false); },
|
||||||
|
error: () => this.isLoading.set(false)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.dashboardService.getUserStats().subscribe({
|
||||||
|
next: stats => { this.userStats.set(stats); this.isLoading.set(false); },
|
||||||
|
error: () => this.isLoading.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,17 +40,10 @@ export class LoginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private redirectBasedOnRole(role: string) {
|
private redirectBasedOnRole(role: string) {
|
||||||
switch (role) {
|
if (role === 'ADMIN' || role === 'MEMBER') {
|
||||||
case 'ADMIN':
|
|
||||||
void this.router.navigate(['/admin/approvals']);
|
|
||||||
break;
|
|
||||||
case 'MEMBER':
|
|
||||||
void this.router.navigate(['/dashboard']);
|
void this.router.navigate(['/dashboard']);
|
||||||
break;
|
} else {
|
||||||
default:
|
|
||||||
// Fallback, falls die Rolle unbekannt ist
|
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
eeg_frontend/src/app/pages/verify-email/verify-email.html
Normal file
34
eeg_frontend/src/app/pages/verify-email/verify-email.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div class="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
||||||
|
|
||||||
|
@if (isLoading) {
|
||||||
|
<div class="text-slate-600">
|
||||||
|
<p>Deine E-Mail wird verifiziert...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isLoading && isSuccess) {
|
||||||
|
<div class="text-emerald-600">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Verifizierung erfolgreich!</h2>
|
||||||
|
<p class="mb-6">Deine E-Mail-Adresse wurde bestätigt. Der Administrator prüft nun deinen Zählpunkt.</p>
|
||||||
|
<a routerLink="/login" class="bg-emerald-600 text-white px-6 py-2 rounded-md hover:bg-emerald-700 transition-colors">
|
||||||
|
Zum Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isLoading && !isSuccess) {
|
||||||
|
<div class="text-red-600">
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Fehler</h2>
|
||||||
|
<p class="mb-6">Der Link ist ungültig oder abgelaufen.</p>
|
||||||
|
<a routerLink="/register" class="text-emerald-600 hover:text-emerald-700 font-medium hover:underline">
|
||||||
|
Zurück zur Registrierung
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
22
eeg_frontend/src/app/pages/verify-email/verify-email.spec.ts
Normal file
22
eeg_frontend/src/app/pages/verify-email/verify-email.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { VerifyEmail } from './verify-email';
|
||||||
|
|
||||||
|
describe('VerifyEmail', () => {
|
||||||
|
let component: VerifyEmail;
|
||||||
|
let fixture: ComponentFixture<VerifyEmail>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [VerifyEmail],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(VerifyEmail);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
eeg_frontend/src/app/pages/verify-email/verify-email.ts
Normal file
44
eeg_frontend/src/app/pages/verify-email/verify-email.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import {Component, inject, OnInit} from '@angular/core';
|
||||||
|
import {ActivatedRoute, RouterLink} from '@angular/router';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-verify-email',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink], // CommonModule wird für die neue Control Flow Syntax nicht mehr benötigt!
|
||||||
|
templateUrl: './verify-email.html',
|
||||||
|
styleUrl: './verify-email.scss'
|
||||||
|
})
|
||||||
|
export class VerifyEmailComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
// Tipp: Hier später idealerweise deinen OpenAPI-generierten Service verwenden,
|
||||||
|
// z.B. private authService = inject(AuthenticationService);
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
isSuccess = false;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const token = this.route.snapshot.queryParamMap.get('token');
|
||||||
|
// Ggf. atNumber auslesen, falls du sie hier mit übergibst
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Wenn du OpenAPI nutzt, kannst du hier die entsprechende Methode aufrufen.
|
||||||
|
// Fürs Beispiel als direkter HTTP-Call:
|
||||||
|
this.http.get(`http://localhost:8080/api/auth/verify-email?token=${token}`).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.isSuccess = true;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.isSuccess = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.isSuccess = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { TestBed } from '@angular/core/testing';
|
import {TestBed} from '@angular/core/testing';
|
||||||
|
|
||||||
import { Auth } from './auth';
|
import {DashboardService} from './dashboard';
|
||||||
|
|
||||||
describe('Auth', () => {
|
describe('Auth', () => {
|
||||||
let service: Auth;
|
let service: DashboardService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({});
|
TestBed.configureTestingModule({});
|
||||||
service = TestBed.inject(Auth);
|
service = TestBed.inject(DashboardService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
|
|
|
||||||
29
eeg_frontend/src/app/services/dashboard.ts
Normal file
29
eeg_frontend/src/app/services/dashboard.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {inject, Injectable} from '@angular/core';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {Observable} from 'rxjs';
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
totalCommunities: number;
|
||||||
|
pendingUsers: number;
|
||||||
|
totalActiveMemberships: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
totalMeteringPoints: number;
|
||||||
|
activeMemberships: number;
|
||||||
|
pendingMemberships: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({providedIn: 'root'})
|
||||||
|
export class DashboardService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private apiUrl = 'http://localhost:8080/api/dashboard';
|
||||||
|
|
||||||
|
getAdminStats(): Observable<AdminStats> {
|
||||||
|
return this.http.get<AdminStats>(`${this.apiUrl}/admin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserStats(): Observable<UserStats> {
|
||||||
|
return this.http.get<UserStats>(`${this.apiUrl}/user`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue