teams test auth bot

This commit is contained in:
ValueOn AG 2026-02-16 21:37:39 +01:00
parent 44d65992bf
commit ae18912e1e
3 changed files with 452 additions and 2 deletions

View file

@ -115,6 +115,31 @@ export interface VoiceOption {
naturalSampleRateHertz: number; naturalSampleRateHertz: number;
} }
// Auth Detection Test Types
export interface AuthTestResult {
variantId: string;
variantName: string;
success: boolean;
pageType: 'v2' | 'lightMeetings' | 'error' | 'unknown';
finalUrl: string;
hasSignInLink: boolean;
hasNameInput: boolean;
hasJoinButton: boolean;
authAttempted: boolean;
authSuccess: boolean | null;
screenshot?: string;
durationMs: number;
error?: string;
detectedSignals: string[];
}
export interface AuthTestResults {
meetingUrl: string;
timestamp: string;
variants: AuthTestResult[];
recommendation: string;
}
// SSE Event Types // SSE Event Types
export interface TeamsbotSSEEvent { export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState'; type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState';
@ -281,6 +306,19 @@ export async function fetchVoices(languageCode: string): Promise<VoiceOption[]>
} }
} }
/**
* Run auth detection tests against a Teams meeting URL.
* Tests 5 browser configuration variants to determine which ones
* receive the /v2/ (authenticated) vs light-meetings (anonymous) page.
* Does NOT join the meeting.
*/
export async function testAuth(instanceId: string, meetingUrl: string): Promise<AuthTestResults> {
const response = await api.post(`/api/teamsbot/${instanceId}/test-auth`, { meetingUrl }, {
timeout: 300000, // 5 minutes — tests run sequentially
});
return response.data;
}
/** /**
* Create an SSE EventSource for live session streaming. * Create an SSE EventSource for live session streaming.
* Returns the EventSource instance for the caller to manage. * Returns the EventSource instance for the caller to manage.

View file

@ -495,3 +495,247 @@
justify-content: flex-end; justify-content: flex-end;
padding-top: 1rem; padding-top: 1rem;
} }
/* ============================================================================
Auth Detection Test Section
============================================================================ */
.testSection {
margin-bottom: 2rem;
padding: 1.25rem;
border: 1px solid var(--primary-color, #4A90D9);
border-radius: 8px;
background: rgba(74, 144, 217, 0.04);
}
.testSectionHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.testSectionTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--primary-color, #4A90D9);
}
.testInputRow {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.testInputRow .input {
flex: 1;
}
.testButton {
padding: 0.5rem 1.25rem;
background: var(--primary-color, #4A90D9);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.testButton:hover {
background: var(--primary-hover, #3A7BC8);
}
.testButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.testProgress {
font-size: 0.85rem;
color: var(--text-secondary, #666);
padding: 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.testResultsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.75rem;
}
.testResultsTable th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 2px solid var(--border-color, #e0e0e0);
font-weight: 600;
font-size: 0.8rem;
color: var(--text-secondary, #666);
white-space: nowrap;
}
.testResultsTable td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
vertical-align: middle;
}
.testResultsTable tr:last-child td {
border-bottom: none;
}
.testBadgeV2 {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(74, 217, 154, 0.15);
color: #2D8E5C;
}
.testBadgeLightMeetings {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(217, 168, 74, 0.15);
color: #B8860B;
}
.testBadgeError {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(217, 74, 74, 0.15);
color: #D94A4A;
}
.testBadgeUnknown {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(128, 128, 128, 0.15);
color: #666;
}
.testCheckmark {
color: #2D8E5C;
font-weight: 600;
}
.testCross {
color: #D94A4A;
font-weight: 600;
}
.testDash {
color: var(--text-tertiary, #999);
}
.testRecommendation {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(74, 144, 217, 0.08);
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-primary, #333);
border-left: 3px solid var(--primary-color, #4A90D9);
}
.testScreenshotButton {
padding: 0.15rem 0.5rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--surface-color, #fff);
color: var(--primary-color, #4A90D9);
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.testScreenshotButton:hover {
background: var(--surface-alt, #f5f5f5);
}
.testScreenshotOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.testScreenshotImage {
max-width: 90vw;
max-height: 85vh;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.testScreenshotCaption {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: 0.9rem;
background: rgba(0, 0, 0, 0.6);
padding: 0.5rem 1rem;
border-radius: 6px;
}
.testErrorText {
font-size: 0.75rem;
color: var(--danger-color, #D94A4A);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.testVariantName {
font-weight: 500;
}
.testDuration {
font-size: 0.8rem;
color: var(--text-tertiary, #999);
white-space: nowrap;
}
.testSignals {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
max-width: 250px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi'; import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, AuthTestResults, AuthTestResult } from '../../../api/teamsbotApi';
import { FaPlay, FaSpinner } from 'react-icons/fa'; import { FaPlay, FaSpinner, FaFlask, FaImage } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';
/** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */ /** Format voice name for display: "de-DE-Wavenet-A" -> "Wavenet A" + gender */
@ -37,6 +37,13 @@ export const TeamsbotSettingsView: React.FC = () => {
const [voices, setVoices] = useState<VoiceOption[]>([]); const [voices, setVoices] = useState<VoiceOption[]>([]);
const [loadingVoices, setLoadingVoices] = useState(false); const [loadingVoices, setLoadingVoices] = useState(false);
// Auth detection test state
const [testMeetingUrl, setTestMeetingUrl] = useState('');
const [testRunning, setTestRunning] = useState(false);
const [testResults, setTestResults] = useState<AuthTestResults | null>(null);
const [testError, setTestError] = useState<string | null>(null);
const [screenshotPreview, setScreenshotPreview] = useState<{ src: string; caption: string } | null>(null);
const _loadConfig = useCallback(async () => { const _loadConfig = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
try { try {
@ -142,6 +149,46 @@ export const TeamsbotSettingsView: React.FC = () => {
} }
}; };
const _handleRunAuthTest = async () => {
if (!instanceId || !testMeetingUrl.trim()) return;
setTestRunning(true);
setTestResults(null);
setTestError(null);
try {
const results = await teamsbotApi.testAuth(instanceId, testMeetingUrl.trim());
setTestResults(results);
} catch (err: any) {
setTestError(err.message || 'Auth-Test fehlgeschlagen');
} finally {
setTestRunning(false);
}
};
const _getPageTypeBadge = (result: AuthTestResult) => {
switch (result.pageType) {
case 'v2':
return <span className={styles.testBadgeV2}>/v2/</span>;
case 'lightMeetings':
return <span className={styles.testBadgeLightMeetings}>light-meetings</span>;
case 'error':
return <span className={styles.testBadgeError}>Fehler</span>;
default:
return <span className={styles.testBadgeUnknown}>Unbekannt</span>;
}
};
const _getCheckmark = (value: boolean) => {
return value
? <span className={styles.testCheckmark}>&#10003;</span>
: <span className={styles.testCross}>&#10007;</span>;
};
const _formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>; if (loading) return <div className={styles.loading}>Lade Konfiguration...</div>;
return ( return (
@ -152,6 +199,127 @@ export const TeamsbotSettingsView: React.FC = () => {
{error && <div className={styles.errorBanner}>{error}</div>} {error && <div className={styles.errorBanner}>{error}</div>}
{successMsg && <div className={styles.successBanner}>{successMsg}</div>} {successMsg && <div className={styles.successBanner}>{successMsg}</div>}
{/* Auth Detection Test */}
<div className={styles.testSection}>
<div className={styles.testSectionHeader}>
<FaFlask />
<h4 className={styles.testSectionTitle}>Auth-Erkennung testen</h4>
</div>
<span className={styles.hint}>
Testet 5 Browser-Konfigurationen gegen einen aktiven Teams-Call.
Prueft ob Teams /v2/ (Auth moeglich) oder light-meetings (anonym erzwungen) liefert.
Der Bot tritt dem Meeting NICHT bei.
</span>
<div className={styles.testInputRow} style={{ marginTop: '0.75rem' }}>
<input
type="url"
className={styles.input}
value={testMeetingUrl}
onChange={(e) => setTestMeetingUrl(e.target.value)}
placeholder="https://teams.microsoft.com/meet/..."
disabled={testRunning}
/>
<button
className={styles.testButton}
onClick={_handleRunAuthTest}
disabled={testRunning || !testMeetingUrl.trim()}
>
{testRunning ? <FaSpinner className={styles.spinner} /> : <FaFlask />}
{testRunning ? 'Teste...' : 'Testen'}
</button>
</div>
{testRunning && (
<div className={styles.testProgress}>
<FaSpinner className={styles.spinner} />
5 Varianten werden sequentiell getestet (~2-3 Minuten)...
</div>
)}
{testError && <div className={styles.errorBanner}>{testError}</div>}
{testResults && (
<>
<table className={styles.testResultsTable}>
<thead>
<tr>
<th>Variante</th>
<th>Seite</th>
<th>Sign-in</th>
<th>Name-Input</th>
<th>Join</th>
<th>Auth</th>
<th>Dauer</th>
<th></th>
</tr>
</thead>
<tbody>
{testResults.variants.map((result) => (
<tr key={result.variantId}>
<td>
<span className={styles.testVariantName}>{result.variantName}</span>
{result.error && (
<div className={styles.testErrorText} title={result.error}>
{result.error}
</div>
)}
</td>
<td>{_getPageTypeBadge(result)}</td>
<td>{result.success ? _getCheckmark(result.hasSignInLink) : <span className={styles.testDash}></span>}</td>
<td>{result.success ? _getCheckmark(result.hasNameInput) : <span className={styles.testDash}></span>}</td>
<td>{result.success ? _getCheckmark(result.hasJoinButton) : <span className={styles.testDash}></span>}</td>
<td>
{result.authAttempted
? (result.authSuccess ? <span className={styles.testCheckmark}>Erfolgreich</span> : <span className={styles.testCross}>Fehlgeschlagen</span>)
: <span className={styles.testDash}></span>
}
</td>
<td><span className={styles.testDuration}>{_formatDuration(result.durationMs)}</span></td>
<td>
{result.screenshot && (
<button
className={styles.testScreenshotButton}
onClick={() => setScreenshotPreview({
src: `data:image/jpeg;base64,${result.screenshot}`,
caption: result.variantName,
})}
>
<FaImage /> Bild
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{testResults.recommendation && (
<div className={styles.testRecommendation}>
<strong>Empfehlung:</strong> {testResults.recommendation}
</div>
)}
</>
)}
</div>
{/* Screenshot Overlay */}
{screenshotPreview && (
<div
className={styles.testScreenshotOverlay}
onClick={() => setScreenshotPreview(null)}
>
<img
src={screenshotPreview.src}
alt={screenshotPreview.caption}
className={styles.testScreenshotImage}
/>
<div className={styles.testScreenshotCaption}>
{screenshotPreview.caption} Klicken zum Schliessen
</div>
</div>
)}
{/* Bot Identity */} {/* Bot Identity */}
<div className={styles.settingsSection}> <div className={styles.settingsSection}>
<h4 className={styles.sectionTitle}>Bot-Identitaet</h4> <h4 className={styles.sectionTitle}>Bot-Identitaet</h4>