feat(disputed): Confirm My Validation button + discussion request modal
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user