feat: restructure profile page with new layout, contact fields, and 2FA relocation

- Add phone, mobile, street, postalCode, city fields to User model (Prisma + migration)
- Extend UpdateUserDto with validated contact/address fields
- Update UsersService (findById, update, updateProfile) for new fields
- Rename tab "Persönliche Informationen" to "Profil"
- New layout: avatar left column, form right column with fieldset groups
- Move 2FA section from always-visible into "Passwort ändern" tab
- Add orange 2FA warning badge next to page title (clickable → password tab)
- Add responsive CSS for mobile breakpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 09:03:15 +01:00
parent c8703ef3e0
commit 5d3958cd74
7 changed files with 623 additions and 313 deletions

View file

@ -24,6 +24,16 @@ model User {
firstName String @map("first_name") @db.VarChar(100) firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100) lastName String @map("last_name") @db.VarChar(100)
avatar String? @db.Text // Profilbild als Base64 Data-URL avatar String? @db.Text // Profilbild als Base64 Data-URL
// Kontaktdaten
phone String? @map("phone") @db.VarChar(30)
mobile String? @map("mobile") @db.VarChar(30)
// Adresse
street String? @map("street") @db.VarChar(200)
postalCode String? @map("postal_code") @db.VarChar(10)
city String? @map("city") @db.VarChar(100)
role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER role String @default("USER") @db.VarChar(50) // PLATFORM_ADMIN, TENANT_ADMIN, USER
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")

View file

@ -0,0 +1,6 @@
-- AlterTable: Kontakt- und Adressfelder für Benutzerprofil
ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(30);
ALTER TABLE "users" ADD COLUMN "mobile" VARCHAR(30);
ALTER TABLE "users" ADD COLUMN "street" VARCHAR(200);
ALTER TABLE "users" ADD COLUMN "postal_code" VARCHAR(10);
ALTER TABLE "users" ADD COLUMN "city" VARCHAR(100);

View file

@ -40,4 +40,41 @@ export class UpdateUserDto {
message: 'Profilbild muss ein gültiges 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;
// --- Kontaktdaten ---
@ApiProperty({ example: '+49 123 456789', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.phone !== null)
@IsString()
@MaxLength(30)
phone?: string | null;
@ApiProperty({ example: '+49 170 1234567', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.mobile !== null)
@IsString()
@MaxLength(30)
mobile?: string | null;
// --- Adresse ---
@ApiProperty({ example: 'Musterstraße 42', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.street !== null)
@IsString()
@MaxLength(200)
street?: string | null;
@ApiProperty({ example: '12345', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.postalCode !== null)
@IsString()
@MaxLength(10)
postalCode?: string | null;
@ApiProperty({ example: 'Berlin', required: false, nullable: true })
@IsOptional()
@ValidateIf((o: UpdateUserDto) => o.city !== null)
@IsString()
@MaxLength(100)
city?: string | null;
} }

View file

@ -96,6 +96,11 @@ export class UsersService {
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
avatar: user.avatar, avatar: user.avatar,
phone: user.phone,
mobile: user.mobile,
street: user.street,
postalCode: user.postalCode,
city: user.city,
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
twoFactorEnabled: user.twoFactorEnabled, twoFactorEnabled: user.twoFactorEnabled,
@ -126,6 +131,11 @@ export class UsersService {
lastName: dto.lastName, lastName: dto.lastName,
isActive: dto.isActive, isActive: dto.isActive,
...(dto.avatar !== undefined && { avatar: dto.avatar }), ...(dto.avatar !== undefined && { avatar: dto.avatar }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.mobile !== undefined && { mobile: dto.mobile }),
...(dto.street !== undefined && { street: dto.street }),
...(dto.postalCode !== undefined && { postalCode: dto.postalCode }),
...(dto.city !== undefined && { city: dto.city }),
}, },
}); });
@ -135,6 +145,11 @@ export class UsersService {
firstName: updated.firstName, firstName: updated.firstName,
lastName: updated.lastName, lastName: updated.lastName,
avatar: updated.avatar, avatar: updated.avatar,
phone: updated.phone,
mobile: updated.mobile,
street: updated.street,
postalCode: updated.postalCode,
city: updated.city,
role: updated.role, role: updated.role,
isActive: updated.isActive, isActive: updated.isActive,
}; };
@ -155,6 +170,11 @@ export class UsersService {
...(dto.firstName !== undefined && { firstName: dto.firstName }), ...(dto.firstName !== undefined && { firstName: dto.firstName }),
...(dto.lastName !== undefined && { lastName: dto.lastName }), ...(dto.lastName !== undefined && { lastName: dto.lastName }),
...(dto.avatar !== undefined && { avatar: dto.avatar }), ...(dto.avatar !== undefined && { avatar: dto.avatar }),
...(dto.phone !== undefined && { phone: dto.phone }),
...(dto.mobile !== undefined && { mobile: dto.mobile }),
...(dto.street !== undefined && { street: dto.street }),
...(dto.postalCode !== undefined && { postalCode: dto.postalCode }),
...(dto.city !== undefined && { city: dto.city }),
}, },
}); });
@ -164,6 +184,11 @@ export class UsersService {
firstName: updated.firstName, firstName: updated.firstName,
lastName: updated.lastName, lastName: updated.lastName,
avatar: updated.avatar, avatar: updated.avatar,
phone: updated.phone,
mobile: updated.mobile,
street: updated.street,
postalCode: updated.postalCode,
city: updated.city,
role: updated.role, role: updated.role,
isActive: updated.isActive, isActive: updated.isActive,
twoFactorEnabled: updated.twoFactorEnabled, twoFactorEnabled: updated.twoFactorEnabled,

View file

@ -14,6 +14,11 @@ interface User {
firstName: string; firstName: string;
lastName: string; lastName: string;
avatar?: string | null; avatar?: string | null;
phone?: string | null;
mobile?: string | null;
street?: string | null;
postalCode?: string | null;
city?: string | null;
role: string; role: string;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
} }

View file

@ -54,6 +54,29 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
/* === Profil-Layout (Avatar links, Formular rechts) === */
.profileLayout {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.avatarColumn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
min-width: 140px;
flex-shrink: 0;
padding-top: 0.25rem;
}
.formColumn {
flex: 1;
min-width: 0;
}
/* === Formular === */
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -64,6 +87,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem; gap: 0.375rem;
flex: 1;
} }
.field label { .field label {
@ -105,6 +129,31 @@
flex: 1; flex: 1;
} }
.fieldSmall {
flex: 0 0 120px !important;
}
/* === Fieldset-Gruppen === */
.fieldGroup {
border: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fieldGroupLegend {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0;
margin-bottom: 0.125rem;
}
/* === Buttons === */
.button { .button {
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background: var(--color-primary); background: var(--color-primary);
@ -128,12 +177,12 @@
} }
.buttonSecondary { .buttonSecondary {
padding: 0.625rem 1.25rem; padding: 0.5rem 1rem;
background: transparent; background: transparent;
color: var(--color-text-secondary); color: var(--color-text-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
@ -144,12 +193,12 @@
} }
.buttonDanger { .buttonDanger {
padding: 0.625rem 1.25rem; padding: 0.5rem 1rem;
background: var(--color-error); background: var(--color-error);
color: white; color: white;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
@ -171,6 +220,7 @@
align-items: center; align-items: center;
} }
/* === Meldungen === */
.success { .success {
background: #f0fdf4; background: #f0fdf4;
color: var(--color-success); color: var(--color-success);
@ -189,6 +239,7 @@
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
/* === 2FA === */
.tfaStatus { .tfaStatus {
display: flex; display: flex;
align-items: center; align-items: center;
@ -244,17 +295,34 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
/* === Avatar === */ /* === 2FA Warn-Badge === */
.avatarSection { .tfaWarning {
display: flex; display: inline-flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.375rem;
padding-bottom: 1.25rem; padding: 0.25rem 0.75rem;
margin-bottom: 1.25rem; background: #fff7ed;
border-bottom: 1px solid var(--color-border); color: #c2410c;
border: 1px solid #fed7aa;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
} }
.tfaWarning::before {
content: '\26A0';
font-size: 0.875rem;
}
.tfaWarning:hover {
background: #ffedd5;
border-color: #fdba74;
}
/* === Avatar === */
.avatarPreview { .avatarPreview {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@ -288,9 +356,45 @@
.avatarHint { .avatarHint {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.75rem; font-size: 0.6875rem;
text-align: center;
} }
.hiddenInput { .hiddenInput {
display: none; display: none;
} }
/* === Responsive === */
@media (max-width: 640px) {
.profileLayout {
flex-direction: column;
align-items: center;
}
.avatarColumn {
min-width: unset;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
width: 100%;
}
.fieldRow {
flex-direction: column;
gap: 1rem;
}
.fieldSmall {
flex: 1 !important;
}
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
padding: 0.625rem 1rem;
font-size: 0.875rem;
white-space: nowrap;
}
}

View file

@ -13,6 +13,11 @@ export function ProfilePage() {
// --- Persönliche 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 [phone, setPhone] = useState(user?.phone ?? '');
const [mobile, setMobile] = useState(user?.mobile ?? '');
const [street, setStreet] = useState(user?.street ?? '');
const [postalCode, setPostalCode] = useState(user?.postalCode ?? '');
const [city, setCity] = useState(user?.city ?? '');
const [profileMsg, setProfileMsg] = useState(''); const [profileMsg, setProfileMsg] = useState('');
const [profileError, setProfileError] = useState(''); const [profileError, setProfileError] = useState('');
const [profileLoading, setProfileLoading] = useState(false); const [profileLoading, setProfileLoading] = useState(false);
@ -47,7 +52,7 @@ export function ProfilePage() {
const [tfaError, setTfaError] = useState(''); const [tfaError, setTfaError] = useState('');
const [tfaLoading, setTfaLoading] = useState(false); const [tfaLoading, setTfaLoading] = useState(false);
// 2FA-Status mit Context-User synchronisieren (Bug-Fix: Login-Response hat jetzt twoFactorEnabled) // 2FA-Status mit Context-User synchronisieren
useEffect(() => { useEffect(() => {
if (user?.twoFactorEnabled !== undefined) { if (user?.twoFactorEnabled !== undefined) {
setTwoFactorEnabled(user.twoFactorEnabled); setTwoFactorEnabled(user.twoFactorEnabled);
@ -61,6 +66,17 @@ export function ProfilePage() {
} }
}, [user?.avatar]); }, [user?.avatar]);
// Kontaktdaten mit Context-User synchronisieren
useEffect(() => {
if (user) {
setPhone(user.phone ?? '');
setMobile(user.mobile ?? '');
setStreet(user.street ?? '');
setPostalCode(user.postalCode ?? '');
setCity(user.city ?? '');
}
}, [user?.phone, user?.mobile, user?.street, user?.postalCode, user?.city]);
// === Handler: Profilbild hochladen === // === Handler: Profilbild hochladen ===
const handleAvatarChange = async (e: ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -128,7 +144,15 @@ export function ProfilePage() {
setProfileLoading(true); setProfileLoading(true);
try { try {
await api.patch('/users/me', { firstName, lastName }); await api.patch('/users/me', {
firstName,
lastName,
phone: phone || null,
mobile: mobile || null,
street: street || null,
postalCode: postalCode || null,
city: city || null,
});
await refreshUser(); await refreshUser();
setProfileMsg('Profil erfolgreich aktualisiert'); setProfileMsg('Profil erfolgreich aktualisiert');
} catch (err: unknown) { } catch (err: unknown) {
@ -267,9 +291,26 @@ export function ProfilePage() {
fontSize: '1.5rem', fontSize: '1.5rem',
fontWeight: 600, fontWeight: 600,
marginBottom: '1.5rem', marginBottom: '1.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}} }}
> >
Mein Profil Mein Profil
{!twoFactorEnabled && (
<span
className={styles.tfaWarning}
onClick={() => setActiveTab('password')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setActiveTab('password');
}}
title="Klicken, um 2FA zu aktivieren"
>
2FA nicht aktiv
</span>
)}
</h1> </h1>
{/* === Tab-Leiste === */} {/* === Tab-Leiste === */}
@ -279,7 +320,7 @@ export function ProfilePage() {
className={`${styles.tab} ${activeTab === 'personal' ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === 'personal' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('personal')} onClick={() => setActiveTab('personal')}
> >
Persönliche Informationen Profil
</button> </button>
<button <button
type="button" type="button"
@ -297,117 +338,197 @@ export function ProfilePage() {
</button> </button>
</div> </div>
{/* === Tab: Persönliche Informationen === */} {/* === Tab: Profil === */}
{activeTab === 'personal' && ( {activeTab === 'personal' && (
<div className={styles.section}> <div className={styles.section}>
{/* Profilbild */} <div className={styles.profileLayout}>
<div className={styles.avatarSection}> {/* --- Linke Spalte: Avatar --- */}
<div <div className={styles.avatarColumn}>
className={styles.avatarPreview} <div
onClick={() => fileInputRef.current?.click()} className={styles.avatarPreview}
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 ä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.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>Ä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...' : '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. Max. 200x200px.
</small>
</div>
{/* --- Rechte Spalte: Formular --- */}
<div className={styles.formColumn}>
<form onSubmit={handleProfileUpdate} className={styles.form}>
{profileMsg && <div className={styles.success}>{profileMsg}</div>}
{profileError && <div className={styles.error}>{profileError}</div>}
{/* Name */}
<fieldset className={styles.fieldGroup}>
<legend className={styles.fieldGroupLegend}>Name</legend>
<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>
</fieldset>
{/* E-Mail & Rolle */}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={user?.email ?? ''}
disabled
/>
<small>E-Mail kann nicht geändert werden</small>
</div>
<div className={styles.field}>
<label>Rolle</label>
<input type="text" value={user?.role ?? ''} disabled />
</div>
</div>
{/* Telefon */}
<fieldset className={styles.fieldGroup}>
<legend className={styles.fieldGroupLegend}>Kontakt</legend>
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="phone">Telefon</label>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={30}
placeholder="+49 123 456789"
/>
</div>
<div className={styles.field}>
<label htmlFor="mobile">Mobil</label>
<input
id="mobile"
type="tel"
value={mobile}
onChange={(e) => setMobile(e.target.value)}
maxLength={30}
placeholder="+49 170 1234567"
/>
</div>
</div>
</fieldset>
{/* Adresse */}
<fieldset className={styles.fieldGroup}>
<legend className={styles.fieldGroupLegend}>Adresse</legend>
<div className={styles.field}>
<label htmlFor="street">Straße</label>
<input
id="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
maxLength={200}
placeholder="Musterstraße 42"
/>
</div>
<div className={styles.fieldRow}>
<div className={`${styles.field} ${styles.fieldSmall}`}>
<label htmlFor="postalCode">PLZ</label>
<input
id="postalCode"
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
maxLength={10}
placeholder="12345"
/>
</div>
<div className={styles.field}>
<label htmlFor="city">Ort</label>
<input
id="city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
maxLength={100}
placeholder="Berlin"
/>
</div>
</div>
</fieldset>
<button
type="submit"
className={styles.button}
disabled={profileLoading}
>
{profileLoading ? 'Speichern...' : 'Profil speichern'}
</button>
</form>
</div> </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>
<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> </div>
)} )}
@ -421,222 +542,224 @@ export function ProfilePage() {
</div> </div>
)} )}
{/* === Tab: Passwort ändern === */} {/* === Tab: Passwort ändern + 2FA === */}
{activeTab === 'password' && ( {activeTab === 'password' && (
<div className={styles.section}> <>
<h2 className={styles.sectionTitle}>Passwort ändern</h2> <div className={styles.section}>
<form onSubmit={handlePasswordChange} className={styles.form}> <h2 className={styles.sectionTitle}>Passwort ändern</h2>
{passwordMsg && <div className={styles.success}>{passwordMsg}</div>} <form onSubmit={handlePasswordChange} className={styles.form}>
{passwordError && ( {passwordMsg && <div className={styles.success}>{passwordMsg}</div>}
<div className={styles.error}>{passwordError}</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 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={passwordLoading}
>
{passwordLoading ? 'Ändern...' : 'Passwort ändern'}
</button>
</form>
</div>
)}
{/* === Sicherheit: Zwei-Faktor-Authentifizierung (immer sichtbar) === */}
<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 für 2FA" />
</div>
<div className={styles.manualSecret}>
<label>Manueller Schlüssel:</label>
<code>{setupData.secret}</code>
</div>
<form onSubmit={handleEnable2fa} className={styles.form}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="totpCode">Bestätigungscode</label> <label htmlFor="currentPassword">Aktuelles Passwort</label>
<input <input
id="totpCode" id="currentPassword"
type="text" type="password"
value={totpCode} value={currentPassword}
onChange={(e) => setTotpCode(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="6-stelliger Code"
maxLength={6}
pattern="[0-9]{6}"
required required
autoFocus minLength={8}
/> />
<small>
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
</small>
</div> </div>
<div className={styles.buttonRow}> <div className={styles.field}>
<button <label htmlFor="newPassword">Neues Passwort</label>
type="submit" <input
className={styles.button} id="newPassword"
disabled={tfaLoading} type="password"
> value={newPassword}
{tfaLoading onChange={(e) => setNewPassword(e.target.value)}
? 'Verifizieren...' required
: 'Code verifizieren und aktivieren'} minLength={8}
</button> />
<button
type="button"
className={styles.buttonSecondary}
onClick={() => {
setSetupData(null);
setTotpCode('');
}}
>
Abbrechen
</button>
</div> </div>
</form>
</div>
)}
{/* 2FA aktiviert: Deaktivieren-Option */} <div className={styles.field}>
{twoFactorEnabled && !showDisableConfirm && ( <label htmlFor="confirmPassword">Neues Passwort bestätigen</label>
<button <input
type="button" id="confirmPassword"
className={styles.buttonDanger} type="password"
onClick={() => setShowDisableConfirm(true)} value={confirmPassword}
> onChange={(e) => setConfirmPassword(e.target.value)}
2FA deaktivieren required
</button> minLength={8}
)} />
</div>
{/* Deaktivierung mit Passwort bestätigen */}
{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 <button
type="submit" type="submit"
className={styles.buttonDanger} className={styles.button}
disabled={tfaLoading} disabled={passwordLoading}
> >
{tfaLoading ? 'Deaktivieren...' : '2FA deaktivieren'} {passwordLoading ? 'Ändern...' : 'Passwort ändern'}
</button> </button>
</form>
</div>
{/* === 2FA Sektion (innerhalb Passwort-Tab) === */}
<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 <button
type="button" type="button"
className={styles.buttonSecondary} className={styles.button}
onClick={() => { onClick={handleSetup2fa}
setShowDisableConfirm(false); disabled={tfaLoading}
setDisablePassword('');
}}
> >
Abbrechen {tfaLoading ? 'Laden...' : '2FA aktivieren'}
</button> </button>
</div> )}
</form>
)} {/* QR-Code anzeigen + Verifizierung */}
</div> {!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 für 2FA" />
</div>
<div className={styles.manualSecret}>
<label>Manueller Schlüssel:</label>
<code>{setupData.secret}</code>
</div>
<form onSubmit={handleEnable2fa} className={styles.form}>
<div className={styles.field}>
<label htmlFor="totpCode">Bestätigungscode</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 bestätigen */}
{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> </div>
); );
} }