mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 09:06:40 +02:00
Neue Felder im Benutzerprofil (analog Microsoft 365 /me): - Stellenbezeichnung (jobTitle), Abteilung (department) - Firma (companyName), Standort (officeLocation) Changes: - Core: Prisma-Migration + neue Felder in User-Model, UpdateUserDto, findById/update/updateProfile - CRM: M365UserProfile-Interface + getM365Profile um neue Felder erweitert; neue Methode getM365Photo() lädt 96x96 JPEG als Base64 Data-URL; neuer Endpoint GET /crm/office365/photo - Frontend: AuthContext User-Interface, M365UserProfile-Typ, office365Api.getM365Photo() ProfilePage: Neues Formular-Fieldset "Organisation" mit 4 Feldern; manueller Sync-Button übernimmt auch Profilbild (immer überschreiben); useO365ProfileSync: Auto-Sync lädt Foto nur wenn noch kein INSIGHT-Avatar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1034 lines
37 KiB
TypeScript
1034 lines
37 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 ?? '');
|
|
|
|
// --- Organisation ---
|
|
const [jobTitle, setJobTitle] = useState(user?.jobTitle ?? '');
|
|
const [department, setDepartment] = useState(user?.department ?? '');
|
|
const [companyName, setCompanyName] = useState(user?.companyName ?? '');
|
|
const [officeLocation, setOfficeLocation] = useState(user?.officeLocation ?? '');
|
|
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]);
|
|
|
|
// Organisationsdaten mit Context-User synchronisieren
|
|
useEffect(() => {
|
|
if (user) {
|
|
setJobTitle(user.jobTitle ?? '');
|
|
setDepartment(user.department ?? '');
|
|
setCompanyName(user.companyName ?? '');
|
|
setOfficeLocation(user.officeLocation ?? '');
|
|
}
|
|
}, [user?.jobTitle, user?.department, user?.companyName, user?.officeLocation]);
|
|
|
|
// === 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,
|
|
jobTitle: jobTitle || null,
|
|
department: department || null,
|
|
companyName: companyName || null,
|
|
officeLocation: officeLocation || 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 synchronisieren (überschreibt bestehende Werte) ===
|
|
const handleEnrichFromO365 = async () => {
|
|
setEnrichMsg('');
|
|
setEnrichError('');
|
|
setEnrichLoading(true);
|
|
|
|
try {
|
|
// Profil + Foto parallel laden
|
|
const [profileResult, photoResult] = await Promise.all([
|
|
office365Api.getM365Profile(),
|
|
office365Api.getM365Photo(),
|
|
]);
|
|
const p = profileResult.data;
|
|
|
|
const updated: string[] = [];
|
|
|
|
// Kontaktfelder — immer überschreiben wo O365 Daten hat
|
|
if (p.givenName) { setFirstName(p.givenName); updated.push('Vorname'); }
|
|
if (p.surname) { setLastName(p.surname); updated.push('Nachname'); }
|
|
if (p.businessPhones?.[0]) { setPhone(p.businessPhones[0]); updated.push('Telefon'); }
|
|
if (p.mobilePhone) { setMobile(p.mobilePhone); updated.push('Mobil'); }
|
|
if (p.city) { setCity(p.city); updated.push('Ort'); }
|
|
if (p.streetAddress) { setStreet(p.streetAddress); updated.push('Straße'); }
|
|
if (p.postalCode) { setPostalCode(p.postalCode); updated.push('PLZ'); }
|
|
|
|
// Organisationsfelder
|
|
if (p.jobTitle) { setJobTitle(p.jobTitle); updated.push('Position'); }
|
|
if (p.department) { setDepartment(p.department); updated.push('Abteilung'); }
|
|
if (p.companyName) { setCompanyName(p.companyName); updated.push('Firma'); }
|
|
if (p.officeLocation) { setOfficeLocation(p.officeLocation); updated.push('Standort'); }
|
|
|
|
// Profilbild übernehmen (manuelle Sync überschreibt immer)
|
|
if (photoResult.data.photoBase64) {
|
|
await api.patch('/users/me', { avatar: photoResult.data.photoBase64 });
|
|
setAvatar(photoResult.data.photoBase64);
|
|
await refreshUser();
|
|
updated.push('Profilbild');
|
|
}
|
|
|
|
if (updated.length > 0) {
|
|
setEnrichMsg(
|
|
`${updated.length} Felder aktualisiert: ${updated.join(', ')}. Bitte prüfen und speichern.`,
|
|
);
|
|
} else {
|
|
setEnrichMsg('Keine Profildaten in Microsoft 365 gefunden.');
|
|
}
|
|
} 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}>
|
|
Profil mit Microsoft 365 synchronisieren
|
|
</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>
|
|
|
|
{/* Organisation */}
|
|
<fieldset className={styles.fieldGroup}>
|
|
<legend className={styles.fieldGroupLegend}>Organisation</legend>
|
|
<div className={styles.fieldRow}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="jobTitle">Stellenbezeichnung</label>
|
|
<input
|
|
id="jobTitle"
|
|
type="text"
|
|
value={jobTitle}
|
|
onChange={(e) => setJobTitle(e.target.value)}
|
|
maxLength={100}
|
|
placeholder="Senior Developer"
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label htmlFor="department">Abteilung</label>
|
|
<input
|
|
id="department"
|
|
type="text"
|
|
value={department}
|
|
onChange={(e) => setDepartment(e.target.value)}
|
|
maxLength={100}
|
|
placeholder="Engineering"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={styles.fieldRow}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="companyName">Firma</label>
|
|
<input
|
|
id="companyName"
|
|
type="text"
|
|
value={companyName}
|
|
onChange={(e) => setCompanyName(e.target.value)}
|
|
maxLength={200}
|
|
placeholder="Acme GmbH"
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label htmlFor="officeLocation">Standort</label>
|
|
<input
|
|
id="officeLocation"
|
|
type="text"
|
|
value={officeLocation}
|
|
onChange={(e) => setOfficeLocation(e.target.value)}
|
|
maxLength={200}
|
|
placeholder="Berlin Office"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|