mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- GraphService: getM365Profile() lädt givenName, surname, mobilePhone, businessPhones, city, streetAddress, postalCode, jobTitle via /me - Office365Controller: GET /crm/office365/profile Endpunkt - Frontend types: M365UserProfile Interface - Frontend api: office365Api.getM365Profile() - ProfilePage: O365 uebernehmen Button (nur wenn M365 verbunden) fuellt leere Felder: Vorname, Nachname, Telefon, Mobil, Ort, Strasse, PLZ Bestehende Werte werden NICHT ueberschrieben; Feedback zeigt welche Felder uebernommen wurden; User muss Speichern klicken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
963 lines
33 KiB
TypeScript
963 lines
33 KiB
TypeScript
import { useState, useEffect, useRef, type FormEvent, type ChangeEvent } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
import api from '../api/client';
|
|
import { UserAvatar } from '../components/UserAvatar';
|
|
import { resizeImageToBase64 } from '../utils/imageUtils';
|
|
import { ExpertProfileTab } from './ExpertProfileTab';
|
|
import { useIntegrations, useDisconnectM365 } from '../crm/hooks';
|
|
import { integrationsApi, office365Api } from '../crm/api';
|
|
import styles from './ProfilePage.module.css';
|
|
|
|
type ProfileTab = 'personal' | 'expert' | 'password' | 'integrations';
|
|
|
|
export function ProfilePage() {
|
|
const { user, refreshUser } = useAuth();
|
|
|
|
// --- Persönliche Informationen ---
|
|
const [firstName, setFirstName] = useState(user?.firstName ?? '');
|
|
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 [profileError, setProfileError] = useState('');
|
|
const [profileLoading, setProfileLoading] = useState(false);
|
|
|
|
// --- Profilbild ---
|
|
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
|
|
const [avatarMsg, setAvatarMsg] = useState('');
|
|
const [avatarError, setAvatarError] = useState('');
|
|
const [avatarLoading, setAvatarLoading] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// --- O365-Profilanreicherung ---
|
|
const [enrichLoading, setEnrichLoading] = useState(false);
|
|
const [enrichMsg, setEnrichMsg] = useState('');
|
|
const [enrichError, setEnrichError] = useState('');
|
|
|
|
// --- Passwort ändern ---
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [passwordMsg, setPasswordMsg] = useState('');
|
|
const [passwordError, setPasswordError] = useState('');
|
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
|
|
|
// --- 2FA ---
|
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(
|
|
user?.twoFactorEnabled ?? false,
|
|
);
|
|
const [setupData, setSetupData] = useState<{
|
|
qrCode: string;
|
|
secret: string;
|
|
} | null>(null);
|
|
const [totpCode, setTotpCode] = useState('');
|
|
const [disablePassword, setDisablePassword] = useState('');
|
|
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
|
|
const [tfaMsg, setTfaMsg] = useState('');
|
|
const [tfaError, setTfaError] = useState('');
|
|
const [tfaLoading, setTfaLoading] = useState(false);
|
|
|
|
// 2FA-Status mit Context-User synchronisieren
|
|
useEffect(() => {
|
|
if (user?.twoFactorEnabled !== undefined) {
|
|
setTwoFactorEnabled(user.twoFactorEnabled);
|
|
}
|
|
}, [user?.twoFactorEnabled]);
|
|
|
|
// Avatar mit Context-User synchronisieren
|
|
useEffect(() => {
|
|
if (user?.avatar !== undefined) {
|
|
setAvatar(user.avatar ?? null);
|
|
}
|
|
}, [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 ===
|
|
const handleAvatarChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
setAvatarError('Bitte wählen Sie eine Bilddatei aus');
|
|
return;
|
|
}
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
setAvatarError('Bild darf maximal 5MB groß sein');
|
|
return;
|
|
}
|
|
|
|
setAvatarMsg('');
|
|
setAvatarError('');
|
|
setAvatarLoading(true);
|
|
|
|
try {
|
|
const base64 = await resizeImageToBase64(file, 200, 200);
|
|
await api.patch('/users/me', { avatar: base64 });
|
|
setAvatar(base64);
|
|
await refreshUser();
|
|
setAvatarMsg('Profilbild erfolgreich aktualisiert');
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setAvatarError(
|
|
error.response?.data?.message ?? 'Fehler beim Hochladen des Profilbilds',
|
|
);
|
|
} finally {
|
|
setAvatarLoading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
// === Handler: Profilbild entfernen ===
|
|
const handleAvatarRemove = async () => {
|
|
setAvatarMsg('');
|
|
setAvatarError('');
|
|
setAvatarLoading(true);
|
|
|
|
try {
|
|
await api.patch('/users/me', { avatar: null });
|
|
setAvatar(null);
|
|
await refreshUser();
|
|
setAvatarMsg('Profilbild entfernt');
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setAvatarError(
|
|
error.response?.data?.message ?? 'Fehler beim Entfernen des Profilbilds',
|
|
);
|
|
} finally {
|
|
setAvatarLoading(false);
|
|
}
|
|
};
|
|
|
|
// === Handler: Profil aktualisieren ===
|
|
const handleProfileUpdate = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setProfileMsg('');
|
|
setProfileError('');
|
|
setProfileLoading(true);
|
|
|
|
try {
|
|
await api.patch('/users/me', {
|
|
firstName,
|
|
lastName,
|
|
phone: phone || null,
|
|
mobile: mobile || null,
|
|
street: street || null,
|
|
postalCode: postalCode || null,
|
|
city: city || null,
|
|
});
|
|
await refreshUser();
|
|
setProfileMsg('Profil erfolgreich aktualisiert');
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setProfileError(
|
|
error.response?.data?.message ?? 'Fehler beim Aktualisieren',
|
|
);
|
|
} finally {
|
|
setProfileLoading(false);
|
|
}
|
|
};
|
|
|
|
// === Handler: Profil aus O365 anreichern ===
|
|
const handleEnrichFromO365 = async () => {
|
|
setEnrichMsg('');
|
|
setEnrichError('');
|
|
setEnrichLoading(true);
|
|
|
|
try {
|
|
const result = await office365Api.getM365Profile();
|
|
const p = result.data;
|
|
|
|
const filled: string[] = [];
|
|
|
|
if (!firstName && p.givenName) {
|
|
setFirstName(p.givenName);
|
|
filled.push('Vorname');
|
|
}
|
|
if (!lastName && p.surname) {
|
|
setLastName(p.surname);
|
|
filled.push('Nachname');
|
|
}
|
|
if (!phone && p.businessPhones?.[0]) {
|
|
setPhone(p.businessPhones[0]);
|
|
filled.push('Telefon');
|
|
}
|
|
if (!mobile && p.mobilePhone) {
|
|
setMobile(p.mobilePhone);
|
|
filled.push('Mobil');
|
|
}
|
|
if (!city && p.city) {
|
|
setCity(p.city);
|
|
filled.push('Ort');
|
|
}
|
|
if (!street && p.streetAddress) {
|
|
setStreet(p.streetAddress);
|
|
filled.push('Straße');
|
|
}
|
|
if (!postalCode && p.postalCode) {
|
|
setPostalCode(p.postalCode);
|
|
filled.push('PLZ');
|
|
}
|
|
|
|
if (filled.length > 0) {
|
|
setEnrichMsg(
|
|
`${filled.length} Felder übernommen: ${filled.join(', ')}. Bitte prüfen und speichern.`,
|
|
);
|
|
} else {
|
|
setEnrichMsg('Alle Felder sind bereits ausgefüllt — keine Änderungen.');
|
|
}
|
|
} catch {
|
|
setEnrichError('O365-Profil konnte nicht geladen werden.');
|
|
} finally {
|
|
setEnrichLoading(false);
|
|
}
|
|
};
|
|
|
|
// === Handler: Passwort ändern ===
|
|
const handlePasswordChange = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setPasswordMsg('');
|
|
setPasswordError('');
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError('Passwörter stimmen nicht überein');
|
|
return;
|
|
}
|
|
|
|
if (newPassword.length < 8) {
|
|
setPasswordError('Neues Passwort muss mindestens 8 Zeichen lang sein');
|
|
return;
|
|
}
|
|
|
|
setPasswordLoading(true);
|
|
|
|
try {
|
|
await api.post('/users/me/change-password', {
|
|
currentPassword,
|
|
newPassword,
|
|
});
|
|
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 Ändern des Passworts',
|
|
);
|
|
} finally {
|
|
setPasswordLoading(false);
|
|
}
|
|
};
|
|
|
|
// === Handler: 2FA Setup starten ===
|
|
const handleSetup2fa = async () => {
|
|
setTfaMsg('');
|
|
setTfaError('');
|
|
setTfaLoading(true);
|
|
|
|
try {
|
|
const { data } = await api.post<{ qrCode: string; secret: string }>(
|
|
'/auth/2fa/setup',
|
|
);
|
|
setSetupData(data);
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setTfaError(
|
|
error.response?.data?.message ?? 'Fehler beim 2FA-Setup',
|
|
);
|
|
} finally {
|
|
setTfaLoading(false);
|
|
}
|
|
};
|
|
|
|
// === Handler: 2FA aktivieren (Code verifizieren) ===
|
|
const handleEnable2fa = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setTfaMsg('');
|
|
setTfaError('');
|
|
setTfaLoading(true);
|
|
|
|
try {
|
|
await api.post('/auth/2fa/enable', { totpCode });
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setTfaError(
|
|
error.response?.data?.message ?? 'Ungültiger Code',
|
|
);
|
|
setTfaLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Erfolg — State aktualisieren
|
|
setTwoFactorEnabled(true);
|
|
setSetupData(null);
|
|
setTotpCode('');
|
|
setTfaMsg('2FA wurde erfolgreich aktiviert');
|
|
setTfaLoading(false);
|
|
|
|
// User-Context im Hintergrund aktualisieren
|
|
refreshUser().catch(() => {});
|
|
};
|
|
|
|
// === Handler: 2FA deaktivieren ===
|
|
const handleDisable2fa = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setTfaMsg('');
|
|
setTfaError('');
|
|
setTfaLoading(true);
|
|
|
|
try {
|
|
await api.post('/auth/2fa/disable', { password: disablePassword });
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { message?: string } } };
|
|
setTfaError(
|
|
error.response?.data?.message ?? 'Fehler beim Deaktivieren',
|
|
);
|
|
setTfaLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Erfolg — State aktualisieren
|
|
setTwoFactorEnabled(false);
|
|
setShowDisableConfirm(false);
|
|
setDisablePassword('');
|
|
setTfaMsg('2FA wurde erfolgreich deaktiviert');
|
|
setTfaLoading(false);
|
|
|
|
// User-Context im Hintergrund aktualisieren
|
|
refreshUser().catch(() => {});
|
|
};
|
|
|
|
// --- Tab-Navigation ---
|
|
const location = useLocation();
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const integrationParam = searchParams.get('integration');
|
|
const integrationStatus = searchParams.get('status');
|
|
|
|
const [activeTab, setActiveTab] = useState<ProfileTab>(
|
|
integrationParam === 'microsoft-365' ? 'integrations' : 'personal',
|
|
);
|
|
|
|
// --- Microsoft 365 Integration ---
|
|
const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations();
|
|
const disconnectM365 = useDisconnectM365();
|
|
const m365Integration = integrationsData?.data?.find((i) => i.provider === 'MICROSOFT_365');
|
|
const [m365Msg, setM365Msg] = useState(
|
|
integrationParam === 'microsoft-365' && integrationStatus === 'success'
|
|
? 'Microsoft 365 wurde erfolgreich verbunden!'
|
|
: integrationParam === 'microsoft-365' && integrationStatus === 'error'
|
|
? 'Verbindung mit Microsoft 365 fehlgeschlagen. Bitte versuchen Sie es erneut.'
|
|
: '',
|
|
);
|
|
const [m365IsError, setM365IsError] = useState(
|
|
integrationParam === 'microsoft-365' && integrationStatus === 'error',
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<h1
|
|
style={{
|
|
fontSize: '1.5rem',
|
|
fontWeight: 600,
|
|
marginBottom: '1.5rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.75rem',
|
|
}}
|
|
>
|
|
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>
|
|
|
|
{/* === Tab-Leiste === */}
|
|
<div className={styles.tabs}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.tab} ${activeTab === 'personal' ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab('personal')}
|
|
>
|
|
Profil
|
|
</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>
|
|
<button
|
|
type="button"
|
|
className={`${styles.tab} ${activeTab === 'integrations' ? styles.tabActive : ''}`}
|
|
onClick={() => setActiveTab('integrations')}
|
|
>
|
|
Integrationen
|
|
</button>
|
|
</div>
|
|
|
|
{/* === Tab: Profil === */}
|
|
{activeTab === 'personal' && (
|
|
<div className={styles.section}>
|
|
<div className={styles.profileLayout}>
|
|
{/* --- Linke Spalte: Avatar --- */}
|
|
<div className={styles.avatarColumn}>
|
|
<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>Ä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()}
|
|
disabled={avatarLoading}
|
|
>
|
|
{avatarLoading ? 'Laden...' : '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. Max. 200x200px.
|
|
</small>
|
|
</div>
|
|
|
|
{/* --- Rechte Spalte: Formular --- */}
|
|
<div className={styles.formColumn}>
|
|
|
|
{/* O365-Anreicherung (nur wenn verbunden) */}
|
|
{m365Integration?.connected && (
|
|
<div className={styles.enrichBanner}>
|
|
<div className={styles.enrichInfo}>
|
|
<span className={styles.enrichIcon}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
|
|
<path d="M15 9l-6 6M9 9l6 6"/>
|
|
</svg>
|
|
</span>
|
|
<span className={styles.enrichLabel}>
|
|
Fehlende Felder aus Microsoft 365 übernehmen
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.enrichBtn}
|
|
onClick={handleEnrichFromO365}
|
|
disabled={enrichLoading}
|
|
>
|
|
{enrichLoading ? 'Lädt…' : '↓ O365 übernehmen'}
|
|
</button>
|
|
{enrichMsg && <div className={`${styles.success} ${styles.enrichFeedback}`}>{enrichMsg}</div>}
|
|
{enrichError && <div className={`${styles.error} ${styles.enrichFeedback}`}>{enrichError}</div>}
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{/* === Tab: Experten Profil === */}
|
|
{activeTab === 'expert' && <ExpertProfileTab />}
|
|
|
|
{/* === Tab: Passwort ändern + 2FA === */}
|
|
{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.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>
|
|
|
|
{/* === 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
|
|
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}>
|
|
<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>
|
|
</>
|
|
)}
|
|
|
|
{/* === Tab: Integrationen === */}
|
|
{activeTab === 'integrations' && (
|
|
<>
|
|
<div className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Microsoft 365</h2>
|
|
|
|
{m365Msg && (
|
|
<div className={m365IsError ? styles.error : styles.success} style={{ marginBottom: '1rem' }}>
|
|
{m365Msg}
|
|
</div>
|
|
)}
|
|
|
|
{integrationsLoading ? (
|
|
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>Laden…</p>
|
|
) : m365Integration?.connected ? (
|
|
<div>
|
|
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text)', marginBottom: '0.5rem' }}>
|
|
<span style={{ color: 'var(--color-success, #16a34a)', fontWeight: 600 }}>● Verbunden</span>
|
|
{m365Integration.msTenantId && (
|
|
<span style={{ marginLeft: '0.75rem', fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
|
Tenant: {m365Integration.msTenantId}
|
|
</span>
|
|
)}
|
|
</p>
|
|
{m365Integration.expiresAt && (
|
|
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
|
|
Token gültig bis: {new Date(m365Integration.expiresAt).toLocaleString('de-DE')}
|
|
</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={styles.buttonDanger}
|
|
disabled={disconnectM365.isPending}
|
|
onClick={() => {
|
|
setM365Msg('');
|
|
disconnectM365.mutate(undefined, {
|
|
onSuccess: () => {
|
|
setM365Msg('Microsoft 365 wurde getrennt.');
|
|
setM365IsError(false);
|
|
},
|
|
onError: () => {
|
|
setM365Msg('Fehler beim Trennen der Verbindung.');
|
|
setM365IsError(true);
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{disconnectM365.isPending ? 'Trennen…' : 'Verbindung trennen'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
|
|
Verbinden Sie Ihr Microsoft 365 Konto, um E-Mails, Kalendertermine und Aufgaben
|
|
direkt in Kontaktprofilen zu sehen.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => integrationsApi.connectM365()}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
padding: '0.5rem 1.25rem',
|
|
background: 'var(--color-primary)',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 'var(--radius-sm)',
|
|
fontSize: '0.9375rem',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M0 0h7.5v7.5H0zm8.5 0H16v7.5H8.5zM0 8.5h7.5V16H0zm8.5 0H16V16H8.5z" />
|
|
</svg>
|
|
Microsoft 365 verbinden
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|