feat(assign): let leads manually assign operators + sync Jira; send platform as Jira label
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
Adds GET /users/operators (leads+admin) and wires a lead-only assign
control into the test detail header, calling the existing but
previously unreachable POST /tests/{id}/assign endpoint. Manual
assignment now also pushes the Jira ticket assignee immediately
instead of waiting for the operator to start execution.
Also adds test.platform as a kebab-case label on Jira ticket creation.
This commit is contained in:
@@ -179,6 +179,15 @@ export async function updateTestClassification(
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + admin only). */
|
||||
export async function assignTestOperators(
|
||||
testId: string,
|
||||
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
||||
): Promise<Test> {
|
||||
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Red Team ───────────────────────────────────────────────────────
|
||||
|
||||
/** Red Team updates their fields (draft, red_executing). */
|
||||
|
||||
@@ -30,6 +30,18 @@ export async function getUsers(): Promise<UserOut[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface OperatorOut {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */
|
||||
export async function getOperators(): Promise<OperatorOut[]> {
|
||||
const { data } = await client.get<OperatorOut[]>("/users/operators");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Create a new user (admin only). */
|
||||
export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
|
||||
const { data } = await client.post<UserOut>("/users", payload);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2, UserCircle2 } from "lucide-react";
|
||||
import type { OperatorOut } from "../../api/users";
|
||||
|
||||
interface Props {
|
||||
side: "red" | "blue";
|
||||
assigneeId: string | null;
|
||||
operators: OperatorOut[];
|
||||
canEdit: boolean;
|
||||
isSaving: boolean;
|
||||
onAssign: (userId: string | null) => void;
|
||||
}
|
||||
|
||||
const SIDE_STYLE = {
|
||||
red: "border-orange-500/30 bg-orange-900/20 text-orange-400",
|
||||
blue: "border-indigo-500/30 bg-indigo-900/20 text-indigo-400",
|
||||
};
|
||||
|
||||
const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
red: ["red_tech", "red_lead", "admin"],
|
||||
blue: ["blue_tech", "blue_lead", "admin"],
|
||||
};
|
||||
|
||||
/** Lead/admin picker for the red_tech_assignee / blue_tech_assignee fields. */
|
||||
export default function AssigneeControl({ side, assigneeId, operators, canEdit, isSaving, onAssign }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const current = operators.find((o) => o.id === assigneeId);
|
||||
const label = current ? current.username : "Unassigned";
|
||||
const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role));
|
||||
|
||||
const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className={badgeClass}>
|
||||
<UserCircle2 className="h-2.5 w-2.5" />
|
||||
{side === "red" ? "RT" : "BT"}: {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={`${badgeClass} transition-colors hover:opacity-80`}
|
||||
>
|
||||
<UserCircle2 className="h-2.5 w-2.5" />
|
||||
{side === "red" ? "RT" : "BT"}: {label}
|
||||
{expanded ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 w-48 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
|
||||
{isSaving ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { onAssign(null); setExpanded(false); }}
|
||||
className={`block w-full rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
!assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Unassigned
|
||||
</button>
|
||||
{eligible.map((op) => (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => { onAssign(op.id); setExpanded(false); }}
|
||||
className={`block w-full rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
op.id === assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{op.username}
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">({op.role.replace(/_/g, " ")})</span>
|
||||
</button>
|
||||
))}
|
||||
{eligible.length === 0 && (
|
||||
<p className="px-2 py-1.5 text-xs text-gray-500">No eligible operators</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { requestDiscussion } from "../../api/tests";
|
||||
import type { Test, TestState, User, DataClassification } from "../../types/models";
|
||||
import type { OperatorOut } from "../../api/users";
|
||||
import LiveTimer from "./LiveTimer";
|
||||
import DataClassificationBadge from "./DataClassificationBadge";
|
||||
import AssigneeControl from "./AssigneeControl";
|
||||
|
||||
// ── Progress steps ─────────────────────────────────────────────────
|
||||
|
||||
@@ -90,6 +92,9 @@ interface TestDetailHeaderProps {
|
||||
isTogglingHold: boolean;
|
||||
onUpdateClassification: (value: DataClassification) => void;
|
||||
isUpdatingClassification: boolean;
|
||||
operators: OperatorOut[];
|
||||
onAssignOperator: (side: "red" | "blue", userId: string | null) => void;
|
||||
isAssigningOperator: boolean;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────
|
||||
@@ -114,6 +119,9 @@ export default function TestDetailHeader({
|
||||
isTogglingHold,
|
||||
onUpdateClassification,
|
||||
isUpdatingClassification,
|
||||
operators,
|
||||
onAssignOperator,
|
||||
isAssigningOperator,
|
||||
}: TestDetailHeaderProps) {
|
||||
const role = user?.role ?? "";
|
||||
const currentIdx = STATE_INDEX[test.state];
|
||||
@@ -539,6 +547,24 @@ export default function TestDetailHeader({
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Created {formatDate(test.created_at)}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<AssigneeControl
|
||||
side="red"
|
||||
assigneeId={test.red_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "red_lead" || role === "admin"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("red", userId)}
|
||||
/>
|
||||
<AssigneeControl
|
||||
side="blue"
|
||||
assigneeId={test.blue_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "blue_lead" || role === "admin"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("blue", userId)}
|
||||
/>
|
||||
</div>
|
||||
{renderValidationIndicators()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
holdTest,
|
||||
resumeTest,
|
||||
updateTestClassification,
|
||||
assignTestOperators,
|
||||
getTestTimeline,
|
||||
getRetestChain,
|
||||
} from "../api/tests";
|
||||
import { getOperators } from "../api/users";
|
||||
import { uploadEvidence, getEvidence } from "../api/evidence";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { TestResult, ContainmentResult, AttackSuccessResult, DataClassification, TeamSide, TestTimelineEntry } from "../types/models";
|
||||
@@ -133,6 +135,14 @@ export default function TestDetailPage() {
|
||||
enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0),
|
||||
});
|
||||
|
||||
const canAssignOperators = ["red_lead", "blue_lead", "admin"].includes(user?.role ?? "");
|
||||
const { data: operators = [] } = useQuery({
|
||||
queryKey: ["operators"],
|
||||
queryFn: getOperators,
|
||||
enabled: canAssignOperators,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Hydrate drafts from test data
|
||||
useEffect(() => {
|
||||
if (test) {
|
||||
@@ -368,6 +378,16 @@ export default function TestDetailPage() {
|
||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||
});
|
||||
|
||||
const assignOperatorMutation = useMutation({
|
||||
mutationFn: ({ side, userId }: { side: "red" | "blue"; userId: string | null }) =>
|
||||
assignTestOperators(testId!, side === "red" ? { red_tech_assignee: userId } : { blue_tech_assignee: userId }),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
showToast("Assignment updated", "success");
|
||||
},
|
||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||
});
|
||||
|
||||
// Evidence upload
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
|
||||
@@ -532,6 +552,9 @@ export default function TestDetailPage() {
|
||||
isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending}
|
||||
onUpdateClassification={(value) => updateClassificationMutation.mutate(value)}
|
||||
isUpdatingClassification={updateClassificationMutation.isPending}
|
||||
operators={operators}
|
||||
onAssignOperator={(side, userId) => assignOperatorMutation.mutate({ side, userId })}
|
||||
isAssigningOperator={assignOperatorMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Content: Tabs + Sidebar */}
|
||||
|
||||
Reference in New Issue
Block a user