From 947bc6a62e4a2d2e86d1d227428bbcc3e7fcad78 Mon Sep 17 00:00:00 2001 From: Alex Yoo Date: Tue, 26 May 2026 17:25:33 +1000 Subject: [PATCH] fix: admin user management shows the same format of chat history and report --- frontend/src/api/admin.api.ts | 7 + .../components/ui/PerformanceReportView.tsx | 248 +++++++++++++++ .../src/pages/admin/AdminChatHistoryPage.tsx | 22 +- .../admin/AdminPerformanceReportPage.tsx | 87 +++++ .../src/pages/admin/LearnerDetailsPage.tsx | 30 +- .../pages/learner/PerformanceAnalysisPage.tsx | 301 ++---------------- frontend/src/router/AppRouter.tsx | 2 + frontend/src/router/routes.ts | 1 + frontend/src/schemas/api.schema.ts | 2 + 9 files changed, 406 insertions(+), 294 deletions(-) create mode 100644 frontend/src/components/ui/PerformanceReportView.tsx create mode 100644 frontend/src/pages/admin/AdminPerformanceReportPage.tsx diff --git a/frontend/src/api/admin.api.ts b/frontend/src/api/admin.api.ts index ef0d691..dd218fb 100644 --- a/frontend/src/api/admin.api.ts +++ b/frontend/src/api/admin.api.ts @@ -9,6 +9,7 @@ import { chatMessagesPageSchema, adminSessionListPageSchema, adminSessionSummarySchema, + sessionReportSchema, type AdminStats, type AdminUserListItem, type AdminUserById, @@ -18,6 +19,7 @@ import { type AdminSessionListPage, type AdminSessionSummary, type AdminAnalytics, + type SessionReport, adminAnalyticsSchema, } from '../schemas/api.schema'; import type { CreateUserPayload, UpdateUserPayload } from '../schemas/user.schema'; @@ -80,3 +82,8 @@ export async function getAdminSessionMessagesRequest(sessionUuid: string, cursor return parseResponse(chatMessagesPageSchema, response.data, 'getAdminSessionMessagesRequest'); } +export async function getAdminSessionReportRequest(sessionUuid: string): Promise { + const response = await api.get(`/admin/sessions/${sessionUuid}/report`); + return parseResponse(sessionReportSchema, response.data, 'getAdminSessionReportRequest'); +} + diff --git a/frontend/src/components/ui/PerformanceReportView.tsx b/frontend/src/components/ui/PerformanceReportView.tsx new file mode 100644 index 0000000..da5554e --- /dev/null +++ b/frontend/src/components/ui/PerformanceReportView.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { Download } from 'lucide-react'; +import type { SessionReport } from '../../schemas/api.schema'; +import SectionPanel from './SectionPanel'; +import InfoField from './InfoField'; +import { toScoreStatus, toQuestionType, barColor, scoreColor } from '../../pages/learner/performance/utils'; +import { TACTIC_LABELS, BCSM_STAGE_META, BCSM_SHORT_LABELS } from '../../pages/learner/performance/constants'; +import type { ScoreEntry, InvQuestion, BcsmStage, BcsmBreakdownEntry, ChipStatus } from '../../pages/learner/performance/types'; +import InvestigationSection from '../../pages/learner/performance/InvestigationSection'; +import NegotiationSection from '../../pages/learner/performance/NegotiationSection'; +import BcsmSection from '../../pages/learner/performance/BcsmSection'; + +export interface ReportHeaderField { + label: string; + value: string; +} + +interface Props { + report: SessionReport | undefined; + isLoading: boolean; + headerTitle: string; + headerFields: ReportHeaderField[]; + onBack: () => void; +} + +export default function PerformanceReportView({ report, isLoading, headerTitle, headerFields, onBack }: Props) { + const [open, setOpen] = useState({ s1: true, s2: true, s3: true }); + const toggle = (key: keyof typeof open) => setOpen(prev => ({ ...prev, [key]: !prev[key] })); + + const isReportReady = !!(report?.scores?.total && report?.verdict && report?.investigation && report?.bcsm); + + const encryptionProofGranted = !!(report?.investigation?.ai_feedback?.ENCRYPTION_PROOF); + const invScoreAdj = isReportReady ? report!.investigation.total_score + (encryptionProofGranted ? 10 : 0) : 0; + const invMaxAdj = isReportReady ? report!.investigation.max_score + 10 : 0; + const totalScoreAdj = isReportReady ? report!.scores.total.score + (encryptionProofGranted ? 10 : 0) : 0; + const totalMaxAdj = isReportReady ? report!.scores.total.max_score + 10 : 0; + + const scores: ScoreEntry[] = isReportReady + ? [ + { label: 'Total Score', score: totalScoreAdj, max: totalMaxAdj, status: toScoreStatus(totalScoreAdj, totalMaxAdj), highlight: true }, + { label: 'Investigation', score: invScoreAdj, max: invMaxAdj, status: toScoreStatus(invScoreAdj, invMaxAdj) }, + { label: 'Negotiation', score: report!.scores.negotiation.score, max: report!.scores.negotiation.max_score, status: toScoreStatus(report!.scores.negotiation.score, report!.scores.negotiation.max_score) }, + { label: 'BCSM', score: report!.scores.bcsm.score, max: report!.scores.bcsm.max_score, status: toScoreStatus(report!.scores.bcsm.score, report!.scores.bcsm.max_score) }, + ] + : []; + + const verdict = isReportReady + ? { + passed: report!.verdict.passed, + title: `${report!.verdict.passed ? 'Passed' : 'Failed'} — ${totalScoreAdj} / ${totalMaxAdj}`, + } + : null; + + const aiFeedback = isReportReady ? report!.investigation.ai_feedback : null; + const aiVerified = [ + aiFeedback?.exploitation_method ?? false, + aiFeedback?.exploited_system ?? false, + aiFeedback?.exploited_data ?? false, + ]; + + const investigation: InvQuestion[] = isReportReady + ? report!.investigation.questions.map((q, i) => ({ + label: `Q${i + 1} — ${q.question_text}`, + detail: q.is_correct + ? `Selected: ${q.selected_answer} ✓` + : `Selected: ${q.selected_answer ?? '—'} ✗ (Correct: ${q.correct_answer ?? '—'})`, + type: toQuestionType(q.question_type), + result: (q.is_correct ? 'pass' : 'fail') as 'pass' | 'fail', + points: q.points, + verified: aiVerified[i] ?? false, + correctAnswer: q.correct_answer, + })) + : []; + + const encryptionProofQ: InvQuestion | null = isReportReady ? { + label: `Q${report!.investigation.questions.length + 1} — Decryption Key for Proof`, + detail: aiFeedback?.ENCRYPTION_PROOF ? 'Requested ✓' : 'Not requested ✗', + type: 'AI', + result: (aiFeedback?.ENCRYPTION_PROOF ? 'pass' : 'fail') as 'pass' | 'fail', + points: aiFeedback?.ENCRYPTION_PROOF ? 10 : 0, + verified: aiFeedback?.ENCRYPTION_PROOF ?? false, + correctAnswer: 'Request decryption proof from threat actor', + } : null; + + const allInvestigation = encryptionProofQ ? [...investigation, encryptionProofQ] : investigation; + const mcQuestions = investigation.filter(q => q.type === 'MC'); + const tfQuestions = investigation.filter(q => q.type === 'T/F'); + + const bcsm: BcsmStage[] = isReportReady + ? report!.bcsm.blocks.map((block, idx) => { + const meta = BCSM_STAGE_META[block.key] ?? { title: block.key, subtitle: '' }; + const chips = Object.entries(block.tactics).map(([key, val]) => ({ + key, + label: TACTIC_LABELS[key] ?? key, + status: (val ? 'detected' : 'missed') as ChipStatus, + })); + return { + num: idx + 1, + status: toScoreStatus(block.score, block.max_score), + title: meta.title, + subtitle: meta.subtitle, + score: block.score, + max: block.max_score, + chips, + }; + }) + : []; + + const bcsmBreakdown: BcsmBreakdownEntry[] = isReportReady + ? report!.bcsm.blocks.map(block => ({ + label: BCSM_SHORT_LABELS[block.key] ?? block.key, + score: block.score, + status: toScoreStatus(block.score, block.max_score), + })) + : []; + + return ( +
+
+ +
+ + {/* Session Header */} + +

+ Session Report · Tactix AI +

+

{headerTitle}

+

+ Behavioral Change Stairway Model (BCSM) post-session evaluation +

+
+ {headerFields.map(field => ( + {field.value}} + /> + ))} +
+
+ } + > + <> + + + {isLoading && ( +
+
+

Loading report…

+
+ )} + + {!isLoading && !isReportReady && ( +
+

Report not available

+

+ This report is still being generated. Please check back later. +

+ +
+ )} + + {!isLoading && isReportReady && ( + <> +
+ {scores.map((s) => ( +
+

+ {s.label} +

+

+ {s.score} +

+

/ {s.max} pts

+
+
+
+
+ ))} +
+ + {verdict && ( +
+ +
+

{verdict.title}

+

+ {report?.open_ended_evaluation} +

+
+
+ )} + + toggle('s1')} + /> + + toggle('s2')} + /> + + toggle('s3')} + /> + + )} +
+ ); +} diff --git a/frontend/src/pages/admin/AdminChatHistoryPage.tsx b/frontend/src/pages/admin/AdminChatHistoryPage.tsx index 5d2db60..0d3a996 100644 --- a/frontend/src/pages/admin/AdminChatHistoryPage.tsx +++ b/frontend/src/pages/admin/AdminChatHistoryPage.tsx @@ -1,5 +1,6 @@ import { useNavigate, useParams, useLocation } from 'react-router-dom'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { FileText } from 'lucide-react'; import { useAuth } from '../../hooks/useAuth'; import { formatTimestamp, formatCurrency } from '../../utils/format.utils'; @@ -50,9 +51,28 @@ export default function AdminChatHistoryPage() { const handleBack = () => navigate(-1); + const handleViewReport = () => { + navigate(ROUTES.ADMIN.SESSION_REPORT.replace(':sessionId', sessionId!), { + state: { user, sessionTitle: summary?.title }, + }); + }; + const summaryHeader = summary ? ( -
+

{summary.title} — Performance Summary

+ {summary.report_status === 'completed' ? ( + + ) : ( + + {summary.report_status ?? 'pending'} + + )}
) : undefined; diff --git a/frontend/src/pages/admin/AdminPerformanceReportPage.tsx b/frontend/src/pages/admin/AdminPerformanceReportPage.tsx new file mode 100644 index 0000000..beb06f8 --- /dev/null +++ b/frontend/src/pages/admin/AdminPerformanceReportPage.tsx @@ -0,0 +1,87 @@ +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; + +import { useAuth } from '../../hooks/useAuth'; +import { ROUTES } from '../../router/routes'; +import { getAdminSessionReportRequest, getAdminSessionSummaryRequest } from '../../api/admin.api'; +import { formatTimestamp } from '../../utils/format.utils'; +import { formatDurationMins } from '../learner/performance/utils'; +import type { AdminUserListItem } from '../../schemas/api.schema'; + +import DashboardHeader from '../../components/ui/DashboardHeader'; +import PerformanceReportView from '../../components/ui/PerformanceReportView'; + +interface AdminReportPageState { + user?: AdminUserListItem; + sessionTitle?: string; +} + +export default function AdminPerformanceReportPage() { + const navigate = useNavigate(); + const { logout } = useAuth(); + const { sessionId } = useParams<{ sessionId: string }>(); + const { state: routeState } = useLocation(); + + const pageState = (routeState ?? {}) as AdminReportPageState; + const learner = pageState.user; + + const handleLogout = () => { logout(); navigate(ROUTES.LOGIN, { replace: true }); }; + + const { data: report, isLoading } = useQuery({ + queryKey: ['admin', 'session', sessionId, 'report'], + queryFn: () => getAdminSessionReportRequest(sessionId!), + enabled: !!sessionId, + }); + + const { data: summary } = useQuery({ + queryKey: ['admin', 'session', sessionId, 'summary'], + queryFn: () => getAdminSessionSummaryRequest(sessionId!), + enabled: !!sessionId, + }); + + const headerTitle = summary?.title ?? pageState.sessionTitle ?? '—'; + const summaryDuration = summary?.start_at && summary?.end_at ? summary.end_at - summary.start_at : null; + + const headerFields = [ + { label: 'Learner', value: learner?.name ?? '—' }, + { label: 'Completed', value: summary?.end_at ? formatTimestamp(summary.end_at) : '—' }, + { label: 'Duration', value: summaryDuration ? formatDurationMins(summaryDuration) : '—' }, + { label: 'Company', value: learner?.company ?? '—' }, + ]; + + return ( + <> + +
+
+ navigate(ROUTES.ADMIN.DASHBOARD)} + onBack={() => navigate(-1)} + onLogout={handleLogout} + /> +
+
+ {learner && ( +
+ Viewing report for {learner.email} +
+ )} + navigate(-1)} + /> +
+
+ + ); +} diff --git a/frontend/src/pages/admin/LearnerDetailsPage.tsx b/frontend/src/pages/admin/LearnerDetailsPage.tsx index ba66dbd..0a7c164 100644 --- a/frontend/src/pages/admin/LearnerDetailsPage.tsx +++ b/frontend/src/pages/admin/LearnerDetailsPage.tsx @@ -154,18 +154,24 @@ export default function LearnerDetailsPage() { View Chat - + {session.report_status === 'completed' ? ( + + ) : ( + + {session.report_status ?? 'pending'} + + )} } /> diff --git a/frontend/src/pages/learner/PerformanceAnalysisPage.tsx b/frontend/src/pages/learner/PerformanceAnalysisPage.tsx index 65603d1..e9751c3 100644 --- a/frontend/src/pages/learner/PerformanceAnalysisPage.tsx +++ b/frontend/src/pages/learner/PerformanceAnalysisPage.tsx @@ -1,6 +1,4 @@ -import { useState } from 'react'; import { useNavigate, useParams, useLocation } from 'react-router-dom'; -import { Download } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../../hooks/useAuth'; @@ -8,17 +6,10 @@ import { useScenario } from '../../hooks/useScenario'; import { ROUTES } from '../../router/routes'; import { getSessionReportRequest, getSessionSummaryRequest } from '../../api/learner.api'; import { formatTimestamp } from '../../utils/format.utils'; +import { formatDurationMins } from './performance/utils'; import DashboardHeader from '../../components/ui/DashboardHeader'; -import SectionPanel from '../../components/ui/SectionPanel'; -import InfoField from '../../components/ui/InfoField'; - -import { toScoreStatus, toQuestionType, barColor, scoreColor, formatDurationMins } from './performance/utils'; -import { TACTIC_LABELS, BCSM_STAGE_META, BCSM_SHORT_LABELS } from './performance/constants'; -import type { ScoreEntry, InvQuestion, BcsmStage, BcsmBreakdownEntry, ChipStatus } from './performance/types'; -import InvestigationSection from './performance/InvestigationSection'; -import NegotiationSection from './performance/NegotiationSection'; -import BcsmSection from './performance/BcsmSection'; +import PerformanceReportView from '../../components/ui/PerformanceReportView'; interface PerformancePageState { title?: string; @@ -37,9 +28,6 @@ export default function PerformanceAnalysisPage() { const pageState = (routeState ?? {}) as PerformancePageState; - const [open, setOpen] = useState({ s1: true, s2: true, s3: true }); - const toggle = (key: keyof typeof open) => setOpen(prev => ({ ...prev, [key]: !prev[key] })); - const handleLogout = () => { logout(); navigate(ROUTES.LOGIN, { replace: true }); }; const { data: report, isLoading } = useQuery({ @@ -54,105 +42,21 @@ export default function PerformanceAnalysisPage() { enabled: !!sessionId, }); - const headerTitle = pageState.title ?? '—'; - const headerCompleted = pageState.end_at ? formatTimestamp(pageState.end_at) : '—'; + const headerTitle = pageState.title ?? summary?.title ?? '—'; const summaryDuration = summary?.start_at && summary?.end_at ? summary.end_at - summary.start_at : null; - const headerDuration = summaryDuration ? formatDurationMins(summaryDuration) : '—'; const resolvedScenario = selectedScenario ?? scenarios.find(s => s.title === headerTitle) ?? null; - const headerThreatActor = pageState.threat_actor ?? resolvedScenario?.threat_actor ?? '—'; + const threatActor = pageState.threat_actor ?? resolvedScenario?.threat_actor ?? '—'; const rawScenarioUuid = pageState.scenario_uuid ?? resolvedScenario?.uuid; - const headerScenarioId = rawScenarioUuid ? rawScenarioUuid.slice(0, 8).toUpperCase() : '—'; - - // ── Data transforms ──────────────────────────────────────────────────────── - - const isReportReady = !!(report?.scores?.total && report?.verdict && report?.investigation && report?.bcsm); - - const encryptionProofGranted = !!(report?.investigation?.ai_feedback?.ENCRYPTION_PROOF); - const invScoreAdj = isReportReady ? report!.investigation.total_score + (encryptionProofGranted ? 10 : 0) : 0; - const invMaxAdj = isReportReady ? report!.investigation.max_score + 10 : 0; - const totalScoreAdj = isReportReady ? report!.scores.total.score + (encryptionProofGranted ? 10 : 0) : 0; - const totalMaxAdj = isReportReady ? report!.scores.total.max_score + 10 : 0; - - const scores: ScoreEntry[] = isReportReady - ? [ - { label: 'Total Score', score: totalScoreAdj, max: totalMaxAdj, status: toScoreStatus(totalScoreAdj, totalMaxAdj), highlight: true }, - { label: 'Investigation', score: invScoreAdj, max: invMaxAdj, status: toScoreStatus(invScoreAdj, invMaxAdj) }, - { label: 'Negotiation', score: report!.scores.negotiation.score, max: report!.scores.negotiation.max_score, status: toScoreStatus(report!.scores.negotiation.score, report!.scores.negotiation.max_score) }, - { label: 'BCSM', score: report!.scores.bcsm.score, max: report!.scores.bcsm.max_score, status: toScoreStatus(report!.scores.bcsm.score, report!.scores.bcsm.max_score) }, - ] - : []; - - const verdict = isReportReady - ? { - passed: report!.verdict.passed, - title: `${report!.verdict.passed ? 'Passed' : 'Failed'} — ${totalScoreAdj} / ${totalMaxAdj}`, - description: `Pass threshold: ${report!.verdict.pass_threshold} points.`, - } - : null; - - const aiFeedback = isReportReady ? report!.investigation.ai_feedback : null; - const aiVerified = [ - aiFeedback?.exploitation_method ?? false, - aiFeedback?.exploited_system ?? false, - aiFeedback?.exploited_data ?? false, + const scenarioId = rawScenarioUuid ? rawScenarioUuid.slice(0, 8).toUpperCase() : '—'; + + const headerFields = [ + { label: 'Learner', value: user ? `${user.first_name} ${user.last_name}` : '—' }, + { label: 'Completed', value: pageState.end_at ? formatTimestamp(pageState.end_at) : summary?.end_at ? formatTimestamp(summary.end_at) : '—' }, + { label: 'Duration', value: summaryDuration ? formatDurationMins(summaryDuration) : pageState.duration ? formatDurationMins(pageState.duration) : '—' }, + { label: 'Threat Actor', value: threatActor }, + { label: 'Scenario ID', value: scenarioId }, ]; - const investigation: InvQuestion[] = isReportReady - ? report!.investigation.questions.map((q, i) => ({ - label: `Q${i + 1} — ${q.question_text}`, - detail: q.is_correct - ? `Selected: ${q.selected_answer} ✓` - : `Selected: ${q.selected_answer ?? '—'} ✗ (Correct: ${q.correct_answer ?? '—'})`, - type: toQuestionType(q.question_type), - result: (q.is_correct ? 'pass' : 'fail') as 'pass' | 'fail', - points: q.points, - verified: aiVerified[i] ?? false, - correctAnswer: q.correct_answer, - })) - : []; - - const encryptionProofQ: InvQuestion | null = isReportReady ? { - label: `Q${report!.investigation.questions.length + 1} — Decryption Key for Proof`, - detail: aiFeedback?.ENCRYPTION_PROOF ? 'Requested ✓' : 'Not requested ✗', - type: 'AI', - result: (aiFeedback?.ENCRYPTION_PROOF ? 'pass' : 'fail') as 'pass' | 'fail', - points: aiFeedback?.ENCRYPTION_PROOF ? 10 : 0, - verified: aiFeedback?.ENCRYPTION_PROOF ?? false, - correctAnswer: 'Request decryption proof from threat actor', - } : null; - - const allInvestigation = encryptionProofQ ? [...investigation, encryptionProofQ] : investigation; - const mcQuestions = investigation.filter(q => q.type === 'MC'); - const tfQuestions = investigation.filter(q => q.type === 'T/F'); - - const bcsm: BcsmStage[] = isReportReady - ? report!.bcsm.blocks.map((block, idx) => { - const meta = BCSM_STAGE_META[block.key] ?? { title: block.key, subtitle: '' }; - const chips = Object.entries(block.tactics).map(([key, val]) => ({ - key, - label: TACTIC_LABELS[key] ?? key, - status: (val ? 'detected' : 'missed') as ChipStatus, - })); - return { - num: idx + 1, - status: toScoreStatus(block.score, block.max_score), - title: meta.title, - subtitle: meta.subtitle, - score: block.score, - max: block.max_score, - chips, - }; - }) - : []; - - const bcsmBreakdown: BcsmBreakdownEntry[] = isReportReady - ? report!.bcsm.blocks.map(block => ({ - label: BCSM_SHORT_LABELS[block.key] ?? block.key, - score: block.score, - status: toScoreStatus(block.score, block.max_score), - })) - : []; - return ( <>