simplified user registration process

This commit is contained in:
Bernhard Müller 2026-07-05 14:33:29 +02:00
parent 59a0b6b9b7
commit 2e9790b3b2
15 changed files with 251 additions and 176 deletions

View File

@ -22,12 +22,9 @@ public class AuthController {
@PostMapping("/register")
@Operation(summary = "Registriert einen neuen User inkl. Zählpunkt",
description = "Setzt den User auf Status PENDING und triggert den asynchronen EDA-Check.")
description = "Setzt den User auf Status PENDING und versendet eine Verifizierungs-E-Mail.")
public ResponseEntity<Void> register(@Valid @RequestBody RegistrationRequest request) {
authService.register(request);
// Logik: MeteringPoint anlegen
// Event feuern: EdaTopologyCheckEvent
return ResponseEntity.status(HttpStatus.CREATED).build();
}

View File

@ -1,9 +1,10 @@
package at.mueller.eeg.backend.iam.api.dto;
import at.mueller.eeg.backend.iam.domain.ParticipantType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
@ -12,15 +13,19 @@ public interface IamDtos {
@Schema(description = "Payload für die Registrierung eines neuen Users")
record RegistrationRequest(
@NotBlank @Schema(example = "Max") String firstName,
@NotBlank @Schema(example = "Mustermann") String lastName,
@Email @NotBlank @Schema(example = "max@example.com") String email,
@NotBlank @Size(min = 8) @Schema(example = "SecurePass123!") String password,
@NotNull
@Schema(description = "Privatperson oder juristische Person (Firma/Verein/Gemeinde)", example = "PRIVATE")
ParticipantType participantType,
@NotBlank
@Pattern(regexp = "^AT[0-9]{31}$", message = "Muss eine gültige österreichische 33-stellige Zählpunktnummer sein")
@Schema(example = "AT0010000000000000000000001234567")
String atNumber
@Schema(description = "Vorname (bei juristischer Person: Vorname der Ansprechperson)", example = "Max")
String firstName,
@NotBlank
@Schema(description = "Nachname (bei juristischer Person: Nachname der Ansprechperson)", example = "Mustermann")
String lastName,
@Schema(description = "Nur bei juristischer Person: Firmen-, Vereins- oder Gemeindename", example = "Musterverein")
String organizationName,
@Email @NotBlank @Schema(example = "max@example.com") String email,
@NotBlank @Size(min = 8) @Schema(example = "SecurePass123!") String password
) {}
@Schema(description = "Payload für den Login")
@ -39,13 +44,13 @@ public interface IamDtos {
@Schema(description = "Repräsentation eines Users für das Admin-Dashboard")
record UserProfileResponse(
UUID id,
ParticipantType participantType,
String firstName,
String lastName,
String organizationName,
String email,
String status,
String primaryAtNumber
String status
) {}
record ErrorResponse(String message, int status) {}
}

View File

@ -0,0 +1,6 @@
package at.mueller.eeg.backend.iam.domain;
public enum ParticipantType {
PRIVATE,
COMPANY
}

View File

@ -29,12 +29,16 @@ public class User implements UserDetails {
@Column(nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ParticipantType participantType = ParticipantType.PRIVATE;
private String organizationName;
private String firstName;
private String lastName;
private String iban;
@Column(nullable = false)
private boolean emailVerified = false;

View File

@ -15,14 +15,12 @@ public interface UserMapper {
List<IamDtos.UserProfileResponse> mapToDto(List<User> users);
@Mapping(target = "primaryAtNumber", ignore = true)
IamDtos.UserProfileResponse mapToDto(User user);
@Mapping(target = "status", constant = "PENDING")
@Mapping(target = "role", constant = "MEMBER")
@Mapping(target = "passwordHash", source = "password", qualifiedBy = UserMapperAnnotations.PasswordHash.class)
@Mapping(target = "id", ignore = true)
@Mapping(target = "iban", ignore = true)
@Mapping(target = "enabled", constant = "false")
@Mapping(target = "authorities", ignore = true)
@Mapping(target = "emailVerified", ignore = true)

View File

@ -1,5 +1,6 @@
package at.mueller.eeg.backend.iam.service;
import at.mueller.eeg.backend.iam.domain.ParticipantType;
import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.mapper.UserMapper;
import at.mueller.eeg.backend.iam.repository.UserRepository;
@ -62,6 +63,12 @@ public class AuthService {
throw new RuntimeException("E-Mail already exists!");
});
if (request.participantType() == ParticipantType.COMPANY
&& (request.organizationName() == null || request.organizationName().isBlank())) {
throw new IllegalArgumentException(
"Für juristische Personen ist ein Firmen-/Vereins-/Gemeindename erforderlich.");
}
User newUser = userMapper.mapToEntity(request, new User());
String token = UUID.randomUUID().toString();
newUser.setVerificationToken(token);

View File

@ -1,4 +1,4 @@
package at.mueller.eeg.backend.backend;
package at.mueller.eeg.backend;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

View File

@ -0,0 +1,11 @@
package at.mueller.eeg.backend.iam.service;
import org.springframework.stereotype.Service;
@Service
public class FakeMailService implements MailService{
@Override
public void sendVerificationEmail(String toEmail, String token) {
}
}

View File

@ -122,8 +122,7 @@ paths:
tags:
- Authentication
summary: Registriert einen neuen User inkl. Zählpunkt
description: Setzt den User auf Status PENDING und triggert den asynchronen
EDA-Check.
description: Setzt den User auf Status PENDING und versendet eine Verifizierungs-E-Mail.
operationId: register
requestBody:
content:
@ -220,6 +219,30 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/EnergyCommunityDto"
/api/dashboard/user:
get:
tags:
- dashboard-controller
operationId: getUserDashboard
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/UserStats"
/api/dashboard/admin:
get:
tags:
- dashboard-controller
operationId: getAdminDashboard
responses:
"200":
description: OK
content:
'*/*':
schema:
$ref: "#/components/schemas/AdminStats"
/api/community/metering-points/{id}/eligible-communities:
get:
tags:
@ -241,6 +264,20 @@ paths:
type: array
items:
$ref: "#/components/schemas/EligibleCommunityResponse"
/api/auth/verify-email:
get:
tags:
- Authentication
operationId: verifyEmail
parameters:
- name: token
in: query
required: true
schema:
type: string
responses:
"200":
description: OK
/api/admin/users/pending:
get:
tags:
@ -306,14 +343,27 @@ components:
type: object
description: Payload für die Registrierung eines neuen Users
properties:
participantType:
type: string
description: Privatperson oder juristische Person (Firma/Verein/Gemeinde)
enum:
- PRIVATE
- COMPANY
example: PRIVATE
firstName:
type: string
description: "Vorname (bei juristischer Person: Vorname der Ansprechperson)"
example: Max
minLength: 1
lastName:
type: string
description: "Nachname (bei juristischer Person: Nachname der Ansprechperson)"
example: Mustermann
minLength: 1
organizationName:
type: string
description: "Nur bei juristischer Person: Firmen-, Vereins- oder Gemeindename"
example: Musterverein
email:
type: string
format: email
@ -324,16 +374,11 @@ components:
example: SecurePass123!
maxLength: 2147483647
minLength: 8
atNumber:
type: string
example: AT0010000000000000000000001234567
minLength: 1
pattern: "^AT[0-9]{31}$"
required:
- atNumber
- email
- firstName
- lastName
- participantType
- password
LoginRequest:
type: object
@ -366,6 +411,30 @@ components:
type: string
description: Rolle des Users
example: USER
UserStats:
type: object
properties:
totalMeteringPoints:
type: integer
format: int64
activeMemberships:
type: integer
format: int64
pendingMemberships:
type: integer
format: int64
AdminStats:
type: object
properties:
totalCommunities:
type: integer
format: int64
pendingUsers:
type: integer
format: int64
totalActiveMemberships:
type: integer
format: int64
EligibleCommunityResponse:
type: object
properties:
@ -389,13 +458,18 @@ components:
id:
type: string
format: uuid
participantType:
type: string
enum:
- PRIVATE
- COMPANY
firstName:
type: string
lastName:
type: string
organizationName:
type: string
email:
type: string
status:
type: string
primaryAtNumber:
type: string

View File

@ -31,12 +31,14 @@
{{ 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">
<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="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-500">
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
</span>
{{ authService.currentUserRole() }}
<button (click)="onLogout()"
class="text-sm font-medium text-red-600 hover:text-red-700 border border-red-200 hover:border-red-300 px-3 py-1.5 rounded-md transition duration-150">
Abmelden
</button>
</div>
</header>

View File

@ -1,5 +1,5 @@
import {Component, computed, inject} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {AuthService} from '../../services/auth';
import {NAV_ITEMS} from './dashboard-nav-items';
@ -12,9 +12,15 @@ import {NAV_ITEMS} from './dashboard-nav-items';
})
export class DashboardLayout {
authService = inject(AuthService);
private router = inject(Router);
visibleNavItems = computed(() => {
const role = this.authService.currentUserRole();
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
});
onLogout() {
this.authService.logout();
this.router.navigate(['/']);
}
}

View File

@ -22,6 +22,6 @@ export class HeaderComponent {
onLogout() {
this.authService.logout();
this.router.navigate(['/login']);
this.router.navigate(['/']);
}
}

View File

@ -43,12 +43,6 @@
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<div class="text-gray-900 font-mono text-sm">
{{ user.primaryAtNumber || 'Kein Zählpunkt hinterlegt' }}
</div>
</td>
<td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ user.status }}

View File

@ -7,19 +7,38 @@
<h2 class="mt-2 text-xl font-semibold text-slate-600">Erstelle dein Mitgliedskonto</h2>
</div>
<div class="sm:mx-auto sm:w-full sm:max-w-3xl bg-white shadow-md rounded-2xl border border-slate-100 overflow-hidden">
<div class="sm:mx-auto sm:w-full sm:max-w-md bg-white shadow-md rounded-2xl border border-slate-100 overflow-hidden">
<mat-stepper linear #stepper class="!bg-transparent">
@if (!registeredSuccessfully) {
<form [formGroup]="accountForm" (ngSubmit)="onSubmit()" class="p-6 flex flex-col gap-4">
<p class="text-sm text-slate-500 mb-2">
Für die Registrierung reichen deine Kontaktdaten. Zählpunktnummer und IBAN kannst du nach der
Freischaltung deines Kontos in deinem Mitgliederbereich hinterlegen.
</p>
<mat-step [stepControl]="accountForm">
<ng-template matStepLabel>Konto</ng-template>
<form [formGroup]="accountForm" class="p-6 flex flex-col gap-4">
<p class="text-sm text-slate-500 mb-2">Trage deine persönlichen Daten für den Portalzugang ein.</p>
<mat-button-toggle-group formControlName="participantType" class="w-full mb-2">
<mat-button-toggle value="PRIVATE" class="flex-1">Privatperson</mat-button-toggle>
<mat-button-toggle value="COMPANY" class="flex-1">Firma / Verein / Gemeinde</mat-button-toggle>
</mat-button-toggle-group>
@if (isCompany) {
<mat-form-field appearance="outline" class="w-full">
<mat-label>Firmen-, Vereins- oder Gemeindename</mat-label>
<input matInput formControlName="organizationName" placeholder="Musterverein">
</mat-form-field>
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>{{ isCompany ? 'Vorname (Ansprechperson)' : 'Vorname' }}</mat-label>
<input matInput formControlName="firstName" placeholder="Max">
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Vollständiger Name / Vereinsname</mat-label>
<input matInput formControlName="name" placeholder="Max Mustermann">
<mat-label>{{ isCompany ? 'Nachname (Ansprechperson)' : 'Nachname' }}</mat-label>
<input matInput formControlName="lastName" placeholder="Mustermann">
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>E-Mail-Adresse</mat-label>
@ -32,79 +51,21 @@
</mat-form-field>
<div class="flex justify-end mt-4">
<button mat-flat-button color="primary" matStepperNext type="button">Weiter</button>
</div>
</form>
</mat-step>
<mat-step [stepControl]="energyForm">
<ng-template matStepLabel>Anlagendaten</ng-template>
<form [formGroup]="energyForm" class="p-6 flex flex-col gap-4">
<p class="text-sm text-slate-500 mb-2">Diese Daten benötigen wir für die Zuordnung beim Netzbetreiber.</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Zählpunktnummer (33-stellig)</mat-label>
<input matInput formControlName="accountingPointId" placeholder="AT0010000000000000000000000000000" maxlength="35">
<mat-hint>Beginnt mit AT und findest du auf deiner Stromrechnung.</mat-hint>
</mat-form-field>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<mat-form-field appearance="outline" class="md:col-span-2">
<mat-label>Straße & Hausnummer</mat-label>
<input matInput formControlName="street">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PLZ</mat-label>
<input matInput formControlName="zip">
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Ort</mat-label>
<input matInput formControlName="city">
</mat-form-field>
<div class="flex justify-between mt-4">
<button mat-button matStepperPrevious type="button">Zurück</button>
<button mat-flat-button color="primary" matStepperNext type="button">Weiter</button>
</div>
</form>
</mat-step>
<mat-step [stepControl]="paymentForm">
<ng-template matStepLabel>Abrechnung</ng-template>
<form [formGroup]="paymentForm" (ngSubmit)="onSubmit()" class="p-6 flex flex-col gap-4">
<p class="text-sm text-slate-500 mb-2">Für den Einzug der Netzkosten-Ersparnisse/Beiträge sowie Gutschriften für Einspeisung.</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>IBAN</mat-label>
<input matInput formControlName="iban" placeholder="AT.. .... .... .... ....">
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>BIC (Optional)</mat-label>
<input matInput formControlName="bic">
</mat-form-field>
<div class="bg-slate-50 p-4 rounded-xl border border-slate-200 text-xs text-slate-600 my-2 space-y-2">
<p class="font-bold">SEPA-Lastschriftmandat (Creditor ID: ATXXXXXXXXXXXXXXXX)</p>
<p>Ich ermächtige die Energiegemeinschaft, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die eingelösten Lastschriften zu bezahlen.</p>
<p class="italic text-slate-400">Hinweis: Ich kann innerhalb von 8 Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen.</p>
</div>
<mat-checkbox formControlName="acceptMandate" color="primary" class="text-sm">
Ich erteile das SEPA-Lastschriftmandat elektronisch.
</mat-checkbox>
<div class="flex justify-between mt-4">
<button mat-button matStepperPrevious type="button">Zurück</button>
<button mat-flat-button color="accent" type="submit" [disabled]="accountForm.invalid || energyForm.invalid || paymentForm.invalid">
<button mat-flat-button color="accent" type="submit" [disabled]="accountForm.invalid">
Registrierung abschließen
</button>
</div>
</form>
</mat-step>
}
</mat-stepper>
@if (registeredSuccessfully) {
<div class="p-8 text-center">
<h3 class="text-lg font-semibold text-slate-800 mb-2">Fast geschafft!</h3>
<p class="text-sm text-slate-500">
Wir haben dir eine Bestätigungs-E-Mail geschickt. Bitte verifiziere deine E-Mail-Adresse.
Anschließend prüft ein Administrator dein Konto und schaltet es frei.
</p>
</div>
}
</div>
</div>

View File

@ -1,25 +1,25 @@
import {Component, inject} from '@angular/core';
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatStepperModule} from '@angular/material/stepper';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {Router} from '@angular/router';
import {AuthenticationService, RegistrationRequest} from '../../api';
import {MatButtonToggleModule} from '@angular/material/button-toggle';
import {AuthenticationService, RegistrationRequest, UserProfileResponse} from '../../api';
import {RouterLink} from '@angular/router';
import ParticipantTypeEnum = UserProfileResponse.ParticipantTypeEnum;
@Component({
selector: 'app-register',
standalone: true,
imports: [
ReactiveFormsModule,
MatStepperModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule,
MatCheckboxModule
MatButtonToggleModule,
RouterLink
],
templateUrl: './register.html',
styleUrls: ['./register.scss']
@ -27,52 +27,62 @@ import {AuthenticationService, RegistrationRequest} from '../../api';
export class Register {
private fb = inject(FormBuilder);
private authService = inject(AuthenticationService);
private router = inject(Router);
// Schritt 1: Login & Stammdaten
// Für die Toggle-Buttons im Template
readonly ParticipantType = RegistrationRequest.ParticipantTypeEnum;
registeredSuccessfully = false;
accountForm = this.fb.group({
name: ['', Validators.required],
participantType: this.fb.control<ParticipantTypeEnum>(
RegistrationRequest.ParticipantTypeEnum.Private,
{nonNullable: true, validators: Validators.required}
),
organizationName: [''],
firstName: ['', Validators.required],
lastName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
// Schritt 2: Energiegemeinschaft-Spezifische Daten
energyForm = this.fb.group({
accountingPointId: ['', [Validators.required, Validators.pattern(/^AT\d{31}$/)]], // AT + 33 Ziffern für Zählpunkt
street: ['', Validators.required],
zip: ['', Validators.required],
city: ['', Validators.required]
constructor() {
this.accountForm.get('participantType')!.valueChanges.subscribe(type => {
const orgControl = this.accountForm.get('organizationName')!;
if (type === RegistrationRequest.ParticipantTypeEnum.Company) {
orgControl.setValidators([Validators.required]);
} else {
orgControl.clearValidators();
}
orgControl.updateValueAndValidity();
});
}
// Schritt 3: SEPA Lastschrift / Mandat
paymentForm = this.fb.group({
iban: ['', [Validators.required, Validators.pattern(/^[A-Z]{2}[0-9]{2}[A-Z0-9]{12,30}$/)]],
bic: [''],
acceptMandate: [false, Validators.requiredTrue]
});
get isCompany(): boolean {
return this.accountForm.value.participantType === RegistrationRequest.ParticipantTypeEnum.Company;
}
onSubmit() {
if (this.accountForm.valid && this.energyForm.valid && this.paymentForm.valid) {
if (this.accountForm.invalid) {
this.accountForm.markAllAsTouched();
return;
}
const registrationData: RegistrationRequest = {
firstName: this.accountForm.value.name!,
lastName: this.accountForm.value.name!,
participantType: this.accountForm.value.participantType!,
firstName: this.accountForm.value.firstName!,
lastName: this.accountForm.value.lastName!,
organizationName: this.accountForm.value.organizationName || undefined,
email: this.accountForm.value.email!,
password: this.accountForm.value.password!,
atNumber: this.energyForm.value.accountingPointId!
password: this.accountForm.value.password!
};
console.log('Registrierungsdaten für Backend:', registrationData);
this.authService.register(registrationData).subscribe({
next: (response) => {
console.log('Erfolgreich registriert!', response);
// Bei Erfolg z.B. auf eine Bestätigungsseite oder zum Login leiten
// this.router.navigate(['/login']);
next: () => {
this.registeredSuccessfully = true;
},
error: (err) => {
console.error('Fehler bei der Registrierung', err);
// Hier könntest du später einen Angular Material SnackBar einblenden
}
});
}
}
}