frontend_nyla/src/pages/views/teamsbot/TeamsbotDashboardView.tsx
2026-05-06 23:28:15 +02:00

511 lines
20 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotSession, StartSessionRequest, TeamsbotJoinMode, UserAccountStatus, MfaChallengeEvent } from '../../../api/teamsbotApi';
import { getUserDataCache } from '../../../utils/userCache';
import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/**
* TeamsbotDashboardView - Overview of all Teams Bot sessions.
* Allows starting new sessions and viewing active/past sessions.
* Supports "Mein Account" login with saved credentials and MFA relay.
*/
export const TeamsbotDashboardView: React.FC = () => {
const { t } = useLanguage();
const { instance, mandateId, featureCode } = useCurrentInstance();
const instanceId = instance?.id || '';
const navigate = useNavigate();
const cachedUser = getUserDataCache();
const _isSysAdmin = cachedUser?.isSysAdmin === true;
const [sessions, setSessions] = useState<TeamsbotSession[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New session form
const [meetingLink, setMeetingLink] = useState('');
const [botName, setBotName] = useState('');
const [joinMode, setJoinMode] = useState<TeamsbotJoinMode>('anonymous');
const [sessionContext, setSessionContext] = useState('');
const [isStarting, setIsStarting] = useState(false);
// User Account (Mein Account) state
const [userAccount, setUserAccount] = useState<UserAccountStatus | null>(null);
const [showCredentialForm, setShowCredentialForm] = useState(false);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [savingCredentials, setSavingCredentials] = useState(false);
// MFA state
const [mfaChallenge, setMfaChallenge] = useState<MfaChallengeEvent | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaSessionId, setMfaSessionId] = useState<string | null>(null);
const [mfaWaitingPush, setMfaWaitingPush] = useState(false);
const sseRef = useRef<EventSource | null>(null);
const _loadSessions = useCallback(async () => {
if (!instanceId) return;
try {
setLoading(true);
const result = await teamsbotApi.listSessions(instanceId);
setSessions(result.sessions || []);
setError(null);
} catch (err: any) {
setError(err.message || t('Fehler beim Laden der Sitzungen'));
} finally {
setLoading(false);
}
}, [instanceId, t]);
useEffect(() => {
_loadSessions();
}, [_loadSessions]);
// Load user account status when joinMode changes to userAccount
useEffect(() => {
if (joinMode === 'userAccount' && instanceId) {
teamsbotApi.getUserAccount(instanceId).then(setUserAccount).catch(() => setUserAccount(null));
}
}, [joinMode, instanceId]);
// Adaptive polling: 3s with active sessions, 30s otherwise
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
const interval = hasActiveSessions ? 3000 : 30000;
if (instanceId) {
pollRef.current = setInterval(() => {
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
}, interval);
}
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [sessions, instanceId]);
// Cleanup SSE on unmount
useEffect(() => {
return () => { sseRef.current?.close(); };
}, []);
const _startMfaListener = useCallback((sessionId: string) => {
sseRef.current?.close();
const es = teamsbotApi.createSessionStream(instanceId, sessionId);
sseRef.current = es;
setMfaSessionId(sessionId);
es.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed.type === 'mfaChallenge') {
const data = parsed.data as MfaChallengeEvent;
if (data.mfaType === 'timeout') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setError(t('MFA-Zeitlimit überschritten, bitte erneut versuchen'));
} else {
setMfaChallenge(data);
setMfaCode('');
setMfaWaitingPush(data.mfaType === 'pushApproval' || data.mfaType === 'numberMatch');
}
} else if (parsed.type === 'mfaResolved') {
setMfaChallenge(null);
setMfaWaitingPush(false);
setMfaSessionId(null);
es.close();
sseRef.current = null;
_loadSessions();
}
} catch { /* ignore parse errors */ }
};
}, [instanceId, _loadSessions, t]);
const _handleStartSession = async () => {
if (!meetingLink.trim()) return;
// For userAccount: need credentials (saved or entered now)
if (joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && !credEmail) {
setShowCredentialForm(true);
return;
}
// userAccount with new/unsaved credentials: save to DB before starting
const needsSave = joinMode === 'userAccount' && !userAccount?.hasSavedCredentials && credEmail && credPassword;
const needsUpdate = joinMode === 'userAccount' && showCredentialForm && credEmail && credPassword;
if (needsSave || needsUpdate) {
try {
setSavingCredentials(true);
await teamsbotApi.saveUserAccount(instanceId, credEmail, credPassword);
setUserAccount({ hasSavedCredentials: true, email: credEmail });
} catch (err: any) {
setError(err.message || t('Fehler beim Speichern der Zugangsdaten'));
setSavingCredentials(false);
return;
} finally {
setSavingCredentials(false);
}
setShowCredentialForm(false);
}
setIsStarting(true);
setError(null);
try {
const request: StartSessionRequest = {
meetingLink: meetingLink.trim(),
botName: botName.trim() || undefined,
joinMode: joinMode,
sessionContext: sessionContext.trim() || undefined,
};
const result = await teamsbotApi.startSession(instanceId, request);
const newSessionId = result.session?.id;
setMeetingLink('');
setBotName('');
// Start SSE listener for MFA events if userAccount mode
if (joinMode === 'userAccount' && newSessionId) {
_startMfaListener(newSessionId);
}
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Starten der Sitzung'));
} finally {
setIsStarting(false);
}
};
const _handleSubmitMfaCode = async () => {
if (!mfaSessionId || !instanceId) return;
const needsCode = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
try {
await teamsbotApi.submitMfaCode(
instanceId,
mfaSessionId,
needsCode ? mfaCode : '',
needsCode ? 'code' : 'confirmed',
);
if (!needsCode) {
setMfaWaitingPush(true);
}
} catch (err: any) {
setError(err.message || t('Fehler beim Senden des MFA-Codes'));
}
};
const _handleDeleteUserAccount = async () => {
try {
await teamsbotApi.deleteUserAccount(instanceId);
setUserAccount({ hasSavedCredentials: false });
setCredEmail('');
setCredPassword('');
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Zugangsdaten'));
}
};
const _handleStopSession = async (sessionId: string) => {
try {
await teamsbotApi.stopSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Stoppen der Sitzung'));
}
};
const _handleDeleteSession = async (sessionId: string) => {
try {
await teamsbotApi.deleteSession(instanceId, sessionId);
await _loadSessions();
} catch (err: any) {
setError(err.message || t('Fehler beim Löschen der Sitzung'));
}
};
const _getStatusBadgeClass = (status: string) => {
switch (status) {
case 'active': return styles.statusActive;
case 'joining': return styles.statusJoining;
case 'pending': return styles.statusPending;
case 'ended': return styles.statusEnded;
case 'error': return styles.statusError;
case 'leaving': return styles.statusLeaving;
default: return '';
}
};
const _getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
pending: 'Wartend',
joining: 'Beitritt...',
active: 'Aktiv',
leaving: 'Verlassen...',
ended: 'Beendet',
error: 'Fehler',
};
return labels[status] || status;
};
const activeSessions = sessions.filter(s => ['pending', 'joining', 'active'].includes(s.status));
const pastSessions = sessions.filter(s => ['ended', 'error', 'leaving'].includes(s.status));
const _needsCodeInput = mfaChallenge?.mfaType === 'totpCode' || mfaChallenge?.mfaType === 'smsCode';
return (
<div className={styles.dashboardContainer}>
{/* MFA Challenge Dialog */}
{mfaChallenge && (
<div className={styles.mfaOverlay}>
<div className={styles.mfaDialog}>
<div className={styles.mfaTitle}>Multi-Faktor-Authentifizierung</div>
{mfaChallenge.displayNumber && (
<div className={styles.mfaNumber}>{mfaChallenge.displayNumber}</div>
)}
<div className={styles.mfaPrompt}>{mfaChallenge.prompt}</div>
{_needsCodeInput ? (
<>
<input
type="text"
className={styles.mfaCodeInput}
placeholder={t('Code eingeben')}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
autoFocus
onKeyDown={(e) => e.key === 'Enter' && _handleSubmitMfaCode()}
/>
<button className={styles.startButton} onClick={_handleSubmitMfaCode} disabled={!mfaCode.trim()}>
Bestaetigen
</button>
</>
) : mfaWaitingPush ? (
<>
<div className={styles.mfaSpinner} />
<p style={{ fontSize: '0.85rem', color: '#888' }}>
Warte auf Bestaetigung in der Authenticator App...
</p>
</>
) : (
<button className={styles.startButton} onClick={_handleSubmitMfaCode}>
Ich habe bestaetigt
</button>
)}
</div>
</div>
)}
{/* Start New Session Card */}
<div className={styles.startSessionCard}>
<h3 className={styles.cardTitle}>{t('Neue Botsitzung starten')}</h3>
<p className={styles.cardDescription}>
Fuege den Teams Meeting-Link ein, um den AI-Bot in ein Meeting einzuschleusen.
</p>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Teams-Meetinglink')}</label>
<input
type="url"
className={styles.input}
placeholder="https://teams.microsoft.com/l/meetup-join/..."
value={meetingLink}
onChange={(e) => setMeetingLink(e.target.value)}
disabled={isStarting}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>Join-Modus</label>
<select
className={styles.select || styles.input}
value={joinMode}
onChange={(e) => setJoinMode(e.target.value as TeamsbotJoinMode)}
disabled={isStarting}
>
{_isSysAdmin && <option value="systemBot">{t('Systembot authentifiziert')}</option>}
<option value="anonymous">{t('Anonymer Gast')}</option>
<option value="userAccount">{t('Mein Account')}</option>
</select>
</div>
{/* User Account: saved credentials info or credential form */}
{joinMode === 'userAccount' && (
<div className={styles.credentialsCard}>
{userAccount?.hasSavedCredentials && !showCredentialForm ? (
<div className={styles.credentialsInfo}>
<span>
Gespeichert: <span className={styles.credentialsEmail}>{userAccount.email}</span>
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className={styles.viewButton}
onClick={() => { setShowCredentialForm(true); setCredEmail(userAccount.email || ''); }}
>
Aendern
</button>
<button className={styles.deleteButton} onClick={_handleDeleteUserAccount}>
Entfernen
</button>
</div>
</div>
) : (
<>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Microsoft E-Mail')}</label>
<input
type="email"
className={styles.input}
placeholder="name@example.com"
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
disabled={savingCredentials}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Passwort')}</label>
<input
type="password"
className={styles.input}
placeholder="Microsoft-Passwort"
value={credPassword}
onChange={(e) => setCredPassword(e.target.value)}
disabled={savingCredentials}
/>
</div>
<span style={{ fontSize: '12px', color: '#888', marginTop: '4px', display: 'block' }}>
Zugangsdaten werden verschluesselt gespeichert.
</span>
{userAccount?.hasSavedCredentials && (
<button
className={styles.viewButton}
style={{ marginTop: '0.5rem' }}
onClick={() => setShowCredentialForm(false)}
>
Abbrechen
</button>
)}
</>
)}
</div>
)}
<div className={styles.formGroup}>
<label className={styles.label}>{t('Botname (optional)')}</label>
<input
type="text"
className={styles.input}
placeholder={t('KI-Assistent')}
value={botName}
onChange={(e) => setBotName(e.target.value)}
disabled={isStarting}
/>
</div>
<div className={styles.formGroup}>
<label className={styles.label}>{t('Sitzungskontext (optional)')}</label>
<textarea
className={styles.textarea || styles.input}
placeholder={t('Agenda, Hintergrundinfos, Dokumente oder …')}
value={sessionContext}
onChange={(e) => setSessionContext(e.target.value)}
disabled={isStarting}
rows={4}
style={{ resize: 'vertical', minHeight: '80px' }}
/>
<span style={{ fontSize: '12px', color: '#888' }}>
Kontext den der Bot waehrend der Sitzung nutzen kann (z.B. Meeting-Agenda, Projektinfos).
{sessionContext.length > 0 && ` (${sessionContext.length} Zeichen)`}
</span>
</div>
<button
className={styles.startButton}
onClick={_handleStartSession}
disabled={isStarting || !meetingLink.trim() || (joinMode === 'userAccount' && showCredentialForm && (!credEmail || !credPassword))}
>
{isStarting ? t('Wird gestartet') : t('Bot ins Meeting senden')}
</button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
{/* Active Sessions */}
{activeSessions.length > 0 && (
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>{t('Aktive Sitzungen')}</h3>
<div className={styles.sessionList}>
{activeSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}>
<div className={styles.sessionHeader}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.sessionMeta}>
<span>{session.transcriptSegmentCount} Segmente</span>
<span>{session.botResponseCount} Antworten</span>
{session.startedAt && <span>Seit: {new Date(session.startedAt).toLocaleTimeString('de-CH')}</span>}
</div>
<div className={styles.sessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>{t('Live ansehen')}</button>
{session.status === 'active' && (
<button className={styles.stopButton} onClick={() => _handleStopSession(session.id)}>
Stoppen
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Past Sessions */}
<div className={styles.sectionContainer}>
<h3 className={styles.sectionTitle}>
{loading ? 'Lade Sitzungen...' : `Vergangene Sitzungen (${pastSessions.length})`}
</h3>
{pastSessions.length === 0 && !loading && (
<p className={styles.emptyState}>{t('Noch keine vergangenen Sitzungen')}</p>
)}
<div className={styles.sessionList}>
{pastSessions.map((session) => (
<div key={session.id} className={styles.sessionCard}>
<div className={styles.sessionHeader}>
<span className={styles.sessionBotName}>{session.botName}</span>
<span className={`${styles.statusBadge} ${_getStatusBadgeClass(session.status)}`}>
{_getStatusLabel(session.status)}
</span>
</div>
<div className={styles.sessionMeta}>
<span>{session.transcriptSegmentCount} Segmente</span>
<span>{session.botResponseCount} Antworten</span>
{session.startedAt && <span>{new Date(session.startedAt).toLocaleDateString('de-CH')}</span>}
</div>
{session.summary && (
<div className={styles.sessionSummary}>{session.summary.substring(0, 200)}...</div>
)}
{session.errorMessage && (
<div className={styles.sessionError}>{session.errorMessage}</div>
)}
<div className={styles.sessionActions}>
<button className={styles.viewButton} onClick={() => navigate(`/mandates/${mandateId}/${featureCode}/${instanceId}/sessions?sessionId=${session.id}`)}>Details</button>
<button className={styles.deleteButton} onClick={() => _handleDeleteSession(session.id)}>
Loeschen
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default TeamsbotDashboardView;