diff --git a/packages/core-service/src/core/auth/auth.controller.ts b/packages/core-service/src/core/auth/auth.controller.ts index bcb4d35..019bd4f 100644 --- a/packages/core-service/src/core/auth/auth.controller.ts +++ b/packages/core-service/src/core/auth/auth.controller.ts @@ -13,6 +13,8 @@ import { Public } from '../../common/decorators/public.decorator'; import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; +import { Enable2faDto } from './dto/enable-2fa.dto'; +import { Disable2faDto } from './dto/disable-2fa.dto'; @ApiTags('Authentifizierung') @Controller('auth') @@ -107,6 +109,50 @@ export class AuthController { return { message: 'Erfolgreich abgemeldet' }; } + /** + * POST /api/v1/auth/2fa/setup + * 2FA-Setup starten: Secret + QR-Code generieren. + */ + @Post('2fa/setup') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: '2FA-Setup starten (QR-Code generieren)' }) + async setup2fa(@CurrentUser('sub') userId: string) { + return this.authService.setup2fa(userId); + } + + /** + * POST /api/v1/auth/2fa/enable + * 2FA aktivieren: TOTP-Code verifizieren. + */ + @Post('2fa/enable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: '2FA aktivieren (Code verifizieren)' }) + async enable2fa( + @CurrentUser('sub') userId: string, + @Body() dto: Enable2faDto, + ) { + await this.authService.enable2fa(userId, dto.totpCode); + return { message: '2FA wurde erfolgreich aktiviert' }; + } + + /** + * POST /api/v1/auth/2fa/disable + * 2FA deaktivieren (mit Passwort-Bestaetigung). + */ + @Post('2fa/disable') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('access-token') + @ApiOperation({ summary: '2FA deaktivieren (Passwort erforderlich)' }) + async disable2fa( + @CurrentUser('sub') userId: string, + @Body() dto: Disable2faDto, + ) { + await this.authService.disable2fa(userId, dto.password); + return { message: '2FA wurde erfolgreich deaktiviert' }; + } + /** * Setzt das Refresh-Token als HttpOnly Cookie. * Secure + SameSite=Strict nur in Produktion (HTTPS). diff --git a/packages/core-service/src/core/auth/auth.service.ts b/packages/core-service/src/core/auth/auth.service.ts index 82bd4c1..b0cdc51 100644 --- a/packages/core-service/src/core/auth/auth.service.ts +++ b/packages/core-service/src/core/auth/auth.service.ts @@ -226,6 +226,114 @@ export class AuthService { this.logger.log(`Logout: User ${accessToken.sub}`); } + /** + * 2FA-Setup: Neues TOTP-Secret generieren und QR-Code zurueckgeben. + * Secret wird temporaer in Redis gespeichert (5 Minuten TTL). + * Erst nach Verifizierung wird das Secret permanent in der DB gespeichert. + */ + async setup2fa(userId: string): Promise<{ qrCode: string; secret: string }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('Benutzer nicht gefunden'); + } + + if (user.twoFactorEnabled) { + throw new ForbiddenException('2FA ist bereits aktiviert'); + } + + const secret = this.totp.generateSecret(); + const qrCode = await this.totp.generateQrCode(user.email, secret); + + // Secret temporaer in Redis speichern (5 Minuten zum Einrichten) + await this.redis.set(`2fa_setup:${userId}`, secret, 300); + + this.logger.log(`2FA-Setup gestartet fuer User ${user.email}`); + + return { qrCode, secret }; + } + + /** + * 2FA aktivieren: TOTP-Code verifizieren und Secret permanent speichern. + */ + async enable2fa(userId: string, totpCode: string): Promise { + // Temporaeres Secret aus Redis holen + const secret = await this.redis.get(`2fa_setup:${userId}`); + if (!secret) { + throw new ForbiddenException( + '2FA-Setup abgelaufen. Bitte erneut starten.', + ); + } + + // TOTP-Code pruefen + const isValid = this.totp.verify(totpCode, secret); + if (!isValid) { + throw new UnauthorizedException('Ungueltiger 2FA-Code'); + } + + // Secret permanent in AuthProvider speichern + 2FA aktivieren + await this.prisma.$transaction([ + this.prisma.authProvider.updateMany({ + where: { userId, provider: 'LOCAL' }, + data: { totpSecret: secret }, + }), + this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: true }, + }), + ]); + + // Temporaeres Secret aus Redis loeschen + await this.redis.del(`2fa_setup:${userId}`); + + this.logger.log(`2FA aktiviert fuer User ${userId}`); + } + + /** + * 2FA deaktivieren: Passwort-Verifikation erforderlich. + */ + async disable2fa(userId: string, password: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { authProvider: true }, + }); + + if (!user) { + throw new UnauthorizedException('Benutzer nicht gefunden'); + } + + if (!user.twoFactorEnabled) { + throw new ForbiddenException('2FA ist nicht aktiviert'); + } + + // Passwort pruefen + const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL'); + if (!localAuth?.passwordHash) { + throw new UnauthorizedException('Kein lokaler Auth-Provider gefunden'); + } + + const passwordValid = await bcrypt.compare(password, localAuth.passwordHash); + if (!passwordValid) { + throw new UnauthorizedException('Ungueltiges Passwort'); + } + + // 2FA deaktivieren + Secret loeschen + await this.prisma.$transaction([ + this.prisma.authProvider.updateMany({ + where: { userId, provider: 'LOCAL' }, + data: { totpSecret: null }, + }), + this.prisma.user.update({ + where: { id: userId }, + data: { twoFactorEnabled: false }, + }), + ]); + + this.logger.log(`2FA deaktiviert fuer User ${userId}`); + } + /** * Token-Paar generieren (Access + Refresh). */ diff --git a/packages/core-service/src/core/auth/dto/disable-2fa.dto.ts b/packages/core-service/src/core/auth/dto/disable-2fa.dto.ts new file mode 100644 index 0000000..9cc1ace --- /dev/null +++ b/packages/core-service/src/core/auth/dto/disable-2fa.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Disable2faDto { + @ApiProperty({ + example: 'SicheresPasswort123!', + description: 'Aktuelles Passwort zur Bestaetigung', + }) + @IsString() + @IsNotEmpty({ message: 'Passwort darf nicht leer sein' }) + @MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' }) + password!: string; +} diff --git a/packages/core-service/src/core/auth/dto/enable-2fa.dto.ts b/packages/core-service/src/core/auth/dto/enable-2fa.dto.ts new file mode 100644 index 0000000..b99021c --- /dev/null +++ b/packages/core-service/src/core/auth/dto/enable-2fa.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Enable2faDto { + @ApiProperty({ + example: '123456', + description: 'TOTP-Code aus der Authenticator-App', + }) + @IsString() + @IsNotEmpty({ message: '2FA-Code darf nicht leer sein' }) + @Length(6, 6, { message: '2FA-Code muss genau 6 Zeichen lang sein' }) + totpCode!: string; +} diff --git a/packages/core-service/src/core/users/dto/change-password.dto.ts b/packages/core-service/src/core/users/dto/change-password.dto.ts new file mode 100644 index 0000000..1254d76 --- /dev/null +++ b/packages/core-service/src/core/users/dto/change-password.dto.ts @@ -0,0 +1,25 @@ +import { IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangePasswordDto { + @ApiProperty({ + example: 'AltesPasswort123!', + description: 'Aktuelles Passwort', + }) + @IsString() + @IsNotEmpty({ message: 'Aktuelles Passwort darf nicht leer sein' }) + @MinLength(8, { message: 'Passwort muss mindestens 8 Zeichen lang sein' }) + currentPassword!: string; + + @ApiProperty({ + example: 'NeuesSicheresPasswort456!', + description: 'Neues Passwort (mindestens 8 Zeichen)', + }) + @IsString() + @IsNotEmpty({ message: 'Neues Passwort darf nicht leer sein' }) + @MinLength(8, { + message: 'Neues Passwort muss mindestens 8 Zeichen lang sein', + }) + @MaxLength(128, { message: 'Passwort darf maximal 128 Zeichen lang sein' }) + newPassword!: string; +} diff --git a/packages/core-service/src/core/users/users.controller.ts b/packages/core-service/src/core/users/users.controller.ts index 1552383..d8b5990 100644 --- a/packages/core-service/src/core/users/users.controller.ts +++ b/packages/core-service/src/core/users/users.controller.ts @@ -8,11 +8,14 @@ import { Query, UseGuards, ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; import { Roles } from '../../common/decorators/roles.decorator'; import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator'; import { RolesGuard } from '../../common/guards/roles.guard'; @@ -33,6 +36,38 @@ export class UsersController { return this.usersService.findById(userId); } + /** + * PATCH /api/v1/users/me + * Eigenes Profil aktualisieren (firstName, lastName). + */ + @Patch('me') + @ApiOperation({ summary: 'Eigenes Profil aktualisieren' }) + async updateProfile( + @CurrentUser('sub') userId: string, + @Body() dto: UpdateUserDto, + ) { + return this.usersService.updateProfile(userId, dto); + } + + /** + * POST /api/v1/users/me/change-password + * Eigenes Passwort aendern. + */ + @Post('me/change-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Eigenes Passwort aendern' }) + async changePassword( + @CurrentUser('sub') userId: string, + @Body() dto: ChangePasswordDto, + ) { + await this.usersService.changePassword( + userId, + dto.currentPassword, + dto.newPassword, + ); + return { message: 'Passwort erfolgreich geaendert' }; + } + /** * GET /api/v1/users * Alle User auflisten (nur PLATFORM_ADMIN). diff --git a/packages/core-service/src/core/users/users.service.ts b/packages/core-service/src/core/users/users.service.ts index 920ec3e..9b00363 100644 --- a/packages/core-service/src/core/users/users.service.ts +++ b/packages/core-service/src/core/users/users.service.ts @@ -2,6 +2,7 @@ import { Injectable, ConflictException, NotFoundException, + UnauthorizedException, Logger, } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; @@ -136,6 +137,78 @@ export class UsersService { }; } + /** + * Eigenes Profil aktualisieren (nur firstName, lastName). + */ + async updateProfile(userId: string, dto: UpdateUserDto) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('Benutzer nicht gefunden'); + } + + const updated = await this.prisma.user.update({ + where: { id: userId }, + data: { + ...(dto.firstName !== undefined && { firstName: dto.firstName }), + ...(dto.lastName !== undefined && { lastName: dto.lastName }), + }, + }); + + return { + id: updated.id, + email: updated.email, + firstName: updated.firstName, + lastName: updated.lastName, + role: updated.role, + isActive: updated.isActive, + twoFactorEnabled: updated.twoFactorEnabled, + }; + } + + /** + * Eigenes Passwort aendern (mit Verifikation des aktuellen Passworts). + */ + async changePassword( + userId: string, + currentPassword: string, + newPassword: string, + ): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { authProvider: true }, + }); + + if (!user) { + throw new NotFoundException('Benutzer nicht gefunden'); + } + + const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL'); + if (!localAuth?.passwordHash) { + throw new NotFoundException('Kein lokaler Auth-Provider gefunden'); + } + + // Aktuelles Passwort verifizieren + const isCurrentValid = await bcrypt.compare( + currentPassword, + localAuth.passwordHash, + ); + if (!isCurrentValid) { + throw new UnauthorizedException('Aktuelles Passwort ist falsch'); + } + + // Neues Passwort hashen (Bcrypt Cost 12) + const bcryptCost = this.config.get('BCRYPT_COST', 12); + const newHash = await bcrypt.hash(newPassword, bcryptCost); + + // Passwort aktualisieren + await this.prisma.authProvider.update({ + where: { id: localAuth.id }, + data: { passwordHash: newHash }, + }); + + this.logger.log(`Passwort geaendert fuer User ${user.email}`); + } + /** * Alle User auflisten (fuer Admin). */ diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx index de423c7..8b0fde2 100644 --- a/packages/frontend/src/auth/AuthContext.tsx +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -14,6 +14,7 @@ interface User { firstName: string; lastName: string; role: string; + twoFactorEnabled: boolean; } interface AuthContextType { @@ -22,6 +23,7 @@ interface AuthContextType { isLoading: boolean; login: (email: string, password: string, totpCode?: string) => Promise; logout: () => Promise; + refreshUser: () => Promise; } interface LoginResult { @@ -95,6 +97,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { [], ); + const refreshUser = useCallback(async () => { + try { + const { data } = await api.get('/users/me'); + setUser(data); + } catch { + // Fehler ignorieren - User bleibt unveraendert + } + }, []); + const logout = useCallback(async () => { try { await api.post('/auth/logout'); @@ -114,6 +125,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading, login, logout, + refreshUser, }} > {children} diff --git a/packages/frontend/src/profile/ProfilePage.module.css b/packages/frontend/src/profile/ProfilePage.module.css new file mode 100644 index 0000000..f861869 --- /dev/null +++ b/packages/frontend/src/profile/ProfilePage.module.css @@ -0,0 +1,206 @@ +.section { + background: var(--color-bg-card); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); + margin-bottom: 1.5rem; +} + +.sectionTitle { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.field label { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); +} + +.field input { + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9375rem; + transition: border-color 0.15s; +} + +.field input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.field input:disabled { + background: var(--color-bg); + color: var(--color-text-muted); +} + +.field small { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.fieldRow { + display: flex; + gap: 1rem; +} + +.fieldRow .field { + flex: 1; +} + +.button { + padding: 0.625rem 1.25rem; + background: var(--color-primary); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + align-self: flex-start; +} + +.button:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.buttonSecondary { + padding: 0.625rem 1.25rem; + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.buttonSecondary:hover { + background: var(--color-bg); +} + +.buttonDanger { + padding: 0.625rem 1.25rem; + background: var(--color-error); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + align-self: flex-start; +} + +.buttonDanger:hover:not(:disabled) { + background: #b91c1c; +} + +.buttonDanger:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.buttonRow { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.success { + background: #f0fdf4; + color: var(--color-success); + padding: 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + border: 1px solid #bbf7d0; +} + +.error { + background: #fef2f2; + color: var(--color-error); + padding: 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + border: 1px solid #fecaca; +} + +.tfaStatus { + display: flex; + align-items: center; + padding: 0.75rem 0; + font-size: 0.9375rem; + margin-bottom: 1rem; +} + +.tfaSetup { + margin-top: 1rem; +} + +.tfaInstructions { + color: var(--color-text-secondary); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.qrContainer { + display: flex; + justify-content: center; + padding: 1.5rem; + background: white; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + margin-bottom: 1rem; +} + +.qrContainer img { + width: 200px; + height: 200px; +} + +.manualSecret { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-bottom: 1.25rem; +} + +.manualSecret label { + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.manualSecret code { + background: var(--color-bg); + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.875rem; + letter-spacing: 2px; + word-break: break-all; + border: 1px solid var(--color-border); +} diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx new file mode 100644 index 0000000..fe9c039 --- /dev/null +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -0,0 +1,452 @@ +import { useState, type FormEvent } from 'react'; +import { useAuth } from '../auth/AuthContext'; +import api from '../api/client'; +import styles from './ProfilePage.module.css'; + +export function ProfilePage() { + const { user, refreshUser } = useAuth(); + + // --- Persoenliche Informationen --- + const [firstName, setFirstName] = useState(user?.firstName ?? ''); + const [lastName, setLastName] = useState(user?.lastName ?? ''); + const [profileMsg, setProfileMsg] = useState(''); + const [profileError, setProfileError] = useState(''); + const [profileLoading, setProfileLoading] = useState(false); + + // --- Passwort aendern --- + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordMsg, setPasswordMsg] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordLoading, setPasswordLoading] = useState(false); + + // --- 2FA --- + const [twoFactorEnabled, setTwoFactorEnabled] = useState( + user?.twoFactorEnabled ?? false, + ); + const [setupData, setSetupData] = useState<{ + qrCode: string; + secret: string; + } | null>(null); + const [totpCode, setTotpCode] = useState(''); + const [disablePassword, setDisablePassword] = useState(''); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [tfaMsg, setTfaMsg] = useState(''); + const [tfaError, setTfaError] = useState(''); + const [tfaLoading, setTfaLoading] = useState(false); + + // === Handler: Profil aktualisieren === + const handleProfileUpdate = async (e: FormEvent) => { + e.preventDefault(); + setProfileMsg(''); + setProfileError(''); + setProfileLoading(true); + + try { + await api.patch('/users/me', { firstName, lastName }); + await refreshUser(); + setProfileMsg('Profil erfolgreich aktualisiert'); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setProfileError( + error.response?.data?.message ?? 'Fehler beim Aktualisieren', + ); + } finally { + setProfileLoading(false); + } + }; + + // === Handler: Passwort aendern === + const handlePasswordChange = async (e: FormEvent) => { + e.preventDefault(); + setPasswordMsg(''); + setPasswordError(''); + + if (newPassword !== confirmPassword) { + setPasswordError('Passwoerter stimmen nicht ueberein'); + return; + } + + if (newPassword.length < 8) { + setPasswordError('Neues Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + setPasswordLoading(true); + + try { + await api.post('/users/me/change-password', { + currentPassword, + newPassword, + }); + setPasswordMsg('Passwort erfolgreich geaendert'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setPasswordError( + error.response?.data?.message ?? 'Fehler beim Aendern des Passworts', + ); + } finally { + setPasswordLoading(false); + } + }; + + // === Handler: 2FA Setup starten === + const handleSetup2fa = async () => { + setTfaMsg(''); + setTfaError(''); + setTfaLoading(true); + + try { + const { data } = await api.post<{ qrCode: string; secret: string }>( + '/auth/2fa/setup', + ); + setSetupData(data); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setTfaError( + error.response?.data?.message ?? 'Fehler beim 2FA-Setup', + ); + } finally { + setTfaLoading(false); + } + }; + + // === Handler: 2FA aktivieren (Code verifizieren) === + const handleEnable2fa = async (e: FormEvent) => { + e.preventDefault(); + setTfaMsg(''); + setTfaError(''); + setTfaLoading(true); + + try { + await api.post('/auth/2fa/enable', { totpCode }); + setTwoFactorEnabled(true); + setSetupData(null); + setTotpCode(''); + await refreshUser(); + setTfaMsg('2FA wurde erfolgreich aktiviert'); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setTfaError( + error.response?.data?.message ?? 'Ungueltiger Code', + ); + } finally { + setTfaLoading(false); + } + }; + + // === Handler: 2FA deaktivieren === + const handleDisable2fa = async (e: FormEvent) => { + e.preventDefault(); + setTfaMsg(''); + setTfaError(''); + setTfaLoading(true); + + try { + await api.post('/auth/2fa/disable', { password: disablePassword }); + setTwoFactorEnabled(false); + setShowDisableConfirm(false); + setDisablePassword(''); + await refreshUser(); + setTfaMsg('2FA wurde erfolgreich deaktiviert'); + } catch (err: unknown) { + const error = err as { response?: { data?: { message?: string } } }; + setTfaError( + error.response?.data?.message ?? 'Fehler beim Deaktivieren', + ); + } finally { + setTfaLoading(false); + } + }; + + return ( +
+

+ Mein Profil +

+ + {/* === Sektion 1: Persoenliche Informationen === */} +
+

Persoenliche Informationen

+
+ {profileMsg &&
{profileMsg}
} + {profileError &&
{profileError}
} + +
+ + + E-Mail-Adresse kann nicht geaendert werden +
+ +
+
+ + setFirstName(e.target.value)} + required + maxLength={100} + /> +
+
+ + setLastName(e.target.value)} + required + maxLength={100} + /> +
+
+ +
+ + +
+ + +
+
+ + {/* === Sektion 2: Passwort aendern === */} +
+

Passwort aendern

+
+ {passwordMsg &&
{passwordMsg}
} + {passwordError && ( +
{passwordError}
+ )} + +
+ + setCurrentPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ + setNewPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ + +
+
+ + {/* === Sektion 3: Zwei-Faktor-Authentifizierung === */} +
+

+ Zwei-Faktor-Authentifizierung (2FA) +

+ + {tfaMsg &&
{tfaMsg}
} + {tfaError &&
{tfaError}
} + +
+ + + {twoFactorEnabled + ? '2FA ist aktiviert' + : '2FA ist nicht aktiviert'} + +
+ + {/* 2FA NICHT aktiviert: Setup starten */} + {!twoFactorEnabled && !setupData && ( + + )} + + {/* QR-Code anzeigen + Verifizierung */} + {!twoFactorEnabled && setupData && ( +
+

+ Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Google + Authenticator, Authy): +

+ +
+ QR-Code fuer 2FA +
+ +
+ + {setupData.secret} +
+ +
+
+ + setTotpCode(e.target.value)} + placeholder="6-stelliger Code" + maxLength={6} + pattern="[0-9]{6}" + required + autoFocus + /> + + Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein + +
+ +
+ + +
+
+
+ )} + + {/* 2FA aktiviert: Deaktivieren-Option */} + {twoFactorEnabled && !showDisableConfirm && ( + + )} + + {/* Deaktivierung mit Passwort bestaetigen */} + {twoFactorEnabled && showDisableConfirm && ( +
+

+ Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren: +

+
+ + setDisablePassword(e.target.value)} + required + minLength={8} + autoFocus + /> +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 86b7ac9..0b67ea1 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -5,6 +5,7 @@ import { AppLayout } from './AppLayout'; import { DashboardPage } from './DashboardPage'; import { AdminUsersPage } from '../admin/AdminUsersPage'; import { AdminTenantsPage } from '../admin/AdminTenantsPage'; +import { ProfilePage } from '../profile/ProfilePage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -40,6 +41,7 @@ export function App() { } > } /> + } /> } /> } /> diff --git a/packages/frontend/src/shell/AppLayout.module.css b/packages/frontend/src/shell/AppLayout.module.css index 97bc883..744bc53 100644 --- a/packages/frontend/src/shell/AppLayout.module.css +++ b/packages/frontend/src/shell/AppLayout.module.css @@ -70,6 +70,18 @@ border-top: 1px solid rgba(255, 255, 255, 0.1); } +.userProfile { + cursor: pointer; + padding: 0.375rem; + margin: -0.375rem; + border-radius: var(--radius-sm); + transition: background 0.15s; +} + +.userProfile:hover { + background: rgba(255, 255, 255, 0.1); +} + .userName { font-size: 0.875rem; font-weight: 600; diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 4b5ce29..0963feb 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -55,10 +55,20 @@ export function AppLayout() {
-
- {user?.firstName} {user?.lastName} +
navigate('/profile')} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') navigate('/profile'); + }} + > +
+ {user?.firstName} {user?.lastName} +
+
{user?.email}
-
{user?.email}