feat(tests,users): blocking procedure-suggestion review on test detail, multi-role switcher, Power Automate webhook payload
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

- A lead opening a test with a pending procedure suggestion awaiting
  their review now gets a blocking popup (approve/reject only) instead
  of discovering it later in a separate queue.
- Users can be granted more than one role via extra_roles; only one is
  ever active at a time (no permission mixing) and a top-bar switcher
  lets the user swap which one, taking effect immediately since role
  is read fresh from the DB on every request.
- The password-setup/reset webhook now posts the agreed Power Automate
  contract ({to, subject, body} with the platform's standard greeting
  and signature template) and supports an admin-configured API key
  sent as an x-api-key header.
This commit is contained in:
kitos
2026-07-20 09:29:36 +02:00
parent 4dea19cae9
commit bbe7f49c86
24 changed files with 708 additions and 49 deletions
@@ -7,6 +7,13 @@ export async function getProcedureSuggestions(): Promise<ProcedureSuggestion[]>
return data;
}
/** List pending suggestions tied to a specific test — powers the blocking
* review popup a lead sees when opening a test that has one awaiting them. */
export async function getProcedureSuggestionsForTest(testId: string): Promise<ProcedureSuggestion[]> {
const { data } = await client.get<ProcedureSuggestion[]>(`/procedure-suggestions/for-test/${testId}`);
return data;
}
/** Approve a suggestion — writes it into the template's suggested-procedure field. */
export async function approveProcedureSuggestion(id: string): Promise<ProcedureSuggestion> {
const { data } = await client.post<ProcedureSuggestion>(`/procedure-suggestions/${id}/approve`);
+10 -2
View File
@@ -202,6 +202,7 @@ export async function testJiraConnection(): Promise<{
export interface PasswordWebhookConfigOut {
configured: boolean;
url: string;
api_key_set: boolean;
}
export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> {
@@ -209,8 +210,15 @@ export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigO
return data;
}
export async function updatePasswordWebhookConfig(url: string): Promise<PasswordWebhookConfigOut> {
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", { url });
/** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */
export async function updatePasswordWebhookConfig(
url: string,
apiKey?: string,
): Promise<PasswordWebhookConfigOut> {
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", {
url,
api_key: apiKey || undefined,
});
return data;
}
+8
View File
@@ -6,6 +6,7 @@ export interface UserOut {
full_name: string | null;
email: string | null;
role: string;
extra_roles: string[];
is_active: boolean;
must_change_password: boolean;
created_at: string | null;
@@ -22,6 +23,7 @@ export interface UserUpdatePayload {
email?: string;
full_name?: string;
role?: string;
extra_roles?: string[];
is_active?: boolean;
// Not sent by the current UI — the set-password-email flow replaces
// admin-set passwords — but still supported server-side.
@@ -64,3 +66,9 @@ export async function sendPasswordEmail(userId: string): Promise<{ detail: strin
const { data } = await client.post<{ detail: string }>(`/users/${userId}/send-password-email`);
return data;
}
/** Switch the current user's active role to one of their granted extra_roles. */
export async function switchRole(role: string): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users/me/switch-role", { role });
return data;
}
+2
View File
@@ -3,6 +3,7 @@ import { LogOut, AlertTriangle, RefreshCw } from "lucide-react";
import { useAuth } from "../context/AuthContext";
import Sidebar from "./Sidebar";
import NotificationBell from "./NotificationBell";
import RoleSwitcher from "./RoleSwitcher";
import React from "react";
/* ── Error Boundary ──────────────────────────────────────────────────
@@ -71,6 +72,7 @@ export default function Layout() {
{/* Header */}
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
<NotificationBell />
<RoleSwitcher />
<span className="text-sm text-gray-300">{user?.full_name || user?.username}</span>
<button
onClick={logout}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from "react";
import { ChevronDown, Repeat, Loader2 } from "lucide-react";
import { useAuth } from "../context/AuthContext";
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
red_tech: "Red Tech",
blue_tech: "Blue Tech",
red_lead: "Red Lead",
blue_lead: "Blue Lead",
manager: "Manager",
viewer: "Viewer",
};
/** Dropdown in the top bar letting a multi-role user switch which single
* role is currently active — only rendered when the user has extra_roles;
* roles never mix, this just changes which one is in effect. */
export default function RoleSwitcher() {
const { user, switchRole } = useAuth();
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const extraRoles = user?.extra_roles ?? [];
if (extraRoles.length === 0) return null;
const allRoles = [user!.role, ...extraRoles];
const handleSwitch = async (role: string) => {
if (role === user!.role || switching) return;
setSwitching(true);
try {
await switchRole(role);
} finally {
setSwitching(false);
setOpen(false);
}
};
return (
<div className="relative">
<button
onClick={() => setOpen((v) => !v)}
disabled={switching}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:opacity-50"
title="Switch active role"
>
{switching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Repeat className="h-3.5 w-3.5" />}
{ROLE_LABELS[user!.role] || user!.role}
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-30 mt-1 w-44 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
{allRoles.map((role) => (
<button
key={role}
onClick={() => handleSwitch(role)}
className={`block w-full rounded px-2 py-1.5 text-left text-sm transition-colors ${
role === user!.role
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-300 hover:bg-gray-800"
}`}
>
{ROLE_LABELS[role] || role}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { Lightbulb, CheckCircle, XCircle, Loader2 } from "lucide-react";
import type { ProcedureSuggestion } from "../../types/models";
interface Props {
suggestion: ProcedureSuggestion;
onApprove: () => void;
onReject: () => void;
isBusy: boolean;
}
/** Blocking popup shown to a lead opening a test that has a pending
* procedure-improvement suggestion awaiting their review — no backdrop
* click-to-close, no X button; the rest of the page stays inert until
* they approve or reject it. */
export default function PendingSuggestionModal({ suggestion, onApprove, onReject, isBusy }: Props) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-yellow-500/30 bg-gray-900 p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Procedure Suggestion Needs Your Review</h3>
</div>
<p className="mb-4 text-sm text-gray-400">
This test proposed an update to{" "}
<span className="font-medium text-gray-300">{suggestion.template_name || "its template"}</span>.
Approve or reject it before continuing.
</p>
{suggestion.template_current_text && (
<div className="mb-3">
<p className="text-xs font-medium uppercase text-gray-500">Current</p>
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-gray-400">
{suggestion.template_current_text}
</pre>
</div>
)}
<div className="mb-6">
<p className="text-xs font-medium uppercase text-gray-500">Suggested</p>
<pre className="mt-1 whitespace-pre-wrap break-words rounded bg-gray-950 p-2 font-mono text-xs text-green-400">
{suggestion.suggested_text}
</pre>
</div>
<div className="flex justify-end gap-2">
<button
onClick={onReject}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <XCircle className="h-4 w-4" />}
Reject
</button>
<button
onClick={onApprove}
disabled={isBusy}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
Approve
</button>
</div>
</div>
</div>
);
}
+11
View File
@@ -12,6 +12,7 @@ import {
getMe,
refreshToken as apiRefreshToken,
} from "../api/auth";
import { switchRole as apiSwitchRole } from "../api/users";
import type { User } from "../types/models";
import ChangePasswordModal from "../components/ChangePasswordModal";
@@ -23,6 +24,10 @@ interface AuthState {
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
/** Switch the active role to one of the user's granted extra_roles.
* Reloads the page afterward so every role-gated component (nav, route
* guards, etc.) re-evaluates cleanly against the new role. */
switchRole: (role: string) => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
@@ -72,6 +77,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null);
}, []);
const switchRole = useCallback(async (role: string) => {
await apiSwitchRole(role);
window.location.reload();
}, []);
const mustChangePassword = user?.must_change_password === true;
return (
@@ -82,6 +92,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isLoading,
login,
logout,
switchRole,
}}
>
{children}
+43 -22
View File
@@ -1688,6 +1688,7 @@ function SystemInfoSection() {
function PasswordWebhookSection() {
const qc = useQueryClient();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const { data, isLoading } = useQuery({
@@ -1700,12 +1701,13 @@ function PasswordWebhookSection() {
}, [data]);
const saveMutation = useMutation({
mutationFn: () => updatePasswordWebhookConfig(url),
mutationFn: () => updatePasswordWebhookConfig(url, apiKey),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["password-webhook-config"] });
setToast({ msg: "Webhook URL saved", type: "success" });
setApiKey("");
setToast({ msg: "Webhook configuration saved", type: "success" });
},
onError: () => setToast({ msg: "Failed to save webhook URL", type: "error" }),
onError: () => setToast({ msg: "Failed to save webhook configuration", type: "error" }),
});
if (isLoading) {
@@ -1722,26 +1724,45 @@ function PasswordWebhookSection() {
<Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook
</h2>
<p className="mb-4 text-sm text-gray-500">
When an admin sends a set-password or password-reset link from User Management, a
payload with the link is POSTed to this URL point it at your Power Automate flow (or
similar) that actually delivers the email.
When an admin sends a set-password or password-reset link from User Management, a{" "}
<code className="text-gray-400">{"{ to, subject, body }"}</code> payload is POSTed to
this URL point it at your Power Automate flow (or similar) that actually delivers the
email. The API key, if set, is sent as an <code className="text-gray-400">x-api-key</code>{" "}
header.
</p>
<div className="flex gap-3">
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://prod-00.westeurope.logic.azure.com/..."
className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save
</button>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Webhook URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://prod-00.westeurope.logic.azure.com/..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
API Key {data?.api_key_set && <span className="text-gray-600">(already set leave blank to keep it)</span>}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={data?.api_key_set ? "••••••••" : "Optional"}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save
</button>
</div>
</div>
{!data?.configured && (
<p className="mt-2 text-xs text-amber-400/80">
+39
View File
@@ -43,6 +43,12 @@ import ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel";
import TestPhaseTimeline from "../components/TestPhaseTimeline";
import { createTemplate } from "../api/test-templates";
import {
getProcedureSuggestionsForTest,
approveProcedureSuggestion,
rejectProcedureSuggestion,
} from "../api/procedure-suggestions";
import PendingSuggestionModal from "../components/test-detail/PendingSuggestionModal";
import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime";
// ── Page Component ─────────────────────────────────────────────────
@@ -146,6 +152,28 @@ export default function TestDetailPage() {
staleTime: 5 * 60 * 1000,
});
// A lead opening a test with a pending procedure suggestion (from either
// team, scoped server-side to their own) must resolve it before doing
// anything else on the page — see PendingSuggestionModal below.
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
const { data: pendingSuggestions = [] } = useQuery({
queryKey: ["procedure-suggestions-for-test", testId],
queryFn: () => getProcedureSuggestionsForTest(testId!),
enabled: !!testId && isReviewLead,
});
const blockingSuggestion = pendingSuggestions[0];
const invalidateSuggestions = () =>
queryClient.invalidateQueries({ queryKey: ["procedure-suggestions-for-test", testId] });
const approveSuggestionMutation = useMutation({
mutationFn: approveProcedureSuggestion,
onSuccess: invalidateSuggestions,
});
const rejectSuggestionMutation = useMutation({
mutationFn: rejectProcedureSuggestion,
onSuccess: invalidateSuggestions,
});
// Hydrate drafts from test data — only once per test, on first load or
// when navigating to a different test. Refetches triggered by evidence
// uploads, timer ticks, etc. must NOT re-run this, or they'd stomp
@@ -879,6 +907,17 @@ export default function TestDetailPage() {
/>
)}
{/* Blocking popup — a pending procedure suggestion for this test must
be resolved before the lead can do anything else on the page. */}
{blockingSuggestion && (
<PendingSuggestionModal
suggestion={blockingSuggestion}
isBusy={approveSuggestionMutation.isPending || rejectSuggestionMutation.isPending}
onApprove={() => approveSuggestionMutation.mutate(blockingSuggestion.id)}
onReject={() => rejectSuggestionMutation.mutate(blockingSuggestion.id)}
/>
)}
{/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */}
{resolveDisputeModalOpen && (
<ResolveDisputeModal
+52 -7
View File
@@ -159,13 +159,25 @@ export default function UsersPage() {
{user.email || "—"}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
roleBadgeColors[user.role] || roleBadgeColors.viewer
}`}
>
{user.role.replace(/_/g, " ")}
</span>
<div className="flex flex-wrap gap-1">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
roleBadgeColors[user.role] || roleBadgeColors.viewer
}`}
>
{user.role.replace(/_/g, " ")}
</span>
{user.extra_roles?.map((role) => (
<span
key={role}
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium opacity-60 ${
roleBadgeColors[role] || roleBadgeColors.viewer
}`}
>
{role.replace(/_/g, " ")}
</span>
))}
</div>
</td>
<td className="px-6 py-4">
{user.is_active ? (
@@ -408,14 +420,25 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
full_name: user.full_name || "",
email: user.email || "",
role: user.role,
extra_roles: user.extra_roles || [],
});
const toggleExtraRole = (role: string) => {
setFormData((f) => ({
...f,
extra_roles: f.extra_roles.includes(role)
? f.extra_roles.filter((r) => r !== role)
: [...f.extra_roles, role],
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
full_name: formData.full_name || undefined,
email: formData.email || undefined,
role: formData.role,
extra_roles: formData.extra_roles,
});
};
@@ -471,6 +494,28 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300">
Additional Roles <span className="text-gray-500">(switchable via the top-bar role switcher)</span>
</label>
<div className="mt-1.5 grid grid-cols-2 gap-1.5">
{ROLES.filter((r) => r.value !== formData.role).map((role) => (
<label
key={role.value}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-sm text-gray-300"
>
<input
type="checkbox"
checked={formData.extra_roles.includes(role.value)}
onChange={() => toggleExtraRole(role.value)}
className="h-3.5 w-3.5 rounded border-gray-600 bg-gray-900 text-cyan-500 focus:ring-cyan-500"
/>
{role.label}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
+1
View File
@@ -7,6 +7,7 @@ export interface User {
username: string;
full_name?: string | null;
role: string;
extra_roles?: string[];
must_change_password?: boolean;
}