feat(disputed): Confirm My Validation button + discussion request modal
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend: POST /tests/{id}/request-discussion
  - Only callable by the lead whose vote is 'approved' in a disputed test
  - Sends notification to the rejecting lead: 'Lead X confirms their
    approval and wants to discuss your rejection'
  - Logs the action in audit trail

Frontend:
- 'Confirm My Validation' button (amber outline) alongside 'Change to Rejected'
- Opens a modal showing:
    * Explanation: both leads must agree to finalise
    * Other lead's rejection reason/notes
    * What happens next (stays disputed, notification sent, either can change)
- 'Send Discussion Request' → calls the new endpoint → shows success state:
    'Lead username has been notified...'
- Instruction to reach out via team channels to resolve offline

Flow summary for disputed tests:
  Approving lead sees 2 options:
    a) 'Confirm My Validation' → modal → send request → other lead notified
    b) 'Change to Rejected' → validation modal → both agree to reject → rejected
This commit is contained in:
kitos
2026-06-03 12:48:08 +02:00
parent 46ff79e695
commit 4e20bfa835
3 changed files with 233 additions and 8 deletions

View File

@@ -308,6 +308,16 @@ export interface RTImportResult {
engagement: string;
}
/** Confirm approval in a disputed test and notify the rejecting lead to discuss. */
export async function requestDiscussion(testId: string): Promise<{
status: string;
message: string;
rejector_username: string;
}> {
const { data } = await client.post(`/tests/${testId}/request-discussion`);
return data;
}
/** Import results from a real Red Team engagement. */
export async function importRT(payload: RTImportPayload): Promise<RTImportResult> {
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import {
FlaskConical,
Play,
@@ -10,7 +11,11 @@ import {
ShieldCheck,
AlertTriangle,
MessageSquare,
X,
UserCheck,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { requestDiscussion } from "../../api/tests";
import type { Test, TestState, User } from "../../types/models";
import LiveTimer from "./LiveTimer";
@@ -81,6 +86,13 @@ export default function TestDetailHeader({
}: TestDetailHeaderProps) {
const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state];
const [showDiscussModal, setShowDiscussModal] = useState(false);
const [discussResult, setDiscussResult] = useState<string | null>(null);
const discussMutation = useMutation({
mutationFn: () => requestDiscussion(test.id),
onSuccess: (data) => setDiscussResult(data.rejector_username),
});
const formatDate = (d: string | null) => {
if (!d) return null;
@@ -227,14 +239,24 @@ export default function TestDetailHeader({
const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null;
if (side || role === "admin") {
buttons.push(
<button
key="change-vote"
onClick={() => onOpenValidateModal(side ?? (test.red_validation_status === "approved" ? "red" : "blue"))}
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 transition-colors"
>
<XCircle className="h-4 w-4" />
Change Vote to Rejected
</button>,
<div key="disputed-actions" className="flex items-center gap-2">
{/* Confirm keeping approval → open discussion modal */}
<button
onClick={() => { setShowDiscussModal(true); setDiscussResult(null); }}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<UserCheck className="h-4 w-4" />
Confirm My Validation
</button>
{/* Change vote to rejected */}
<button
onClick={() => onOpenValidateModal(side ?? (test.red_validation_status === "approved" ? "red" : "blue"))}
className="flex items-center gap-1.5 rounded-lg bg-red-700/80 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 transition-colors"
>
<XCircle className="h-4 w-4" />
Change to Rejected
</button>
</div>,
);
}
}
@@ -458,6 +480,108 @@ export default function TestDetailHeader({
This test was rejected and needs to be reopened to restart the workflow.
</div>
)}
{/* Confirm My Validation — discussion request modal */}
{showDiscussModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<div className="flex items-center gap-2">
<UserCheck className="h-5 w-5 text-amber-400" />
<h3 className="text-lg font-semibold text-white">Confirm Your Validation</h3>
</div>
<button onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
{!discussResult ? (
<div className="px-6 py-5 space-y-4">
{/* Conflict summary */}
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
<p className="text-sm font-medium text-amber-300 mb-2">
Both leads must agree before a test can be finalised
</p>
<p className="text-xs text-gray-400">
You are confirming that your <strong className="text-white">approval is correct</strong> and
that the other lead's rejection should be reviewed.
</p>
</div>
{/* Other lead's rejection reason */}
{(test.red_validation_status === "rejected" && test.red_validation_notes) && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Red Lead's rejection reason</p>
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
"{test.red_validation_notes}"
</p>
</div>
)}
{(test.blue_validation_status === "rejected" && test.blue_validation_notes) && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Blue Lead's rejection reason</p>
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
"{test.blue_validation_notes}"
</p>
</div>
)}
{/* What happens next */}
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-2">
<p className="text-xs font-semibold text-white">What happens when you send the discussion request:</p>
<ul className="space-y-1 text-xs text-gray-400 list-disc list-inside">
<li>The other lead receives a notification to contact you</li>
<li>The test <strong className="text-white">remains in Disputed</strong> state</li>
<li>Either lead can change their vote at any time to resolve it</li>
<li>If both reject test becomes Rejected; if both approve Validated</li>
</ul>
</div>
{discussMutation.isError && (
<p className="text-sm text-red-400">Failed to send notification. Try again.</p>
)}
</div>
) : (
/* Success state */
<div className="px-6 py-8 text-center space-y-3">
<CheckCircle className="mx-auto h-12 w-12 text-green-400" />
<p className="text-lg font-semibold text-white">Discussion request sent</p>
<p className="text-sm text-gray-400">
<strong className="text-white">{discussResult}</strong> has been notified
that you are standing by your approval and want to discuss their rejection.
</p>
<p className="text-xs text-gray-500">
Reach out directly via your team's communication channels to resolve this.
</p>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
<button
onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
>
{discussResult ? "Close" : "Cancel"}
</button>
{!discussResult && (
<button
onClick={() => discussMutation.mutate()}
disabled={discussMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
>
{discussMutation.isPending
? <><Loader2 className="h-4 w-4 animate-spin" /> Sending</>
: <><MessageSquare className="h-4 w-4" /> Send Discussion Request</>
}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}