fix(campaigns,tests): admin cannot create campaigns, manager can delete unstarted tests, intensify red/blue team colors
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Campaign creation and generate-from-threat-actor now use the strict
role check — admin no longer gets a free pass into campaign content,
same principle already applied to test-template creation.
- New manager-only DELETE /tests/{id}: removes a standalone test still
in draft (not started, not linked to any campaign).
- Replaced the orange/indigo stand-ins used across team badges, tabs,
action buttons, and timers with true red/blue so Red Team and Blue
Team read unambiguously at a glance.
This commit is contained in:
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
@@ -319,7 +319,10 @@ def create_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
|
# Strict variant — admin must NOT get a free pass here. Admin
|
||||||
|
# administers the site, not campaign content; the only admin path to
|
||||||
|
# an active campaign is the emergency /activate override below.
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new campaign.
|
"""Create a new campaign.
|
||||||
|
|
||||||
@@ -987,7 +990,9 @@ def generate_campaign_from_actor(
|
|||||||
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
# Strict variant — admin must NOT get a free pass here, same as the
|
||||||
|
# plain create_campaign endpoint above.
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ from app.services.test_crud_service import (
|
|||||||
create_test_from_template as crud_create_from_template,
|
create_test_from_template as crud_create_from_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import from app.services.test_crud_service
|
||||||
|
from app.services.test_crud_service import (
|
||||||
|
delete_test as crud_delete_test,
|
||||||
|
)
|
||||||
|
|
||||||
# Import from app.services.test_crud_service
|
# Import from app.services.test_crud_service
|
||||||
from app.services.test_crud_service import (
|
from app.services.test_crud_service import (
|
||||||
get_test_detail as crud_get_test_detail,
|
get_test_detail as crud_get_test_detail,
|
||||||
@@ -543,6 +548,38 @@ def update_test(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_test(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# manager-only, and admin does NOT get a free pass — this is a
|
||||||
|
# queue-cleanup action for the manager role specifically, not a site
|
||||||
|
# administration task.
|
||||||
|
current_user: User = Depends(require_any_role_strict("manager")),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a standalone test that hasn't started yet.
|
||||||
|
|
||||||
|
Only tests still in ``draft`` state and not linked to any campaign can
|
||||||
|
be deleted this way.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_id (uuid.UUID): Primary key of the test to delete.
|
||||||
|
db (Session): SQLAlchemy database session.
|
||||||
|
current_user (User): Authenticated manager performing the deletion.
|
||||||
|
"""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
crud_delete_test(db, test_id)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="delete_test",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PATCH /tests/{id}/classification — admin data classification
|
# PATCH /tests/{id}/classification — admin data classification
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -438,6 +438,28 @@ def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def delete_test(db: Session, test_id: uuid.UUID) -> None:
|
||||||
|
"""Delete a standalone, not-yet-started test from the queue.
|
||||||
|
|
||||||
|
Only a test still in ``draft`` (execution hasn't started) and not
|
||||||
|
linked to any campaign may be deleted this way — a test already being
|
||||||
|
worked on, or one that's part of a campaign's plan, must be handled
|
||||||
|
through the normal workflow/campaign-modification paths instead.
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation. Does not commit;
|
||||||
|
caller commits.
|
||||||
|
"""
|
||||||
|
test = get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.state != TestState.draft:
|
||||||
|
raise BusinessRuleViolation("Only tests that haven't started yet can be deleted")
|
||||||
|
|
||||||
|
in_campaign = db.query(CampaignTest).filter(CampaignTest.test_id == test.id).first()
|
||||||
|
if in_campaign:
|
||||||
|
raise BusinessRuleViolation("Cannot delete a test that belongs to a campaign")
|
||||||
|
|
||||||
|
db.delete(test)
|
||||||
|
|
||||||
|
|
||||||
# Define function get_test_with_technique
|
# Define function get_test_with_technique
|
||||||
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
||||||
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ DUMMY_ID = str(uuid.uuid4())
|
|||||||
("post", "/api/v1/tests", {"technique_id": DUMMY_ID, "name": "x"}),
|
("post", "/api/v1/tests", {"technique_id": DUMMY_ID, "name": "x"}),
|
||||||
("post", "/api/v1/tests/from-template", {"template_id": DUMMY_ID, "technique_id": DUMMY_ID}),
|
("post", "/api/v1/tests/from-template", {"template_id": DUMMY_ID, "technique_id": DUMMY_ID}),
|
||||||
("post", "/api/v1/tests/import-rt", {"name": "x", "techniques": []}),
|
("post", "/api/v1/tests/import-rt", {"name": "x", "techniques": []}),
|
||||||
|
("post", "/api/v1/campaigns", {"name": "x"}),
|
||||||
|
("post", f"/api/v1/campaigns/from-threat-actor/{DUMMY_ID}", None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_admin_forbidden(client, auth_headers, method, path, payload):
|
def test_admin_forbidden(client, auth_headers, method, path, payload):
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""A manager (and only a manager) can delete a standalone test from the
|
||||||
|
queue, but only while it hasn't started yet (draft) and isn't linked to
|
||||||
|
any campaign — a test already in progress, or one that's part of a
|
||||||
|
campaign's plan, must go through the normal workflow instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.600", "name": "Command Line Delete Test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def draft_test(api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
|
json={"technique_id": technique, "name": "Standalone draft test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_delete_draft_standalone_test(api, manager_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 204, resp.text
|
||||||
|
|
||||||
|
reloaded = api("get", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert reloaded.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_lead_cannot_delete_test(api, red_lead_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", red_lead_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_delete_test(api, auth_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", auth_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_delete_started_test(api, manager_headers, red_tech_headers, draft_test):
|
||||||
|
started = api("post", f"/api/v1/tests/{draft_test}/start-execution", red_tech_headers)
|
||||||
|
assert started.status_code == 200, started.text
|
||||||
|
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_delete_test_linked_to_a_campaign(
|
||||||
|
api, db, manager_headers, red_lead_headers, draft_test,
|
||||||
|
):
|
||||||
|
campaign = api(
|
||||||
|
"post", "/api/v1/campaigns", red_lead_headers,
|
||||||
|
json={"name": "Holds the test"},
|
||||||
|
)
|
||||||
|
assert campaign.status_code == 201, campaign.text
|
||||||
|
campaign_id = campaign.json()["id"]
|
||||||
|
|
||||||
|
add = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign_id}/tests", red_lead_headers,
|
||||||
|
json={"test_id": draft_test},
|
||||||
|
)
|
||||||
|
assert add.status_code in (200, 201), add.text
|
||||||
|
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -372,6 +372,11 @@ export async function resumeTest(testId: string): Promise<Test> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a standalone test that hasn't started yet. Manager only. */
|
||||||
|
export async function deleteTest(testId: string): Promise<void> {
|
||||||
|
await client.delete(`/tests/${testId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Timeline ───────────────────────────────────────────────────────
|
// ── Timeline ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Get the audit-log timeline for a test. */
|
/** Get the audit-log timeline for a test. */
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import type { Test, TestState, TestTemplateSummary } from "../types/models";
|
|||||||
|
|
||||||
const stateBadge: Record<TestState, string> = {
|
const stateBadge: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ const PHASES = [
|
|||||||
|
|
||||||
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
|
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||||
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
|
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
|
||||||
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
|
red_executing: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
|
||||||
red_review: { bg: "bg-amber-900/50", text: "text-amber-400", border: "border-amber-500/50" },
|
red_review: { bg: "bg-amber-900/50", text: "text-amber-400", border: "border-amber-500/50" },
|
||||||
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
|
blue_evaluating: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
|
||||||
blue_review: { bg: "bg-purple-900/50", text: "text-purple-400", border: "border-purple-500/50" },
|
blue_review: { bg: "bg-purple-900/50", text: "text-purple-400", border: "border-purple-500/50" },
|
||||||
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
|
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
|
||||||
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
|
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
|
|||||||
{
|
{
|
||||||
label: "Red Execution",
|
label: "Red Execution",
|
||||||
value: data.red_execution_secs,
|
value: data.red_execution_secs,
|
||||||
icon: <Swords className="h-4 w-4 text-orange-400" />,
|
icon: <Swords className="h-4 w-4 text-red-400" />,
|
||||||
color: "text-orange-400",
|
color: "text-red-400",
|
||||||
bg: "bg-orange-500/10 border-orange-500/20",
|
bg: "bg-red-500/10 border-red-500/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Blue Queue",
|
label: "Blue Queue",
|
||||||
@@ -97,9 +97,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
|
|||||||
{
|
{
|
||||||
label: "Blue Evaluation",
|
label: "Blue Evaluation",
|
||||||
value: data.blue_evaluation_secs,
|
value: data.blue_evaluation_secs,
|
||||||
icon: <Shield className="h-4 w-4 text-indigo-400" />,
|
icon: <Shield className="h-4 w-4 text-blue-400" />,
|
||||||
color: "text-indigo-400",
|
color: "text-blue-400",
|
||||||
bg: "bg-indigo-500/10 border-indigo-500/20",
|
bg: "bg-blue-500/10 border-blue-500/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Total",
|
label: "Total",
|
||||||
@@ -127,14 +127,14 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
|
|||||||
{data.total_secs > 0 && (
|
{data.total_secs > 0 && (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="flex h-3 w-full gap-0.5 overflow-hidden rounded-full bg-gray-800">
|
<div className="flex h-3 w-full gap-0.5 overflow-hidden rounded-full bg-gray-800">
|
||||||
<BarSegment value={data.red_execution_secs} total={data.total_secs} color="bg-orange-500" label="Red Execution" />
|
<BarSegment value={data.red_execution_secs} total={data.total_secs} color="bg-red-500" label="Red Execution" />
|
||||||
<BarSegment value={data.blue_queue_secs} total={data.total_secs} color="bg-yellow-500" label="Blue Queue" />
|
<BarSegment value={data.blue_queue_secs} total={data.total_secs} color="bg-yellow-500" label="Blue Queue" />
|
||||||
<BarSegment value={data.blue_evaluation_secs} total={data.total_secs} color="bg-indigo-500" label="Blue Evaluation" />
|
<BarSegment value={data.blue_evaluation_secs} total={data.total_secs} color="bg-blue-500" label="Blue Evaluation" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-gray-500">
|
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-gray-500">
|
||||||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" />Red Execution</span>
|
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-red-500" />Red Execution</span>
|
||||||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-yellow-500" />Blue Queue</span>
|
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-yellow-500" />Blue Queue</span>
|
||||||
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-indigo-500" />Blue Evaluation</span>
|
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-blue-500" />Blue Evaluation</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -160,9 +160,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{/* Mini stacked bar */}
|
{/* Mini stacked bar */}
|
||||||
<div className="flex h-1.5 w-full gap-0.5 overflow-hidden rounded-full bg-gray-700">
|
<div className="flex h-1.5 w-full gap-0.5 overflow-hidden rounded-full bg-gray-700">
|
||||||
<BarSegment value={b.red_execution_secs} total={b.total_secs} color="bg-orange-500" label="Red" />
|
<BarSegment value={b.red_execution_secs} total={b.total_secs} color="bg-red-500" label="Red" />
|
||||||
<BarSegment value={b.blue_queue_secs} total={b.total_secs} color="bg-yellow-500" label="Queue" />
|
<BarSegment value={b.blue_queue_secs} total={b.total_secs} color="bg-yellow-500" label="Queue" />
|
||||||
<BarSegment value={b.blue_evaluation_secs} total={b.total_secs} color="bg-indigo-500" label="Blue" />
|
<BarSegment value={b.blue_evaluation_secs} total={b.total_secs} color="bg-blue-500" label="Blue" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex gap-3 text-[9px] text-gray-500 tabular-nums">
|
<div className="mt-1 flex gap-3 text-[9px] text-gray-500 tabular-nums">
|
||||||
{b.red_execution_secs > 0 && <span>🗡 {fmtDuration(b.red_execution_secs)}</span>}
|
{b.red_execution_secs > 0 && <span>🗡 {fmtDuration(b.red_execution_secs)}</span>}
|
||||||
|
|||||||
@@ -264,8 +264,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
<div className="relative space-y-0">
|
<div className="relative space-y-0">
|
||||||
{hasRedExec && (
|
{hasRedExec && (
|
||||||
<PhaseRow
|
<PhaseRow
|
||||||
dotClass="border-orange-500/60"
|
dotClass="border-red-500/60"
|
||||||
icon={<Sword className="h-3 w-3 text-orange-400 inline" />}
|
icon={<Sword className="h-3 w-3 text-red-400 inline" />}
|
||||||
label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"}
|
label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"}
|
||||||
startTs={red_started_at}
|
startTs={red_started_at}
|
||||||
duration={redExecSecs}
|
duration={redExecSecs}
|
||||||
@@ -299,8 +299,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
|
|
||||||
{hasBlueEval && (
|
{hasBlueEval && (
|
||||||
<PhaseRow
|
<PhaseRow
|
||||||
dotClass="border-indigo-500/60"
|
dotClass="border-blue-500/60"
|
||||||
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
|
icon={<Shield className="h-3 w-3 text-blue-400 inline" />}
|
||||||
label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"}
|
label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"}
|
||||||
startTs={blue_work_started_at}
|
startTs={blue_work_started_at}
|
||||||
duration={blueEvalSecs}
|
duration={blueEvalSecs}
|
||||||
@@ -310,8 +310,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
|
|
||||||
{hasRedValidation && (
|
{hasRedValidation && (
|
||||||
<PhaseRow
|
<PhaseRow
|
||||||
dotClass="border-orange-400/60"
|
dotClass="border-red-400/60"
|
||||||
icon={<CheckCircle className="h-3 w-3 text-orange-400 inline" />}
|
icon={<CheckCircle className="h-3 w-3 text-red-400 inline" />}
|
||||||
label="Red Lead Validation"
|
label="Red Lead Validation"
|
||||||
badge={<ValidationBadge status={red_validation_status} />}
|
badge={<ValidationBadge status={red_validation_status} />}
|
||||||
startTs={red_validated_at}
|
startTs={red_validated_at}
|
||||||
@@ -322,8 +322,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
|
|||||||
|
|
||||||
{hasBlueValidation && (
|
{hasBlueValidation && (
|
||||||
<PhaseRow
|
<PhaseRow
|
||||||
dotClass="border-indigo-400/60"
|
dotClass="border-blue-400/60"
|
||||||
icon={<CheckCircle className="h-3 w-3 text-indigo-400 inline" />}
|
icon={<CheckCircle className="h-3 w-3 text-blue-400 inline" />}
|
||||||
label="Blue Lead Validation"
|
label="Blue Lead Validation"
|
||||||
badge={<ValidationBadge status={blue_validation_status} />}
|
badge={<ValidationBadge status={blue_validation_status} />}
|
||||||
startTs={blue_validated_at}
|
startTs={blue_validated_at}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ interface WorklogTimelineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
|
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
|
||||||
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
|
red_team: { bg: "bg-red-900/30", text: "text-red-400", icon: "border-red-500/40" },
|
||||||
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
|
blue_validation: { bg: "bg-blue-900/30", text: "text-blue-400", icon: "border-blue-500/40" },
|
||||||
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
|
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
|
||||||
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
|
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
|
||||||
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
|
execution: { bg: "bg-red-900/30", text: "text-red-400", icon: "border-red-500/40" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
|
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIDE_STYLE = {
|
const SIDE_STYLE = {
|
||||||
red: "border-orange-500/40 bg-orange-900/20 text-orange-400 hover:bg-orange-900/40",
|
red: "border-red-500/40 bg-red-900/20 text-red-400 hover:bg-red-900/40",
|
||||||
blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
|
blue: "border-blue-500/40 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ELIGIBLE_ROLES: Record<"operator" | "reviewer", Record<"red" | "blue", string[]>> = {
|
const ELIGIBLE_ROLES: Record<"operator" | "reviewer", Record<"red" | "blue", string[]>> = {
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ export default function LiveTimer({
|
|||||||
const colors = isPaused
|
const colors = isPaused
|
||||||
? "border-yellow-500/40 bg-yellow-900/30 text-yellow-300"
|
? "border-yellow-500/40 bg-yellow-900/30 text-yellow-300"
|
||||||
: variant === "red"
|
: variant === "red"
|
||||||
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
|
? "border-red-500/40 bg-red-900/30 text-red-300"
|
||||||
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
|
: "border-blue-500/40 bg-blue-900/30 text-blue-300";
|
||||||
|
|
||||||
const dotColor = isPaused
|
const dotColor = isPaused
|
||||||
? "bg-yellow-400"
|
? "bg-yellow-400"
|
||||||
: variant === "red"
|
: variant === "red"
|
||||||
? "bg-orange-400"
|
? "bg-red-400"
|
||||||
: "bg-indigo-400";
|
: "bg-blue-400";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}>
|
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function ResolveDisputeModal({
|
|||||||
onClick={() => setTargetTeam("red")}
|
onClick={() => setTargetTeam("red")}
|
||||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
targetTeam === "red"
|
targetTeam === "red"
|
||||||
? "border-orange-500 bg-orange-500/10 text-orange-400"
|
? "border-red-500 bg-red-500/10 text-red-400"
|
||||||
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -64,7 +64,7 @@ export default function ResolveDisputeModal({
|
|||||||
onClick={() => setTargetTeam("blue")}
|
onClick={() => setTargetTeam("blue")}
|
||||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
targetTeam === "blue"
|
targetTeam === "blue"
|
||||||
? "border-indigo-500 bg-indigo-500/10 text-indigo-400"
|
? "border-blue-500 bg-blue-500/10 text-blue-400"
|
||||||
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ReviewModal({
|
|||||||
const [systemGaps, setSystemGaps] = useState("");
|
const [systemGaps, setSystemGaps] = useState("");
|
||||||
|
|
||||||
const title = isRed ? "Review Red Team Submission" : "Review Blue Team Submission";
|
const title = isRed ? "Review Red Team Submission" : "Review Blue Team Submission";
|
||||||
const accent = isRed ? "orange" : "indigo";
|
const accent = isRed ? "red" : "blue";
|
||||||
const evidences = isRed ? test.red_evidences || [] : test.blue_evidences || [];
|
const evidences = isRed ? test.red_evidences || [] : test.blue_evidences || [];
|
||||||
|
|
||||||
const requiresNotes = decision === "reopen" && notes.trim().length === 0;
|
const requiresNotes = decision === "reopen" && notes.trim().length === 0;
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TAB_COLORS: Record<TabKey, string> = {
|
const TAB_COLORS: Record<TabKey, string> = {
|
||||||
red: "border-orange-500 text-orange-400",
|
red: "border-red-500 text-red-400",
|
||||||
blue: "border-indigo-500 text-indigo-400",
|
blue: "border-blue-500 text-blue-400",
|
||||||
summary: "border-cyan-500 text-cyan-400",
|
summary: "border-cyan-500 text-cyan-400",
|
||||||
timeline: "border-gray-500 text-gray-400",
|
timeline: "border-gray-500 text-gray-400",
|
||||||
};
|
};
|
||||||
@@ -209,7 +209,7 @@ export default function TeamTabs({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Locked hint for red_tech in draft state */}
|
{/* Locked hint for red_tech in draft state */}
|
||||||
{redLockedHint && (
|
{redLockedHint && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/5 px-4 py-3 text-sm text-orange-400">
|
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3 text-sm text-red-400">
|
||||||
<span className="text-base">⏱</span>
|
<span className="text-base">⏱</span>
|
||||||
{redLockedHint}
|
{redLockedHint}
|
||||||
</div>
|
</div>
|
||||||
@@ -407,7 +407,7 @@ export default function TeamTabs({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Locked hint for blue_tech before Start Evaluation */}
|
{/* Locked hint for blue_tech before Start Evaluation */}
|
||||||
{blueLockedHint && (
|
{blueLockedHint && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 px-4 py-3 text-sm text-indigo-400">
|
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
|
||||||
<span className="text-base">⏱</span>
|
<span className="text-base">⏱</span>
|
||||||
{blueLockedHint}
|
{blueLockedHint}
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ export default function TeamTabs({
|
|||||||
value={blueDraft.detect_procedure}
|
value={blueDraft.detect_procedure}
|
||||||
onChange={(e) => onBlueFieldChange("detect_procedure", e.target.value)}
|
onChange={(e) => onBlueFieldChange("detect_procedure", e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 font-mono"
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
|
||||||
placeholder="Describe how you detected the attack..."
|
placeholder="Describe how you detected the attack..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -533,7 +533,7 @@ export default function TeamTabs({
|
|||||||
required
|
required
|
||||||
value={blueDraft.detection_time}
|
value={blueDraft.detection_time}
|
||||||
onChange={(e) => onBlueFieldChange("detection_time", e.target.value)}
|
onChange={(e) => onBlueFieldChange("detection_time", e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
@@ -552,7 +552,7 @@ export default function TeamTabs({
|
|||||||
required
|
required
|
||||||
value={blueDraft.containment_time}
|
value={blueDraft.containment_time}
|
||||||
onChange={(e) => onBlueFieldChange("containment_time", e.target.value)}
|
onChange={(e) => onBlueFieldChange("containment_time", e.target.value)}
|
||||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
@@ -574,7 +574,7 @@ export default function TeamTabs({
|
|||||||
value={blueDraft.blue_summary}
|
value={blueDraft.blue_summary}
|
||||||
onChange={(e) => onBlueFieldChange("blue_summary", e.target.value)}
|
onChange={(e) => onBlueFieldChange("blue_summary", e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
placeholder="Summarize the Blue Team analysis..."
|
placeholder="Summarize the Blue Team analysis..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -602,7 +602,7 @@ export default function TeamTabs({
|
|||||||
{/* Detection Rule Checklist */}
|
{/* Detection Rule Checklist */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-300">
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
<ShieldCheck className="h-4 w-4 text-indigo-400" />
|
<ShieldCheck className="h-4 w-4 text-blue-400" />
|
||||||
Detection Rule Evaluation
|
Detection Rule Evaluation
|
||||||
</h3>
|
</h3>
|
||||||
<DetectionRuleChecklist
|
<DetectionRuleChecklist
|
||||||
@@ -746,8 +746,8 @@ export default function TeamTabs({
|
|||||||
{/* Side-by-side comparison */}
|
{/* Side-by-side comparison */}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* Red Side */}
|
{/* Red Side */}
|
||||||
<div className="rounded-lg border border-orange-500/20 bg-orange-900/10 p-4">
|
<div className="rounded-lg border border-red-500/20 bg-red-900/10 p-4">
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-orange-400">
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-400">
|
||||||
<Shield className="h-4 w-4" /> Red Team (Attack)
|
<Shield className="h-4 w-4" /> Red Team (Attack)
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-3">
|
<dl className="space-y-3">
|
||||||
@@ -804,8 +804,8 @@ export default function TeamTabs({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Blue Side */}
|
{/* Blue Side */}
|
||||||
<div className="rounded-lg border border-indigo-500/20 bg-indigo-900/10 p-4">
|
<div className="rounded-lg border border-blue-500/20 bg-blue-900/10 p-4">
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-indigo-400">
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-blue-400">
|
||||||
<ShieldCheck className="h-4 w-4" /> Blue Team (Detection)
|
<ShieldCheck className="h-4 w-4" /> Blue Team (Detection)
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-3">
|
<dl className="space-y-3">
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ const STATE_INDEX: Record<TestState, number> = {
|
|||||||
|
|
||||||
const STATE_BADGE: Record<TestState, string> = {
|
const STATE_BADGE: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
@@ -203,7 +203,7 @@ export default function TestDetailHeader({
|
|||||||
key="start"
|
key="start"
|
||||||
onClick={onStartExecution}
|
onClick={onStartExecution}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors disabled:opacity-50"
|
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
Start Execution
|
Start Execution
|
||||||
@@ -223,7 +223,7 @@ export default function TestDetailHeader({
|
|||||||
onClick={onSubmitRed}
|
onClick={onSubmitRed}
|
||||||
disabled={isTransitioning || !hasRedEvidence}
|
disabled={isTransitioning || !hasRedEvidence}
|
||||||
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
|
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
Submit to Blue Team
|
Submit to Blue Team
|
||||||
@@ -282,7 +282,7 @@ export default function TestDetailHeader({
|
|||||||
key="start-blue-work"
|
key="start-blue-work"
|
||||||
onClick={onStartBlueWork}
|
onClick={onStartBlueWork}
|
||||||
disabled={isTransitioning}
|
disabled={isTransitioning}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
Start Evaluation
|
Start Evaluation
|
||||||
@@ -320,7 +320,7 @@ export default function TestDetailHeader({
|
|||||||
<button
|
<button
|
||||||
key="validate-red"
|
key="validate-red"
|
||||||
onClick={() => onOpenValidateModal("red")}
|
onClick={() => onOpenValidateModal("red")}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors"
|
||||||
>
|
>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Validate Red Side
|
Validate Red Side
|
||||||
@@ -338,7 +338,7 @@ export default function TestDetailHeader({
|
|||||||
<button
|
<button
|
||||||
key="validate-blue"
|
key="validate-blue"
|
||||||
onClick={() => onOpenValidateModal("blue")}
|
onClick={() => onOpenValidateModal("blue")}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors"
|
||||||
>
|
>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Validate Blue Side
|
Validate Blue Side
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ const typeLabels: Record<string, string> = {
|
|||||||
|
|
||||||
const testStateColors: Record<string, string> = {
|
const testStateColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export default function CampaignsPage() {
|
|||||||
start_date: "",
|
start_date: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin administers the site, not campaign content — no free pass here,
|
||||||
|
// same principle as the backend's require_any_role_strict on this action.
|
||||||
const canCreate =
|
const canCreate =
|
||||||
user?.role === "admin" ||
|
|
||||||
user?.role === "red_lead" ||
|
user?.role === "red_lead" ||
|
||||||
user?.role === "blue_lead" ||
|
user?.role === "blue_lead" ||
|
||||||
user?.role === "manager";
|
user?.role === "manager";
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ import type { TestState } from "../types/models";
|
|||||||
|
|
||||||
const testStateBadgeColors: Record<string, string> = {
|
const testStateBadgeColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
@@ -547,8 +547,8 @@ export default function DashboardPage() {
|
|||||||
function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
|
function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
|
||||||
const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
|
const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
|
||||||
{ key: "draft", label: "Draft", color: "bg-gray-600", icon: <Clock className="h-4 w-4 text-gray-400" /> },
|
{ key: "draft", label: "Draft", color: "bg-gray-600", icon: <Clock className="h-4 w-4 text-gray-400" /> },
|
||||||
{ key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: <Play className="h-4 w-4 text-orange-400" /> },
|
{ key: "red_executing", label: "Red Executing", color: "bg-red-500", icon: <Play className="h-4 w-4 text-red-400" /> },
|
||||||
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: <Shield className="h-4 w-4 text-indigo-400" /> },
|
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-blue-500", icon: <Shield className="h-4 w-4 text-blue-400" /> },
|
||||||
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
|
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
|
||||||
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
|
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
|
||||||
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
|
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
|
||||||
|
|||||||
@@ -453,9 +453,9 @@ const statusColors: Record<string, string> = {
|
|||||||
not_covered: "bg-red-500/10 text-red-400 border-red-500/30",
|
not_covered: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
||||||
draft: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
draft: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
||||||
red_executing: "bg-orange-500/10 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-500/10 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-500/10 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-500/10 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-500/10 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-500/10 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-500/10 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||||
rejected: "bg-red-500/10 text-red-400 border-red-500/30",
|
rejected: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ const statusBadgeColors: Record<TechniqueStatus, string> = {
|
|||||||
|
|
||||||
const testStateBadgeColors: Record<TestState, string> = {
|
const testStateBadgeColors: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ export default function TestDetailPage() {
|
|||||||
saveRedMutation.mutate();
|
saveRedMutation.mutate();
|
||||||
}}
|
}}
|
||||||
disabled={saveRedMutation.isPending}
|
disabled={saveRedMutation.isPending}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{saveRedMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{saveRedMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Save Red Team Fields
|
Save Red Team Fields
|
||||||
@@ -638,7 +638,7 @@ export default function TestDetailPage() {
|
|||||||
saveBlueMutation.mutate();
|
saveBlueMutation.mutate();
|
||||||
}}
|
}}
|
||||||
disabled={saveBlueMutation.isPending}
|
disabled={saveBlueMutation.isPending}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{saveBlueMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{saveBlueMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Save Blue Team Fields
|
Save Blue Team Fields
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTests, type TestListFilters } from "../api/tests";
|
import { getTests, deleteTest, type TestListFilters } from "../api/tests";
|
||||||
import {
|
import {
|
||||||
getProcedureSuggestions,
|
getProcedureSuggestions,
|
||||||
approveProcedureSuggestion,
|
approveProcedureSuggestion,
|
||||||
@@ -29,14 +30,15 @@ import {
|
|||||||
} from "../api/procedure-suggestions";
|
} from "../api/procedure-suggestions";
|
||||||
import type { Test, TestState } from "../types/models";
|
import type { Test, TestState } from "../types/models";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useToast } from "../components/Toast";
|
||||||
|
|
||||||
/* ── Badge colour map ──────────────────────────────────────────────── */
|
/* ── Badge colour map ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
const testStateBadgeColors: Record<TestState, string> = {
|
const testStateBadgeColors: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
@@ -503,9 +505,9 @@ export default function TestsPage() {
|
|||||||
{ALL_STATES.map((state) => {
|
{ALL_STATES.map((state) => {
|
||||||
const icons: Record<TestState, React.ReactNode> = {
|
const icons: Record<TestState, React.ReactNode> = {
|
||||||
draft: <Clock className="h-4 w-4 text-gray-400" />,
|
draft: <Clock className="h-4 w-4 text-gray-400" />,
|
||||||
red_executing: <Play className="h-4 w-4 text-orange-400" />,
|
red_executing: <Play className="h-4 w-4 text-red-400" />,
|
||||||
red_review: <Shield className="h-4 w-4 text-amber-400" />,
|
red_review: <Shield className="h-4 w-4 text-amber-400" />,
|
||||||
blue_evaluating: <Shield className="h-4 w-4 text-indigo-400" />,
|
blue_evaluating: <Shield className="h-4 w-4 text-blue-400" />,
|
||||||
blue_review: <Shield className="h-4 w-4 text-purple-400" />,
|
blue_review: <Shield className="h-4 w-4 text-purple-400" />,
|
||||||
in_review: <Eye className="h-4 w-4 text-blue-400" />,
|
in_review: <Eye className="h-4 w-4 text-blue-400" />,
|
||||||
validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
validated: <CheckCircle className="h-4 w-4 text-green-400" />,
|
||||||
@@ -514,9 +516,9 @@ export default function TestsPage() {
|
|||||||
};
|
};
|
||||||
const colorMap: Record<TestState, string> = {
|
const colorMap: Record<TestState, string> = {
|
||||||
draft: "text-gray-400",
|
draft: "text-gray-400",
|
||||||
red_executing: "text-orange-400",
|
red_executing: "text-red-400",
|
||||||
red_review: "text-amber-400",
|
red_review: "text-amber-400",
|
||||||
blue_evaluating: "text-indigo-400",
|
blue_evaluating: "text-blue-400",
|
||||||
blue_review: "text-purple-400",
|
blue_review: "text-purple-400",
|
||||||
in_review: "text-blue-400",
|
in_review: "text-blue-400",
|
||||||
validated: "text-green-400",
|
validated: "text-green-400",
|
||||||
@@ -916,6 +918,24 @@ function TestTable({
|
|||||||
* where seeing who's working each side is the whole point. */
|
* where seeing who's working each side is the whole point. */
|
||||||
showAssignees?: boolean;
|
showAssignees?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (testId: string) => deleteTest(testId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
showToast("success", "Test deleted");
|
||||||
|
},
|
||||||
|
onError: () => showToast("error", "Could not delete this test — it may already be in progress or belong to a campaign"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manager-only queue cleanup: a not-yet-started, standalone test can be
|
||||||
|
// deleted directly. Tests already underway or attached to a campaign are
|
||||||
|
// rejected server-side — the error above covers that case.
|
||||||
|
const canDelete = user?.role === "manager";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -997,7 +1017,7 @@ function TestTable({
|
|||||||
{/* Waiting time — how long since Red submitted to Blue */}
|
{/* Waiting time — how long since Red submitted to Blue */}
|
||||||
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
<td className="py-3 px-4 text-xs whitespace-nowrap">
|
||||||
{test.state === "blue_evaluating" ? (
|
{test.state === "blue_evaluating" ? (
|
||||||
<span className="font-mono text-indigo-400">
|
<span className="font-mono text-blue-400">
|
||||||
{formatElapsed(test.blue_started_at)}
|
{formatElapsed(test.blue_started_at)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -1021,15 +1041,32 @@ function TestTable({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<td className="py-3 pl-4">
|
<td className="py-3 pl-4">
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/tests/${test.id}`);
|
e.stopPropagation();
|
||||||
}}
|
navigate(`/tests/${test.id}`);
|
||||||
className="text-sm text-cyan-400 hover:underline"
|
}}
|
||||||
>
|
className="text-sm text-cyan-400 hover:underline"
|
||||||
View
|
>
|
||||||
</button>
|
View
|
||||||
|
</button>
|
||||||
|
{canDelete && test.state === "draft" && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.confirm(`Delete "${test.name}"? This cannot be undone.`)) {
|
||||||
|
deleteMutation.mutate(test.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Delete this test (only if not started and not part of a campaign)"
|
||||||
|
className="text-gray-500 hover:text-red-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ const roleBadgeColors: Record<string, string> = {
|
|||||||
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
|
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_lead: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
|
blue_lead: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user