teams test auth bot
This commit is contained in:
parent
44d65992bf
commit
ae18912e1e
3 changed files with 452 additions and 2 deletions
|
|
@ -115,6 +115,31 @@ export interface VoiceOption {
|
|||
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
|
||||
export interface TeamsbotSSEEvent {
|
||||
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.
|
||||
* Returns the EventSource instance for the caller to manage.
|
||||
|
|
|
|||
|
|
@ -495,3 +495,247 @@
|
|||
justify-content: flex-end;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import * as teamsbotApi from '../../../api/teamsbotApi';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
|
||||
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption, AuthTestResults, AuthTestResult } from '../../../api/teamsbotApi';
|
||||
import { FaPlay, FaSpinner, FaFlask, FaImage } from 'react-icons/fa';
|
||||
import styles from './Teamsbot.module.css';
|
||||
|
||||
/** 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 [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 () => {
|
||||
if (!instanceId) return;
|
||||
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}>✓</span>
|
||||
: <span className={styles.testCross}>✗</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>;
|
||||
|
||||
return (
|
||||
|
|
@ -152,6 +199,127 @@ export const TeamsbotSettingsView: React.FC = () => {
|
|||
{error && <div className={styles.errorBanner}>{error}</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 */}
|
||||
<div className={styles.settingsSection}>
|
||||
<h4 className={styles.sectionTitle}>Bot-Identitaet</h4>
|
||||
|
|
|
|||
Loading…
Reference in a new issue