mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
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:
parent
c8703ef3e0
commit
5d3958cd74
7 changed files with 623 additions and 313 deletions
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue