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) {
// Generische Fehlermeldung (kein Hinweis ob User existiert)
throw new UnauthorizedException('Ungueltige Anmeldedaten');
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// Passwort pruefen (nur fuer lokale Auth)
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Ungueltige Anmeldedaten');
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
const passwordValid = await bcrypt.compare(
@ -87,7 +87,7 @@ export class AuthService {
lastFailedLogin: new Date(),
},
});
throw new UnauthorizedException('Ungueltige Anmeldedaten');
throw new UnauthorizedException('Ungültige Anmeldedaten');
}
// Account-Sperre pruefen (nach 5 Fehlversuchen)
@ -125,11 +125,11 @@ export class AuthService {
localAuth.totpSecret ?? '',
);
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({
where: { id: user.id },
data: {
@ -179,12 +179,12 @@ export class AuthService {
payload.jti,
);
if (!isValid) {
// Moeglicherweise Refresh-Token-Diebstahl: alle invalidieren
// Möglicherweise Refresh-Token-Diebstahl: alle invalidieren
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);
throw new UnauthorizedException('Refresh Token ungueltig');
throw new UnauthorizedException('Refresh Token ungültig');
}
// Alten Refresh-Token invalidieren
@ -200,7 +200,7 @@ export class AuthService {
});
} catch (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);
if (!isValid) {
throw new UnauthorizedException('Ungueltiger 2FA-Code');
throw new UnauthorizedException('Ungültiger 2FA-Code');
}
// 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}`);
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');
}
// Passwort pruefen
// Passwort prüfen
const localAuth = user.authProvider.find((ap) => ap.provider === 'LOCAL');
if (!localAuth?.passwordHash) {
throw new UnauthorizedException('Kein lokaler Auth-Provider gefunden');
@ -321,10 +321,10 @@ export class AuthService {
const passwordValid = await bcrypt.compare(password, localAuth.passwordHash);
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([
this.prisma.authProvider.updateMany({
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',
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' })
email!: string;

View file

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

View file

@ -35,9 +35,9 @@ export class UpdateUserDto {
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.avatar !== null)
@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+/=]+$/, {
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;
}

View file

@ -51,11 +51,11 @@ export class UsersController {
/**
* POST /api/v1/users/me/change-password
* Eigenes Passwort aendern.
* Eigenes Passwort ändern.
*/
@Post('me/change-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Eigenes Passwort aendern' })
@ApiOperation({ summary: 'Eigenes Passwort ändern' })
async changePassword(
@CurrentUser('sub') userId: string,
@Body() dto: ChangePasswordDto,
@ -65,7 +65,7 @@ export class UsersController {
dto.currentPassword,
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})`);
// Passwort-Hash nicht zurueckgeben
// Passwort-Hash nicht zurückgeben
return {
id: user.id,
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(
userId: string,
@ -211,11 +211,11 @@ export class UsersService {
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) {
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');
setUser(data);
} 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 {
background: var(--color-bg-card);
border-radius: var(--radius-md);

View file

@ -5,10 +5,12 @@ import { UserAvatar } from '../components/UserAvatar';
import { resizeImageToBase64 } from '../utils/imageUtils';
import styles from './ProfilePage.module.css';
type ProfileTab = 'personal' | 'expert' | 'password';
export function ProfilePage() {
const { user, refreshUser } = useAuth();
// --- Persoenliche Informationen ---
// --- Persönliche Informationen ---
const [firstName, setFirstName] = useState(user?.firstName ?? '');
const [lastName, setLastName] = useState(user?.lastName ?? '');
const [profileMsg, setProfileMsg] = useState('');
@ -22,7 +24,7 @@ export function ProfilePage() {
const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// --- Passwort aendern ---
// --- Passwort ändern ---
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@ -65,12 +67,12 @@ export function ProfilePage() {
if (!file) return;
if (!file.type.startsWith('image/')) {
setAvatarError('Bitte waehlen Sie eine Bilddatei aus');
setAvatarError('Bitte wählen Sie eine Bilddatei aus');
return;
}
if (file.size > 5 * 1024 * 1024) {
setAvatarError('Bild darf maximal 5MB gross sein');
setAvatarError('Bild darf maximal 5MB groß sein');
return;
}
@ -139,14 +141,14 @@ export function ProfilePage() {
}
};
// === Handler: Passwort aendern ===
// === Handler: Passwort ändern ===
const handlePasswordChange = async (e: FormEvent) => {
e.preventDefault();
setPasswordMsg('');
setPasswordError('');
if (newPassword !== confirmPassword) {
setPasswordError('Passwoerter stimmen nicht ueberein');
setPasswordError('Passwörter stimmen nicht überein');
return;
}
@ -162,14 +164,14 @@ export function ProfilePage() {
currentPassword,
newPassword,
});
setPasswordMsg('Passwort erfolgreich geaendert');
setPasswordMsg('Passwort erfolgreich geändert');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
setPasswordError(
error.response?.data?.message ?? 'Fehler beim Aendern des Passworts',
error.response?.data?.message ?? 'Fehler beim Ändern des Passworts',
);
} finally {
setPasswordLoading(false);
@ -209,7 +211,7 @@ export function ProfilePage() {
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
setTfaError(
error.response?.data?.message ?? 'Ungueltiger Code',
error.response?.data?.message ?? 'Ungültiger Code',
);
setTfaLoading(false);
return;
@ -255,6 +257,9 @@ export function ProfilePage() {
refreshUser().catch(() => {});
};
// --- Tab-Navigation ---
const [activeTab, setActiveTab] = useState<ProfileTab>('personal');
return (
<div>
<h1
@ -267,10 +272,34 @@ export function ProfilePage() {
Mein Profil
</h1>
{/* === Sektion 1: Persoenliche Informationen === */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Persoenliche Informationen</h2>
{/* === Tab-Leiste === */}
<div className={styles.tabs}>
<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>
{/* === Tab: Persönliche Informationen === */}
{activeTab === 'personal' && (
<div className={styles.section}>
{/* Profilbild */}
<div className={styles.avatarSection}>
<div
@ -289,7 +318,7 @@ export function ProfilePage() {
size={96}
/>
<div className={styles.avatarOverlay}>
<span>Bild aendern</span>
<span>Bild ändern</span>
</div>
</div>
<input
@ -338,7 +367,7 @@ export function ProfilePage() {
value={user?.email ?? ''}
disabled
/>
<small>E-Mail-Adresse kann nicht geaendert werden</small>
<small>E-Mail-Adresse kann nicht geändert werden</small>
</div>
<div className={styles.fieldRow}>
@ -380,10 +409,22 @@ export function ProfilePage() {
</button>
</form>
</div>
)}
{/* === Sektion 2: Passwort aendern === */}
{/* === Tab: Experten Profil (Platzhalter) === */}
{activeTab === 'expert' && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Passwort aendern</h2>
<h2 className={styles.sectionTitle}>Experten Profil</h2>
<p className={styles.placeholder}>
Hier können Sie zukünftig Ihr Experten-Profil verwalten.
</p>
</div>
)}
{/* === Tab: Passwort ändern === */}
{activeTab === 'password' && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Passwort ändern</h2>
<form onSubmit={handlePasswordChange} className={styles.form}>
{passwordMsg && <div className={styles.success}>{passwordMsg}</div>}
{passwordError && (
@ -415,7 +456,7 @@ export function ProfilePage() {
</div>
<div className={styles.field}>
<label htmlFor="confirmPassword">Neues Passwort bestaetigen</label>
<label htmlFor="confirmPassword">Neues Passwort bestätigen</label>
<input
id="confirmPassword"
type="password"
@ -431,12 +472,13 @@ export function ProfilePage() {
className={styles.button}
disabled={passwordLoading}
>
{passwordLoading ? 'Aendern...' : 'Passwort aendern'}
{passwordLoading ? 'Ändern...' : 'Passwort ändern'}
</button>
</form>
</div>
)}
{/* === Sektion 3: Zwei-Faktor-Authentifizierung === */}
{/* === Sicherheit: Zwei-Faktor-Authentifizierung (immer sichtbar) === */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
Zwei-Faktor-Authentifizierung (2FA)
@ -486,17 +528,17 @@ export function ProfilePage() {
</p>
<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 className={styles.manualSecret}>
<label>Manueller Schluessel:</label>
<label>Manueller Schlüssel:</label>
<code>{setupData.secret}</code>
</div>
<form onSubmit={handleEnable2fa} className={styles.form}>
<div className={styles.field}>
<label htmlFor="totpCode">Bestaetigungscode</label>
<label htmlFor="totpCode">Bestätigungscode</label>
<input
id="totpCode"
type="text"
@ -549,7 +591,7 @@ export function ProfilePage() {
</button>
)}
{/* Deaktivierung mit Passwort bestaetigen */}
{/* Deaktivierung mit Passwort bestätigen */}
{twoFactorEnabled && showDisableConfirm && (
<form onSubmit={handleDisable2fa} className={styles.form}>
<p

View file

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

View file

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