mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:36:39 +02:00
feat: add user profile page with 2FA management and password change
Backend: - POST /auth/2fa/setup - generate TOTP secret + QR code (temp Redis storage) - POST /auth/2fa/enable - verify TOTP code and activate 2FA - POST /auth/2fa/disable - deactivate 2FA (requires password) - PATCH /users/me - update own profile (firstName, lastName) - POST /users/me/change-password - change own password Frontend: - New ProfilePage with 3 sections: personal info, password, 2FA - QR code display for Authenticator app setup - Clickable user info in sidebar navigates to /profile - AuthContext extended with twoFactorEnabled + refreshUser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bd00ea5e50
commit
779d90ca43
13 changed files with 1010 additions and 3 deletions
|
|
@ -13,6 +13,8 @@ import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { Enable2faDto } from './dto/enable-2fa.dto';
|
||||||
|
import { Disable2faDto } from './dto/disable-2fa.dto';
|
||||||
|
|
||||||
@ApiTags('Authentifizierung')
|
@ApiTags('Authentifizierung')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
|
|
@ -107,6 +109,50 @@ export class AuthController {
|
||||||
return { message: 'Erfolgreich abgemeldet' };
|
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.
|
* Setzt das Refresh-Token als HttpOnly Cookie.
|
||||||
* Secure + SameSite=Strict nur in Produktion (HTTPS).
|
* Secure + SameSite=Strict nur in Produktion (HTTPS).
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,114 @@ export class AuthService {
|
||||||
this.logger.log(`Logout: User ${accessToken.sub}`);
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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).
|
* Token-Paar generieren (Access + Refresh).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
13
packages/core-service/src/core/auth/dto/disable-2fa.dto.ts
Normal file
13
packages/core-service/src/core/auth/dto/disable-2fa.dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
13
packages/core-service/src/core/auth/dto/enable-2fa.dto.ts
Normal file
13
packages/core-service/src/core/auth/dto/enable-2fa.dto.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,14 @@ import {
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { Roles } from '../../common/decorators/roles.decorator';
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser, JwtPayload } from '../../common/decorators/current-user.decorator';
|
||||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||||
|
|
@ -33,6 +36,38 @@ export class UsersController {
|
||||||
return this.usersService.findById(userId);
|
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
|
* GET /api/v1/users
|
||||||
* Alle User auflisten (nur PLATFORM_ADMIN).
|
* Alle User auflisten (nur PLATFORM_ADMIN).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
Injectable,
|
Injectable,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcrypt';
|
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<void> {
|
||||||
|
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<number>('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).
|
* Alle User auflisten (fuer Admin).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface User {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
|
|
@ -22,6 +23,7 @@ interface AuthContextType {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
|
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResult {
|
interface LoginResult {
|
||||||
|
|
@ -95,6 +97,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<User>('/users/me');
|
||||||
|
setUser(data);
|
||||||
|
} catch {
|
||||||
|
// Fehler ignorieren - User bleibt unveraendert
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout');
|
await api.post('/auth/logout');
|
||||||
|
|
@ -114,6 +125,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
isLoading,
|
isLoading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
refreshUser,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
206
packages/frontend/src/profile/ProfilePage.module.css
Normal file
206
packages/frontend/src/profile/ProfilePage.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
452
packages/frontend/src/profile/ProfilePage.tsx
Normal file
452
packages/frontend/src/profile/ProfilePage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mein Profil
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* === Sektion 1: Persoenliche Informationen === */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Persoenliche Informationen</h2>
|
||||||
|
<form onSubmit={handleProfileUpdate} className={styles.form}>
|
||||||
|
{profileMsg && <div className={styles.success}>{profileMsg}</div>}
|
||||||
|
{profileError && <div className={styles.error}>{profileError}</div>}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="email">E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={user?.email ?? ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<small>E-Mail-Adresse kann nicht geaendert werden</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="firstName">Vorname</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="lastName">Nachname</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label>Rolle</label>
|
||||||
|
<input type="text" value={user?.role ?? ''} disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={profileLoading}
|
||||||
|
>
|
||||||
|
{profileLoading ? 'Speichern...' : 'Profil speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Sektion 2: Passwort aendern === */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Passwort aendern</h2>
|
||||||
|
<form onSubmit={handlePasswordChange} className={styles.form}>
|
||||||
|
{passwordMsg && <div className={styles.success}>{passwordMsg}</div>}
|
||||||
|
{passwordError && (
|
||||||
|
<div className={styles.error}>{passwordError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="currentPassword">Aktuelles Passwort</label>
|
||||||
|
<input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="newPassword">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="confirmPassword">Neues Passwort bestaetigen</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={passwordLoading}
|
||||||
|
>
|
||||||
|
{passwordLoading ? 'Aendern...' : 'Passwort aendern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Sektion 3: Zwei-Faktor-Authentifizierung === */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
Zwei-Faktor-Authentifizierung (2FA)
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{tfaMsg && <div className={styles.success}>{tfaMsg}</div>}
|
||||||
|
{tfaError && <div className={styles.error}>{tfaError}</div>}
|
||||||
|
|
||||||
|
<div className={styles.tfaStatus}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: twoFactorEnabled
|
||||||
|
? 'var(--color-success)'
|
||||||
|
: 'var(--color-error)',
|
||||||
|
marginRight: '0.5rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{twoFactorEnabled
|
||||||
|
? '2FA ist aktiviert'
|
||||||
|
: '2FA ist nicht aktiviert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA NICHT aktiviert: Setup starten */}
|
||||||
|
{!twoFactorEnabled && !setupData && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleSetup2fa}
|
||||||
|
disabled={tfaLoading}
|
||||||
|
>
|
||||||
|
{tfaLoading ? 'Laden...' : '2FA aktivieren'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR-Code anzeigen + Verifizierung */}
|
||||||
|
{!twoFactorEnabled && setupData && (
|
||||||
|
<div className={styles.tfaSetup}>
|
||||||
|
<p className={styles.tfaInstructions}>
|
||||||
|
Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Google
|
||||||
|
Authenticator, Authy):
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.qrContainer}>
|
||||||
|
<img src={setupData.qrCode} alt="QR-Code fuer 2FA" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.manualSecret}>
|
||||||
|
<label>Manueller Schluessel:</label>
|
||||||
|
<code>{setupData.secret}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleEnable2fa} className={styles.form}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="totpCode">Bestaetigungscode</label>
|
||||||
|
<input
|
||||||
|
id="totpCode"
|
||||||
|
type="text"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value)}
|
||||||
|
placeholder="6-stelliger Code"
|
||||||
|
maxLength={6}
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={tfaLoading}
|
||||||
|
>
|
||||||
|
{tfaLoading
|
||||||
|
? 'Verifizieren...'
|
||||||
|
: 'Code verifizieren und aktivieren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.buttonSecondary}
|
||||||
|
onClick={() => {
|
||||||
|
setSetupData(null);
|
||||||
|
setTotpCode('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2FA aktiviert: Deaktivieren-Option */}
|
||||||
|
{twoFactorEnabled && !showDisableConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.buttonDanger}
|
||||||
|
onClick={() => setShowDisableConfirm(true)}
|
||||||
|
>
|
||||||
|
2FA deaktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deaktivierung mit Passwort bestaetigen */}
|
||||||
|
{twoFactorEnabled && showDisableConfirm && (
|
||||||
|
<form onSubmit={handleDisable2fa} className={styles.form}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren:
|
||||||
|
</p>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="disablePassword">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="disablePassword"
|
||||||
|
type="password"
|
||||||
|
value={disablePassword}
|
||||||
|
onChange={(e) => setDisablePassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.buttonDanger}
|
||||||
|
disabled={tfaLoading}
|
||||||
|
>
|
||||||
|
{tfaLoading ? 'Deaktivieren...' : '2FA deaktivieren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.buttonSecondary}
|
||||||
|
onClick={() => {
|
||||||
|
setShowDisableConfirm(false);
|
||||||
|
setDisablePassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { AppLayout } from './AppLayout';
|
||||||
import { DashboardPage } from './DashboardPage';
|
import { DashboardPage } from './DashboardPage';
|
||||||
import { AdminUsersPage } from '../admin/AdminUsersPage';
|
import { AdminUsersPage } from '../admin/AdminUsersPage';
|
||||||
import { AdminTenantsPage } from '../admin/AdminTenantsPage';
|
import { AdminTenantsPage } from '../admin/AdminTenantsPage';
|
||||||
|
import { ProfilePage } from '../profile/ProfilePage';
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
@ -40,6 +41,7 @@ export function App() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="admin/users" element={<AdminUsersPage />} />
|
<Route path="admin/users" element={<AdminUsersPage />} />
|
||||||
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
<Route path="admin/tenants" element={<AdminTenantsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
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 {
|
.userName {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,20 @@ export function AppLayout() {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className={styles.userInfo}>
|
<div className={styles.userInfo}>
|
||||||
|
<div
|
||||||
|
className={styles.userProfile}
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className={styles.userName}>
|
<div className={styles.userName}>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.userEmail}>{user?.email}</div>
|
<div className={styles.userEmail}>{user?.email}</div>
|
||||||
|
</div>
|
||||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue