feat: add profile page tabs and fix German umlauts throughout app

- Add tab navigation to profile page (Persönliche Informationen, Experten Profil, Passwort ändern)
- Experten Profil tab as placeholder for future content
- 2FA section remains always visible below tabs
- Fix all German umlauts (ae→ä, oe→ö, ue→ü, ss→ß) in frontend and backend
- Fix validation messages, error messages, comments, and UI text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 07:56:55 +01:00
parent b5ec4e13b7
commit c8703ef3e0
11 changed files with 272 additions and 191 deletions

View file

@ -65,13 +65,13 @@ export class AuthService {
if (!user || !user.isActive) { if (!user || !user.isActive) {
// Generische Fehlermeldung (kein Hinweis ob User existiert) // Generische Fehlermeldung (kein Hinweis ob User existiert)
throw new UnauthorizedException('Ungueltige Anmeldedaten'); throw new UnauthorizedException('Ungültige Anmeldedaten');
} }
// Passwort pruefen (nur fuer lokale Auth) // Passwort pruefen (nur fuer lokale Auth)
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL'); const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) { if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Ungueltige Anmeldedaten'); throw new UnauthorizedException('Ungültige Anmeldedaten');
} }
const passwordValid = await bcrypt.compare( const passwordValid = await bcrypt.compare(
@ -87,7 +87,7 @@ export class AuthService {
lastFailedLogin: new Date(), lastFailedLogin: new Date(),
}, },
}); });
throw new UnauthorizedException('Ungueltige Anmeldedaten'); throw new UnauthorizedException('Ungültige Anmeldedaten');
} }
// Account-Sperre pruefen (nach 5 Fehlversuchen) // Account-Sperre pruefen (nach 5 Fehlversuchen)
@ -125,11 +125,11 @@ export class AuthService {
localAuth.totpSecret ?? '', localAuth.totpSecret ?? '',
); );
if (!totpValid) { if (!totpValid) {
throw new UnauthorizedException('Ungueltiger 2FA-Code'); throw new UnauthorizedException('Ungültiger 2FA-Code');
} }
} }
// Erfolgreicher Login: Counter zuruecksetzen // Erfolgreicher Login: Counter zurücksetzen
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@ -179,12 +179,12 @@ export class AuthService {
payload.jti, payload.jti,
); );
if (!isValid) { if (!isValid) {
// Moeglicherweise Refresh-Token-Diebstahl: alle invalidieren // Möglicherweise Refresh-Token-Diebstahl: alle invalidieren
this.logger.warn( this.logger.warn(
`Verdaechtiger Refresh-Token Wiederverwendung fuer User ${payload.sub}`, `Verdächtiger Refresh-Token Wiederverwendung für User ${payload.sub}`,
); );
await this.redis.invalidateAllRefreshTokens(payload.sub); await this.redis.invalidateAllRefreshTokens(payload.sub);
throw new UnauthorizedException('Refresh Token ungueltig'); throw new UnauthorizedException('Refresh Token ungültig');
} }
// Alten Refresh-Token invalidieren // Alten Refresh-Token invalidieren
@ -200,7 +200,7 @@ export class AuthService {
}); });
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedException) throw error; if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Refresh Token ungueltig oder abgelaufen'); throw new UnauthorizedException('Refresh Token ungültig oder abgelaufen');
} }
} }
@ -272,10 +272,10 @@ export class AuthService {
); );
} }
// TOTP-Code pruefen // TOTP-Code prüfen
const isValid = this.totp.verify(totpCode, secret); const isValid = this.totp.verify(totpCode, secret);
if (!isValid) { if (!isValid) {
throw new UnauthorizedException('Ungueltiger 2FA-Code'); throw new UnauthorizedException('Ungültiger 2FA-Code');
} }
// Secret permanent in AuthProvider speichern + 2FA aktivieren // Secret permanent in AuthProvider speichern + 2FA aktivieren
@ -290,10 +290,10 @@ export class AuthService {
}), }),
]); ]);
// Temporaeres Secret aus Redis loeschen // Temporäres Secret aus Redis löschen
await this.redis.del(`2fa_setup:${userId}`); await this.redis.del(`2fa_setup:${userId}`);
this.logger.log(`2FA aktiviert fuer User ${userId}`); this.logger.log(`2FA aktiviert für User ${userId}`);
} }
/** /**
@ -313,7 +313,7 @@ export class AuthService {
throw new ForbiddenException('2FA ist nicht aktiviert'); throw new ForbiddenException('2FA ist nicht aktiviert');
} }
// Passwort pruefen // Passwort prüfen
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL'); const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) { if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Kein lokaler Auth-Provider gefunden'); throw new UnauthorizedException('Kein lokaler Auth-Provider gefunden');
@ -321,10 +321,10 @@ export class AuthService {
const passwordValid = await bcrypt.compare(password, localAuth.passwordHash); const passwordValid = await bcrypt.compare(password, localAuth.passwordHash);
if (!passwordValid) { if (!passwordValid) {
throw new UnauthorizedException('Ungueltiges Passwort'); throw new UnauthorizedException('Ungültiges Passwort');
} }
// 2FA deaktivieren + Secret loeschen // 2FA deaktivieren + Secret löschen
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.authProvider.updateMany({ this.prisma.authProvider.updateMany({
where: { userId, provider: 'LOCAL' }, where: { userId, provider: 'LOCAL' },
@ -336,7 +336,7 @@ export class AuthService {
}), }),
]); ]);
this.logger.log(`2FA deaktiviert fuer User ${userId}`); this.logger.log(`2FA deaktiviert für User ${userId}`);
} }
/** /**

View file

@ -6,7 +6,7 @@ export class LoginDto {
example: 'admin@xinion.de', example: 'admin@xinion.de',
description: 'E-Mail-Adresse des Benutzers', description: 'E-Mail-Adresse des Benutzers',
}) })
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' }) @IsEmail({}, { message: 'Bitte gültige E-Mail-Adresse angeben' })
@IsNotEmpty({ message: 'E-Mail darf nicht leer sein' }) @IsNotEmpty({ message: 'E-Mail darf nicht leer sein' })
email!: string; email!: string;

View file

@ -11,7 +11,7 @@ import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto { export class CreateUserDto {
@ApiProperty({ example: 'max.mustermann@xinion.de' }) @ApiProperty({ example: 'max.mustermann@xinion.de' })
@IsEmail({}, { message: 'Bitte gueltige E-Mail-Adresse angeben' }) @IsEmail({}, { message: 'Bitte gültige E-Mail-Adresse angeben' })
@IsNotEmpty() @IsNotEmpty()
email!: string; email!: string;

View file

@ -35,9 +35,9 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@ValidateIf((o: UpdateUserDto) => o.avatar !== null) @ValidateIf((o: UpdateUserDto) => o.avatar !== null)
@IsString() @IsString()
@MaxLength(400000, { message: 'Profilbild darf maximal 400KB gross sein' }) @MaxLength(400000, { message: 'Profilbild darf maximal 400KB groß sein' })
@Matches(/^data:image\/(jpeg|png|gif|webp);base64,[A-Za-z0-9+/=]+$/, { @Matches(/^data:image\/(jpeg|png|gif|webp);base64,[A-Za-z0-9+/=]+$/, {
message: 'Profilbild muss ein gueltiges Base64-Bild sein (JPEG, PNG, GIF oder WebP)', message: 'Profilbild muss ein gültiges Base64-Bild sein (JPEG, PNG, GIF oder WebP)',
}) })
avatar?: string | null; avatar?: string | null;
} }

View file

@ -51,11 +51,11 @@ export class UsersController {
/** /**
* POST /api/v1/users/me/change-password * POST /api/v1/users/me/change-password
* Eigenes Passwort aendern. * Eigenes Passwort ändern.
*/ */
@Post('me/change-password') @Post('me/change-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Eigenes Passwort aendern' }) @ApiOperation({ summary: 'Eigenes Passwort ändern' })
async changePassword( async changePassword(
@CurrentUser('sub') userId: string, @CurrentUser('sub') userId: string,
@Body() dto: ChangePasswordDto, @Body() dto: ChangePasswordDto,
@ -65,7 +65,7 @@ export class UsersController {
dto.currentPassword, dto.currentPassword,
dto.newPassword, dto.newPassword,
); );
return { message: 'Passwort erfolgreich geaendert' }; return { message: 'Passwort erfolgreich geändert' };
} }
/** /**

View file

@ -60,7 +60,7 @@ export class UsersService {
this.logger.log(`User erstellt: ${user.email} (${user.role})`); this.logger.log(`User erstellt: ${user.email} (${user.role})`);
// Passwort-Hash nicht zurueckgeben // Passwort-Hash nicht zurückgeben
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -171,7 +171,7 @@ export class UsersService {
} }
/** /**
* Eigenes Passwort aendern (mit Verifikation des aktuellen Passworts). * Eigenes Passwort ändern (mit Verifikation des aktuellen Passworts).
*/ */
async changePassword( async changePassword(
userId: string, userId: string,
@ -211,11 +211,11 @@ export class UsersService {
data: { passwordHash: newHash }, data: { passwordHash: newHash },
}); });
this.logger.log(`Passwort geaendert fuer User ${user.email}`); this.logger.log(`Passwort geändert für User ${user.email}`);
} }
/** /**
* Alle User auflisten (fuer Admin). * Alle User auflisten (für Admin).
*/ */
async findAll(page = 1, limit = 20) { async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;

View file

@ -103,7 +103,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { data } = await api.get<User>('/users/me'); const { data } = await api.get<User>('/users/me');
setUser(data); setUser(data);
} catch { } catch {
// Fehler ignorieren - User bleibt unveraendert // Fehler ignorieren - User bleibt unverändert
} }
}, []); }, []);

View file

@ -1,3 +1,42 @@
/* === Tabs === */
.tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
transition: all 0.15s;
}
.tab:hover {
color: var(--color-text);
}
.tabActive {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
/* === Platzhalter === */
.placeholder {
color: var(--color-text-muted);
font-size: 0.9375rem;
padding: 2rem 0;
text-align: center;
}
.section { .section {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-md); border-radius: var(--radius-md);

View file

@ -5,10 +5,12 @@ import { UserAvatar } from '../components/UserAvatar';
import { resizeImageToBase64 } from '../utils/imageUtils'; import { resizeImageToBase64 } from '../utils/imageUtils';
import styles from './ProfilePage.module.css'; import styles from './ProfilePage.module.css';
type ProfileTab = 'personal' | 'expert' | 'password';
export function ProfilePage() { export function ProfilePage() {
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
// --- Persoenliche Informationen --- // --- Persönliche Informationen ---
const [firstName, setFirstName] = useState(user?.firstName ?? ''); const [firstName, setFirstName] = useState(user?.firstName ?? '');
const [lastName, setLastName] = useState(user?.lastName ?? ''); const [lastName, setLastName] = useState(user?.lastName ?? '');
const [profileMsg, setProfileMsg] = useState(''); const [profileMsg, setProfileMsg] = useState('');
@ -22,7 +24,7 @@ export function ProfilePage() {
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// --- Passwort aendern --- // --- Passwort ändern ---
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@ -65,12 +67,12 @@ export function ProfilePage() {
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
setAvatarError('Bitte waehlen Sie eine Bilddatei aus'); setAvatarError('Bitte wählen Sie eine Bilddatei aus');
return; return;
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
setAvatarError('Bild darf maximal 5MB gross sein'); setAvatarError('Bild darf maximal 5MB groß sein');
return; return;
} }
@ -139,14 +141,14 @@ export function ProfilePage() {
} }
}; };
// === Handler: Passwort aendern === // === Handler: Passwort ändern ===
const handlePasswordChange = async (e: FormEvent) => { const handlePasswordChange = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setPasswordMsg(''); setPasswordMsg('');
setPasswordError(''); setPasswordError('');
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setPasswordError('Passwoerter stimmen nicht ueberein'); setPasswordError('Passwörter stimmen nicht überein');
return; return;
} }
@ -162,14 +164,14 @@ export function ProfilePage() {
currentPassword, currentPassword,
newPassword, newPassword,
}); });
setPasswordMsg('Passwort erfolgreich geaendert'); setPasswordMsg('Passwort erfolgreich geändert');
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }; const error = err as { response?: { data?: { message?: string } } };
setPasswordError( setPasswordError(
error.response?.data?.message ?? 'Fehler beim Aendern des Passworts', error.response?.data?.message ?? 'Fehler beim Ändern des Passworts',
); );
} finally { } finally {
setPasswordLoading(false); setPasswordLoading(false);
@ -209,7 +211,7 @@ export function ProfilePage() {
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }; const error = err as { response?: { data?: { message?: string } } };
setTfaError( setTfaError(
error.response?.data?.message ?? 'Ungueltiger Code', error.response?.data?.message ?? 'Ungültiger Code',
); );
setTfaLoading(false); setTfaLoading(false);
return; return;
@ -255,6 +257,9 @@ export function ProfilePage() {
refreshUser().catch(() => {}); refreshUser().catch(() => {});
}; };
// --- Tab-Navigation ---
const [activeTab, setActiveTab] = useState<ProfileTab>('personal');
return ( return (
<div> <div>
<h1 <h1
@ -267,176 +272,213 @@ export function ProfilePage() {
Mein Profil Mein Profil
</h1> </h1>
{/* === Sektion 1: Persoenliche Informationen === */} {/* === Tab-Leiste === */}
<div className={styles.section}> <div className={styles.tabs}>
<h2 className={styles.sectionTitle}>Persoenliche Informationen</h2> <button
type="button"
className={`${styles.tab} ${activeTab === 'personal' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('personal')}
>
Persönliche Informationen
</button>
<button
type="button"
className={`${styles.tab} ${activeTab === 'expert' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('expert')}
>
Experten Profil
</button>
<button
type="button"
className={`${styles.tab} ${activeTab === 'password' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('password')}
>
Passwort ändern
</button>
</div>
{/* Profilbild */} {/* === Tab: Persönliche Informationen === */}
<div className={styles.avatarSection}> {activeTab === 'personal' && (
<div <div className={styles.section}>
className={styles.avatarPreview} {/* Profilbild */}
onClick={() => fileInputRef.current?.click()} <div className={styles.avatarSection}>
role="button" <div
tabIndex={0} className={styles.avatarPreview}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click();
}}
>
<UserAvatar
firstName={user?.firstName ?? ''}
lastName={user?.lastName ?? ''}
avatar={avatar}
size={96}
/>
<div className={styles.avatarOverlay}>
<span>Bild aendern</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarChange}
className={styles.hiddenInput}
/>
<div className={styles.avatarActions}>
<button
type="button"
className={styles.buttonSecondary}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={avatarLoading} role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click();
}}
> >
{avatarLoading ? 'Laden...' : 'Profilbild hochladen'} <UserAvatar
</button> firstName={user?.firstName ?? ''}
{avatar && ( lastName={user?.lastName ?? ''}
avatar={avatar}
size={96}
/>
<div className={styles.avatarOverlay}>
<span>Bild ändern</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarChange}
className={styles.hiddenInput}
/>
<div className={styles.avatarActions}>
<button <button
type="button" type="button"
className={styles.buttonDanger} className={styles.buttonSecondary}
onClick={handleAvatarRemove} onClick={() => fileInputRef.current?.click()}
disabled={avatarLoading} disabled={avatarLoading}
> >
Entfernen {avatarLoading ? 'Laden...' : 'Profilbild hochladen'}
</button> </button>
)} {avatar && (
<button
type="button"
className={styles.buttonDanger}
onClick={handleAvatarRemove}
disabled={avatarLoading}
>
Entfernen
</button>
)}
</div>
{avatarMsg && <div className={styles.success}>{avatarMsg}</div>}
{avatarError && <div className={styles.error}>{avatarError}</div>}
<small className={styles.avatarHint}>
JPEG, PNG, GIF oder WebP. Wird auf 200x200 Pixel skaliert.
</small>
</div> </div>
{avatarMsg && <div className={styles.success}>{avatarMsg}</div>}
{avatarError && <div className={styles.error}>{avatarError}</div>} <form onSubmit={handleProfileUpdate} className={styles.form}>
<small className={styles.avatarHint}> {profileMsg && <div className={styles.success}>{profileMsg}</div>}
JPEG, PNG, GIF oder WebP. Wird auf 200x200 Pixel skaliert. {profileError && <div className={styles.error}>{profileError}</div>}
</small>
<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 geändert 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> </div>
)}
<form onSubmit={handleProfileUpdate} className={styles.form}> {/* === Tab: Experten Profil (Platzhalter) === */}
{profileMsg && <div className={styles.success}>{profileMsg}</div>} {activeTab === 'expert' && (
{profileError && <div className={styles.error}>{profileError}</div>} <div className={styles.section}>
<h2 className={styles.sectionTitle}>Experten Profil</h2>
<p className={styles.placeholder}>
Hier können Sie zukünftig Ihr Experten-Profil verwalten.
</p>
</div>
)}
<div className={styles.field}> {/* === Tab: Passwort ändern === */}
<label htmlFor="email">E-Mail</label> {activeTab === 'password' && (
<input <div className={styles.section}>
id="email" <h2 className={styles.sectionTitle}>Passwort ändern</h2>
type="email" <form onSubmit={handlePasswordChange} className={styles.form}>
value={user?.email ?? ''} {passwordMsg && <div className={styles.success}>{passwordMsg}</div>}
disabled {passwordError && (
/> <div className={styles.error}>{passwordError}</div>
<small>E-Mail-Adresse kann nicht geaendert werden</small> )}
</div>
<div className={styles.fieldRow}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="firstName">Vorname</label> <label htmlFor="currentPassword">Aktuelles Passwort</label>
<input <input
id="firstName" id="currentPassword"
type="text" type="password"
value={firstName} value={currentPassword}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
required required
maxLength={100} minLength={8}
/> />
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="lastName">Nachname</label> <label htmlFor="newPassword">Neues Passwort</label>
<input <input
id="lastName" id="newPassword"
type="text" type="password"
value={lastName} value={newPassword}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
maxLength={100} minLength={8}
/> />
</div> </div>
</div>
<div className={styles.field}> <div className={styles.field}>
<label>Rolle</label> <label htmlFor="confirmPassword">Neues Passwort bestätigen</label>
<input type="text" value={user?.role ?? ''} disabled /> <input
</div> id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<button <button
type="submit" type="submit"
className={styles.button} className={styles.button}
disabled={profileLoading} disabled={passwordLoading}
> >
{profileLoading ? 'Speichern...' : 'Profil speichern'} {passwordLoading ? 'Ändern...' : 'Passwort ändern'}
</button> </button>
</form> </form>
</div> </div>
)}
{/* === Sektion 2: Passwort aendern === */} {/* === Sicherheit: Zwei-Faktor-Authentifizierung (immer sichtbar) === */}
<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}> <div className={styles.section}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
Zwei-Faktor-Authentifizierung (2FA) Zwei-Faktor-Authentifizierung (2FA)
@ -486,17 +528,17 @@ export function ProfilePage() {
</p> </p>
<div className={styles.qrContainer}> <div className={styles.qrContainer}>
<img src={setupData.qrCode} alt="QR-Code fuer 2FA" /> <img src={setupData.qrCode} alt="QR-Code für 2FA" />
</div> </div>
<div className={styles.manualSecret}> <div className={styles.manualSecret}>
<label>Manueller Schluessel:</label> <label>Manueller Schlüssel:</label>
<code>{setupData.secret}</code> <code>{setupData.secret}</code>
</div> </div>
<form onSubmit={handleEnable2fa} className={styles.form}> <form onSubmit={handleEnable2fa} className={styles.form}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="totpCode">Bestaetigungscode</label> <label htmlFor="totpCode">Bestätigungscode</label>
<input <input
id="totpCode" id="totpCode"
type="text" type="text"
@ -549,7 +591,7 @@ export function ProfilePage() {
</button> </button>
)} )}
{/* Deaktivierung mit Passwort bestaetigen */} {/* Deaktivierung mit Passwort bestätigen */}
{twoFactorEnabled && showDisableConfirm && ( {twoFactorEnabled && showDisableConfirm && (
<form onSubmit={handleDisable2fa} className={styles.form}> <form onSubmit={handleDisable2fa} className={styles.form}>
<p <p

View file

@ -28,10 +28,10 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
export function App() { export function App() {
return ( return (
<Routes> <Routes>
{/* Oeffentliche Routen */} {/* Öffentliche Routen */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
{/* Geschuetzte Routen */} {/* Geschützte Routen */}
<Route <Route
path="/" path="/"
element={ element={

View file

@ -1,7 +1,7 @@
/** /**
* Skaliert ein Bild auf maximal maxWidth x maxHeight und gibt es als Base64 Data-URL zurueck. * Skaliert ein Bild auf maximal maxWidth x maxHeight und gibt es als Base64 Data-URL zurück.
* Das Seitenverhaeltnis bleibt erhalten. * Das Seitenverhältnis bleibt erhalten.
* Ausgabeformat: JPEG mit Qualitaet 0.85 (guter Kompromiss aus Qualitaet und Groesse). * Ausgabeformat: JPEG mit Qualität 0.85 (guter Kompromiss aus Qualität und Größe).
*/ */
export function resizeImageToBase64( export function resizeImageToBase64(
file: File, file: File,