511 lines
20 KiB
TypeScript
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;
|