mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
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:
parent
b5ec4e13b7
commit
c8703ef3e0
11 changed files with 272 additions and 191 deletions
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,176 +272,213 @@ 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>
|
||||
|
||||
{/* Profilbild */}
|
||||
<div className={styles.avatarSection}>
|
||||
<div
|
||||
className={styles.avatarPreview}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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}
|
||||
{/* === Tab: Persönliche Informationen === */}
|
||||
{activeTab === 'personal' && (
|
||||
<div className={styles.section}>
|
||||
{/* Profilbild */}
|
||||
<div className={styles.avatarSection}>
|
||||
<div
|
||||
className={styles.avatarPreview}
|
||||
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'}
|
||||
</button>
|
||||
{avatar && (
|
||||
<UserAvatar
|
||||
firstName={user?.firstName ?? ''}
|
||||
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
|
||||
type="button"
|
||||
className={styles.buttonDanger}
|
||||
onClick={handleAvatarRemove}
|
||||
className={styles.buttonSecondary}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarLoading}
|
||||
>
|
||||
Entfernen
|
||||
{avatarLoading ? 'Laden...' : 'Profilbild hochladen'}
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleProfileUpdate} className={styles.form}>
|
||||
{profileMsg && <div className={styles.success}>{profileMsg}</div>}
|
||||
{profileError && <div className={styles.error}>{profileError}</div>}
|
||||
{/* === Tab: Experten Profil (Platzhalter) === */}
|
||||
{activeTab === 'expert' && (
|
||||
<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}>
|
||||
<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>
|
||||
{/* === 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 && (
|
||||
<div className={styles.error}>{passwordError}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="firstName">Vorname</label>
|
||||
<label htmlFor="currentPassword">Aktuelles Passwort</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="lastName">Nachname</label>
|
||||
<label htmlFor="newPassword">Neues Passwort</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Rolle</label>
|
||||
<input type="text" value={user?.role ?? ''} disabled />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="confirmPassword">Neues Passwort bestätigen</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={profileLoading}
|
||||
>
|
||||
{profileLoading ? 'Speichern...' : 'Profil speichern'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.button}
|
||||
disabled={passwordLoading}
|
||||
>
|
||||
{passwordLoading ? 'Ändern...' : 'Passwort ändern'}
|
||||
</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 === */}
|
||||
{/* === 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
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue