feat: O365-Profil automatisch beim Login synchronisieren

- Neuer Hook useO365ProfileSync: läuft einmalig pro Browser-Session,
  überschreibt INSIGHT-Profil-Felder mit O365-Daten (firstName, lastName,
  phone, mobile, city, street, postalCode) — kein UI-Feedback, kein Fehler
  bricht die UX auf.
- AppLayout ruft useO365ProfileSync() auf, sodass die Synchronisation
  beim Laden der App (nach Login) automatisch startet.
- ProfilePage: "↓ O365 übernehmen"-Button überschreibt jetzt alle Felder
  wo O365 Daten hat (nicht mehr nur leere Felder) — konsistent mit dem
  Auto-Sync-Verhalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 12:50:25 +01:00
parent 347a6ca418
commit 138742d385
3 changed files with 87 additions and 20 deletions

View file

@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { useAuth } from '../auth/AuthContext';
import { useIntegrations } from '../crm/hooks';
import { office365Api } from '../crm/api';
import api from '../api/client';
const SESSION_KEY = 'o365_profile_synced';
/**
* Syncs the INSIGHT user profile from Microsoft 365 once per browser session.
* Runs silently in the background no UI feedback, no disruption on error.
* Uses sessionStorage to prevent duplicate syncs within the same session.
*
* Fields overwritten where O365 has a non-null value:
* firstName, lastName, phone, mobile, city, street, postalCode
*/
export function useO365ProfileSync(): void {
const { user, refreshUser } = useAuth();
const { data: integrationsData } = useIntegrations();
const isM365Connected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
useEffect(() => {
// Prerequisites: authenticated user + M365 connected + not yet synced this session
if (!user || !isM365Connected) return;
if (sessionStorage.getItem(SESSION_KEY)) return;
// Mark as synced immediately to prevent duplicate calls on concurrent re-renders
sessionStorage.setItem(SESSION_KEY, 'true');
void (async () => {
try {
const result = await office365Api.getM365Profile();
const p = result.data;
// Overwrite all fields where O365 has a non-null value
const patch: Record<string, string | null> = {};
if (p.givenName) patch.firstName = p.givenName;
if (p.surname) patch.lastName = p.surname;
if (p.businessPhones?.[0]) patch.phone = p.businessPhones[0];
if (p.mobilePhone) patch.mobile = p.mobilePhone;
if (p.city) patch.city = p.city;
if (p.streetAddress) patch.street = p.streetAddress;
if (p.postalCode) patch.postalCode = p.postalCode;
if (Object.keys(patch).length > 0) {
await api.patch('/users/me', patch);
await refreshUser();
}
} catch {
// Silent failure — auto-sync must never disrupt the user experience.
// Remove the flag so a future navigation attempt can retry.
sessionStorage.removeItem(SESSION_KEY);
}
})();
// refreshUser is stable from AuthContext; intentionally excluded from deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, isM365Connected]);
}

View file

@ -174,7 +174,7 @@ export function ProfilePage() {
} }
}; };
// === Handler: Profil aus O365 anreichern === // === Handler: Profil aus O365 synchronisieren (überschreibt bestehende Werte) ===
const handleEnrichFromO365 = async () => { const handleEnrichFromO365 = async () => {
setEnrichMsg(''); setEnrichMsg('');
setEnrichError(''); setEnrichError('');
@ -184,43 +184,44 @@ export function ProfilePage() {
const result = await office365Api.getM365Profile(); const result = await office365Api.getM365Profile();
const p = result.data; const p = result.data;
const filled: string[] = []; const updated: string[] = [];
if (!firstName && p.givenName) { // Always overwrite with O365 values where O365 has data
if (p.givenName) {
setFirstName(p.givenName); setFirstName(p.givenName);
filled.push('Vorname'); updated.push('Vorname');
} }
if (!lastName && p.surname) { if (p.surname) {
setLastName(p.surname); setLastName(p.surname);
filled.push('Nachname'); updated.push('Nachname');
} }
if (!phone && p.businessPhones?.[0]) { if (p.businessPhones?.[0]) {
setPhone(p.businessPhones[0]); setPhone(p.businessPhones[0]);
filled.push('Telefon'); updated.push('Telefon');
} }
if (!mobile && p.mobilePhone) { if (p.mobilePhone) {
setMobile(p.mobilePhone); setMobile(p.mobilePhone);
filled.push('Mobil'); updated.push('Mobil');
} }
if (!city && p.city) { if (p.city) {
setCity(p.city); setCity(p.city);
filled.push('Ort'); updated.push('Ort');
} }
if (!street && p.streetAddress) { if (p.streetAddress) {
setStreet(p.streetAddress); setStreet(p.streetAddress);
filled.push('Straße'); updated.push('Straße');
} }
if (!postalCode && p.postalCode) { if (p.postalCode) {
setPostalCode(p.postalCode); setPostalCode(p.postalCode);
filled.push('PLZ'); updated.push('PLZ');
} }
if (filled.length > 0) { if (updated.length > 0) {
setEnrichMsg( setEnrichMsg(
`${filled.length} Felder übernommen: ${filled.join(', ')}. Bitte prüfen und speichern.`, `${updated.length} Felder aktualisiert: ${updated.join(', ')}. Bitte prüfen und speichern.`,
); );
} else { } else {
setEnrichMsg('Alle Felder sind bereits ausgefüllt — keine Änderungen.'); setEnrichMsg('Keine Profildaten in Microsoft 365 gefunden.');
} }
} catch { } catch {
setEnrichError('O365-Profil konnte nicht geladen werden.'); setEnrichError('O365-Profil konnte nicht geladen werden.');
@ -504,7 +505,7 @@ export function ProfilePage() {
</svg> </svg>
</span> </span>
<span className={styles.enrichLabel}> <span className={styles.enrichLabel}>
Fehlende Felder aus Microsoft 365 übernehmen Profil mit Microsoft 365 synchronisieren
</span> </span>
</div> </div>
<button <button

View file

@ -6,6 +6,7 @@ import { UserAvatar } from '../components/UserAvatar';
import api from '../api/client'; import api from '../api/client';
import { useTheme } from '../theme/ThemeContext'; import { useTheme } from '../theme/ThemeContext';
import { useCrmSettings } from '../crm/settings/CrmSettingsContext'; import { useCrmSettings } from '../crm/settings/CrmSettingsContext';
import { useO365ProfileSync } from '../hooks/useO365ProfileSync';
import styles from './AppLayout.module.css'; import styles from './AppLayout.module.css';
interface ExternalLink { interface ExternalLink {
@ -114,6 +115,9 @@ export function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { mode, setMode } = useTheme(); const { mode, setMode } = useTheme();
const { isModuleEnabled } = useCrmSettings(); const { isModuleEnabled } = useCrmSettings();
// Silently sync INSIGHT profile from O365 once per browser session
useO365ProfileSync();
const isAdmin = user?.role === 'PLATFORM_ADMIN' || user?.role === 'TENANT_ADMIN'; const isAdmin = user?.role === 'PLATFORM_ADMIN' || user?.role === 'TENANT_ADMIN';
const anyCrmModuleEnabled = const anyCrmModuleEnabled =
isModuleEnabled('contacts') || isModuleEnabled('contacts') ||