feat(permissions): admin no longer acts on the test workflow; managers coordinate operators
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
Admin administers the site — it no longer has any override on
validate-red/blue, review-red/blue, resolve-dispute, request-discussion,
reopen, hold, resume, assign-operators, or classification-update.
Introduces require_any_role_strict(), a role dependency without the
global admin bypass, so these specific endpoints truly exclude admin
instead of only removing it from the (redundant) role tuple.
Managers gain the ability to assign red_tech/blue_tech operators
(POST /tests/{id}/assign, GET /users/operators) alongside leads, since
that's coordination, not resolving a ticket.
Also enlarges and repositions the operator-assignment controls next
to the Start Execution button, and fixes a literal '\u2014' rendering
as text instead of an em dash in the test detail technique line.
This commit is contained in:
@@ -179,7 +179,7 @@ export async function updateTestClassification(
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + admin only). */
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + managers only). */
|
||||
export async function assignTestOperators(
|
||||
testId: string,
|
||||
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface OperatorOut {
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */
|
||||
/** Fetch red/blue operators + leads for assignment pickers (leads + managers). */
|
||||
export async function getOperators(): Promise<OperatorOut[]> {
|
||||
const { data } = await client.get<OperatorOut[]>("/users/operators");
|
||||
return data;
|
||||
|
||||
@@ -9,11 +9,13 @@ interface Props {
|
||||
canEdit: boolean;
|
||||
isSaving: boolean;
|
||||
onAssign: (userId: string | null) => void;
|
||||
/** "sm" = compact pill (default), "lg" = full-size button matching the action bar. */
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
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",
|
||||
red: "border-orange-500/40 bg-orange-900/20 text-orange-400 hover:bg-orange-900/40",
|
||||
blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
|
||||
};
|
||||
|
||||
const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
@@ -21,20 +23,28 @@ const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
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) {
|
||||
/** Lead/manager picker for the red_tech_assignee / blue_tech_assignee fields. */
|
||||
export default function AssigneeControl({
|
||||
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm",
|
||||
}: 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 sideLabel = side === "red" ? "RT" : "BT";
|
||||
|
||||
const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
|
||||
const badgeClass =
|
||||
size === "lg"
|
||||
? `flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium ${SIDE_STYLE[side]}`
|
||||
: `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
|
||||
const iconSize = size === "lg" ? "h-4 w-4" : "h-2.5 w-2.5";
|
||||
const chevronSize = size === "lg" ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className={badgeClass}>
|
||||
<UserCircle2 className="h-2.5 w-2.5" />
|
||||
{side === "red" ? "RT" : "BT"}: {label}
|
||||
<UserCircle2 className={iconSize} />
|
||||
{sideLabel}: {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -43,15 +53,15 @@ export default function AssigneeControl({ side, assigneeId, operators, canEdit,
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={`${badgeClass} transition-colors hover:opacity-80`}
|
||||
className={`${badgeClass} transition-colors`}
|
||||
>
|
||||
<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" />}
|
||||
<UserCircle2 className={iconSize} />
|
||||
{sideLabel}: {label}
|
||||
{expanded ? <ChevronUp className={chevronSize} /> : <ChevronDown className={chevronSize} />}
|
||||
</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">
|
||||
<div className="absolute right-0 top-full z-20 mt-1 w-52 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" />
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function TestDetailHeader({
|
||||
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
|
||||
const canHold =
|
||||
HOLDABLE_STATES.includes(test.state) &&
|
||||
(role === "red_tech" || role === "blue_tech" || role === "admin");
|
||||
(role === "red_tech" || role === "blue_tech");
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: React.ReactNode[] = [];
|
||||
@@ -224,7 +224,7 @@ export default function TestDetailHeader({
|
||||
// Red Lead assigned to review this submission -> Review Red Submission
|
||||
if (
|
||||
test.state === "red_review" &&
|
||||
(role === "admin" || (role === "red_lead" && test.red_reviewer_assignee === user?.id))
|
||||
role === "red_lead" && test.red_reviewer_assignee === user?.id
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -241,7 +241,7 @@ export default function TestDetailHeader({
|
||||
// Blue Lead assigned to review this submission -> Review Blue Submission
|
||||
if (
|
||||
test.state === "blue_review" &&
|
||||
(role === "admin" || (role === "blue_lead" && test.blue_reviewer_assignee === user?.id))
|
||||
role === "blue_lead" && test.blue_reviewer_assignee === user?.id
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -299,7 +299,7 @@ export default function TestDetailHeader({
|
||||
// Red Lead in in_review -> Validate Red
|
||||
if (
|
||||
test.state === "in_review" &&
|
||||
(role === "red_lead" || role === "admin") &&
|
||||
role === "red_lead" &&
|
||||
!test.red_validation_status
|
||||
) {
|
||||
buttons.push(
|
||||
@@ -317,7 +317,7 @@ export default function TestDetailHeader({
|
||||
// Blue Lead in in_review -> Validate Blue
|
||||
if (
|
||||
test.state === "in_review" &&
|
||||
(role === "blue_lead" || role === "admin") &&
|
||||
role === "blue_lead" &&
|
||||
!test.blue_validation_status
|
||||
) {
|
||||
buttons.push(
|
||||
@@ -336,18 +336,16 @@ export default function TestDetailHeader({
|
||||
if (test.state === "disputed") {
|
||||
const isApproving =
|
||||
(role === "red_lead" && test.red_validation_status === "approved") ||
|
||||
(role === "blue_lead" && test.blue_validation_status === "approved") ||
|
||||
(role === "admin" && test.red_validation_status === "approved");
|
||||
(role === "blue_lead" && test.blue_validation_status === "approved");
|
||||
|
||||
const isRejecting =
|
||||
(role === "red_lead" && test.red_validation_status === "rejected") ||
|
||||
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
|
||||
(role === "admin" && test.blue_validation_status === "rejected");
|
||||
(role === "blue_lead" && test.blue_validation_status === "rejected");
|
||||
|
||||
const rejectingSide: "red" | "blue" =
|
||||
test.red_validation_status === "rejected" ? "red" : "blue";
|
||||
|
||||
if (isApproving || role === "admin") {
|
||||
if (isApproving) {
|
||||
buttons.push(
|
||||
<div key="disputed-approver" className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -401,10 +399,10 @@ export default function TestDetailHeader({
|
||||
}
|
||||
}
|
||||
|
||||
// Leads/admin on rejected -> Reopen
|
||||
// Leads on rejected -> Reopen
|
||||
if (
|
||||
test.state === "rejected" &&
|
||||
(role === "red_lead" || role === "blue_lead" || role === "admin")
|
||||
(role === "red_lead" || role === "blue_lead")
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -539,7 +537,7 @@ export default function TestDetailHeader({
|
||||
</span>
|
||||
<DataClassificationBadge
|
||||
value={test.data_classification}
|
||||
canEdit={["admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
|
||||
canEdit={["manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
|
||||
isSaving={isUpdatingClassification}
|
||||
onChange={onUpdateClassification}
|
||||
/>
|
||||
@@ -547,29 +545,31 @@ 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>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssigneeControl
|
||||
side="red"
|
||||
assigneeId={test.red_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "red_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("red", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
<AssigneeControl
|
||||
side="blue"
|
||||
assigneeId={test.blue_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "blue_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("blue", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{renderLiveTimer()}
|
||||
{renderActions()}
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ 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 canAssignOperators = ["red_lead", "blue_lead", "manager"].includes(user?.role ?? "");
|
||||
const { data: operators = [] } = useQuery({
|
||||
queryKey: ["operators"],
|
||||
queryFn: getOperators,
|
||||
@@ -629,7 +629,7 @@ export default function TestDetailPage() {
|
||||
</span>
|
||||
{test.technique_name && (
|
||||
<span className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors">
|
||||
\u2014 {test.technique_name}
|
||||
\— {test.technique_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user