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

@@ -745,6 +745,97 @@ def sync_tempo(
return {"results": results} return {"results": results}
# ---------------------------------------------------------------------------
# POST /tests/{id}/request-discussion — disputed: confirm vote + notify other lead
# ---------------------------------------------------------------------------
@router.post("/{test_id}/request-discussion")
def request_discussion(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Called when the approving lead confirms their vote in a disputed test.
Sends a notification to the other lead (who rejected) asking them to
discuss and resolve the conflict. The test remains in 'disputed' state.
"""
from app.models.enums import TestState as ModelTestState
from app.models.user import User as UserModel
from app.services.notification_service import create_notification
test = crud_get_test_or_raise(db, test_id)
if test.state.value != "disputed":
from app.domain.errors import BusinessRuleViolation
raise BusinessRuleViolation("Test is not in disputed state")
role = current_user.role
# Identify who the "other lead" is (the one who rejected)
if role == "red_lead" and test.red_validation_status == "approved":
# Red approved, Blue rejected → notify Blue Lead who rejected
rejector_id = test.blue_validated_by
rejector_label = "Blue Lead"
requester_label = "Red Lead"
elif role == "blue_lead" and test.blue_validation_status == "approved":
# Blue approved, Red rejected → notify Red Lead who rejected
rejector_id = test.red_validated_by
rejector_label = "Red Lead"
requester_label = "Blue Lead"
else:
from app.domain.errors import BusinessRuleViolation
raise BusinessRuleViolation(
"You are not the approving lead in this conflict or the state is inconsistent"
)
# Look up the rejecting lead's username for the message
rejector = (
db.query(UserModel).filter(UserModel.id == rejector_id).first()
if rejector_id else None
)
rejector_name = rejector.username if rejector else rejector_label
# Notify the rejecting lead
if rejector_id:
try:
create_notification(
db,
user_id=rejector_id,
type="validation_conflict",
title="Discussion requested on disputed test",
message=(
f"{requester_label} ({current_user.username}) is confirming their approval "
f"of test '{test.name}' and wants to discuss your rejection with you. "
f"Please reach out to resolve the disagreement."
),
entity_type="test",
entity_id=str(test.id),
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
"Failed to send discussion notification: %s", e
)
log_action(
db,
user_id=current_user.id,
action="request_dispute_discussion",
entity_type="test",
entity_id=test.id,
details={"test_name": test.name, "rejector": rejector_name},
)
db.commit()
return {
"status": "notification_sent",
"message": f"Discussion request sent to {rejector_name}",
"rejector_username": rejector_name,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# POST /tests/import-rt — bulk import from a real Red Team engagement # POST /tests/import-rt — bulk import from a real Red Team engagement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -308,6 +308,16 @@ export interface RTImportResult {
engagement: string; 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. */ /** Import results from a real Red Team engagement. */
export async function importRT(payload: RTImportPayload): Promise<RTImportResult> { export async function importRT(payload: RTImportPayload): Promise<RTImportResult> {
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload); const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { import {
FlaskConical, FlaskConical,
Play, Play,
@@ -10,7 +11,11 @@ import {
ShieldCheck, ShieldCheck,
AlertTriangle, AlertTriangle,
MessageSquare, MessageSquare,
X,
UserCheck,
} from "lucide-react"; } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { requestDiscussion } from "../../api/tests";
import type { Test, TestState, User } from "../../types/models"; import type { Test, TestState, User } from "../../types/models";
import LiveTimer from "./LiveTimer"; import LiveTimer from "./LiveTimer";
@@ -81,6 +86,13 @@ export default function TestDetailHeader({
}: TestDetailHeaderProps) { }: TestDetailHeaderProps) {
const role = user?.role ?? ""; const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state]; 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) => { const formatDate = (d: string | null) => {
if (!d) return null; if (!d) return null;
@@ -227,14 +239,24 @@ export default function TestDetailHeader({
const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null; const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null;
if (side || role === "admin") { if (side || role === "admin") {
buttons.push( buttons.push(
<button <div key="disputed-actions" className="flex items-center gap-2">
key="change-vote" {/* Confirm keeping approval → open discussion modal */}
onClick={() => onOpenValidateModal(side ?? (test.red_validation_status === "approved" ? "red" : "blue"))} <button
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" 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"
<XCircle className="h-4 w-4" /> >
Change Vote to Rejected <UserCheck className="h-4 w-4" />
</button>, 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. This test was rejected and needs to be reopened to restart the workflow.
</div> </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> </div>
); );
} }