You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/10/01 18:10:55 UTC

(airavata-custos) branch amie-account-creation updated: Show held and refused AMIE packets and require a reason to deny a cluster account (#583)

This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch amie-account-creation
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


The following commit(s) were added to refs/heads/amie-account-creation by this push:
     new 4f575eba3 Show held and refused AMIE packets and require a reason to deny a cluster account (#583)
4f575eba3 is described below

commit 4f575eba368203e04792e4e3c545ef7650ac1875
Author: Lahiru Jayathilake <la...@gmail.com>
AuthorDate: Thu Oct 1 14:10:48 2026 -0400

    Show held and refused AMIE packets and require a reason to deny a cluster account (#583)
---
 .../connectors/amie/__fixtures__/packets.json      | 28 +++++++++++++++++++++-
 .../amie/__tests__/PacketStatusBadge.test.tsx      |  5 ++++
 .../connectors/amie/__tests__/schemas.test.ts      | 16 +++++++++++++
 .../amie/components/PacketDetailDrawer.tsx         | 13 ++++++++++
 .../amie/components/PacketInboxTable.tsx           | 17 +++++++++----
 .../amie/components/PacketStatusBadge.tsx          |  7 ++++--
 .../amie/components/PacketsTrendChart.tsx          | 23 ++++++++++++------
 web/src/features/connectors/amie/schemas.ts        |  9 ++++++-
 web/src/features/connectors/amie/types.ts          | 21 +++++++++++++++-
 .../__tests__/ReviewClusterAccountDialog.test.tsx  | 19 +++++++++++++++
 .../components/ReviewClusterAccountDialog.tsx      |  7 +++---
 web/src/generated/core/sdk.gen.ts                  |  2 +-
 web/src/generated/core/types.gen.ts                | 14 +++++++----
 web/src/generated/core/zod.gen.ts                  |  2 +-
 web/src/mocks/handlers/clusters.ts                 |  5 +++-
 15 files changed, 161 insertions(+), 27 deletions(-)

diff --git a/web/src/features/connectors/amie/__fixtures__/packets.json b/web/src/features/connectors/amie/__fixtures__/packets.json
index d6880aa44..3751362cc 100644
--- a/web/src/features/connectors/amie/__fixtures__/packets.json
+++ b/web/src/features/connectors/amie/__fixtures__/packets.json
@@ -66,9 +66,35 @@
       "decoded_at": "2026-06-08T14:21:32.412Z",
       "retries": 1,
       "last_error": "create account failed: cluster not reachable"
+    },
+    {
+      "id": "pkt-101",
+      "amie_id": "1234601",
+      "type": "request_account_create",
+      "status": "WAITING_APPROVAL",
+      "source": "access",
+      "raw_json": "{\"packet_type\":\"request_account_create\",\"body\":{\"GrantNumber\":\"BIO130001\",\"UserPersonID\":\"dana\"}}",
+      "decoded_payload": { "GrantNumber": "BIO130001", "UserPersonID": "dana" },
+      "received_at": "2026-06-08T15:02:10Z",
+      "updated_at": "2026-06-08T15:02:11Z",
+      "decoded_at": "2026-06-08T15:02:11Z",
+      "retries": 0
+    },
+    {
+      "id": "pkt-102",
+      "amie_id": "1234602",
+      "type": "request_account_create",
+      "status": "REFUSED",
+      "source": "access",
+      "raw_json": "{\"packet_type\":\"request_account_create\",\"body\":{\"GrantNumber\":\"BIO130001\",\"UserPersonID\":\"evan\"}}",
+      "decoded_payload": { "GrantNumber": "BIO130001", "UserPersonID": "evan" },
+      "received_at": "2026-06-08T15:10:40Z",
+      "updated_at": "2026-06-08T15:10:41Z",
+      "decoded_at": "2026-06-08T15:10:41Z",
+      "retries": 0
     }
   ],
-  "total": 5,
+  "total": 7,
   "limit": 100,
   "offset": 0
 }
diff --git a/web/src/features/connectors/amie/__tests__/PacketStatusBadge.test.tsx b/web/src/features/connectors/amie/__tests__/PacketStatusBadge.test.tsx
index 56372514f..f79dc40a1 100644
--- a/web/src/features/connectors/amie/__tests__/PacketStatusBadge.test.tsx
+++ b/web/src/features/connectors/amie/__tests__/PacketStatusBadge.test.tsx
@@ -25,6 +25,11 @@ describe("PacketStatusBadge", () => {
     expect(screen.getByText("PROCESSED")).toBeInTheDocument();
   });
 
+  it("shows a held packet as WAITING APPROVAL, without the underscore", () => {
+    render(<PacketStatusBadge status="WAITING_APPROVAL" />);
+    expect(screen.getByText("WAITING APPROVAL")).toBeInTheDocument();
+  });
+
   it("adds the loud '!' marker on aged FAILED packets", () => {
     const { container } = render(<PacketStatusBadge status="FAILED" ageHours={36} />);
     expect(container.textContent).toContain("FAILED");
diff --git a/web/src/features/connectors/amie/__tests__/schemas.test.ts b/web/src/features/connectors/amie/__tests__/schemas.test.ts
index 822b81efa..aaec38eb6 100644
--- a/web/src/features/connectors/amie/__tests__/schemas.test.ts
+++ b/web/src/features/connectors/amie/__tests__/schemas.test.ts
@@ -56,6 +56,22 @@ describe("amie schemas", () => {
     expect(parsed.linked_entity?.type).toBe("project");
   });
 
+  // Make sure a packet whose reply is held or refused parses, since one
+  // packet that fails to parse makes the whole inbox list fail.
+  it.each(["WAITING_APPROVAL", "REFUSED"])("packetSchema accepts a %s packet", (status) => {
+    const parsed = packetSchema.parse({
+      id: "pkt-x",
+      amie_id: "1",
+      type: "request_account_create",
+      status,
+      source: "access",
+      received_at: "2026-06-08T00:00:00Z",
+      updated_at: "2026-06-08T00:00:00Z",
+      retries: 0,
+    });
+    expect(parsed.status).toBe(status);
+  });
+
   it("packetSchema rejects unknown status", () => {
     expect(() =>
       packetSchema.parse({
diff --git a/web/src/features/connectors/amie/components/PacketDetailDrawer.tsx b/web/src/features/connectors/amie/components/PacketDetailDrawer.tsx
index d15a16bef..34532f854 100644
--- a/web/src/features/connectors/amie/components/PacketDetailDrawer.tsx
+++ b/web/src/features/connectors/amie/components/PacketDetailDrawer.tsx
@@ -134,6 +134,19 @@ function OverviewTab({ packet }: { packet: Packet }) {
         <dt className="text-muted-foreground">Updated</dt>
         <dd className="tabular-nums">{formatDate(packet.updated_at)}</dd>
       </dl>
+      {packet.status === "WAITING_APPROVAL" ? (
+        <div className="rounded-md bg-[color:var(--tone-warn-bg)] p-3 text-sm text-[color:var(--tone-warn-fg)]">
+          The reply to ACCESS is held until an admin reviews the cluster account.{" "}
+          <Link href="/admin/users/cluster-accounts" className="font-medium underline">
+            Review cluster accounts
+          </Link>
+        </div>
+      ) : null}
+      {packet.status === "REFUSED" ? (
+        <div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
+          An admin denied the cluster account. ACCESS was sent a failure with the reason.
+        </div>
+      ) : null}
       {packet.last_error ? (
         <div className="rounded-md border border-[color:var(--custos-red-200)] bg-[color:var(--custos-red-50)] p-3 text-sm text-[color:var(--custos-red-700)]">
           <strong>Last error:</strong> {packet.last_error}
diff --git a/web/src/features/connectors/amie/components/PacketInboxTable.tsx b/web/src/features/connectors/amie/components/PacketInboxTable.tsx
index bba22dae7..54e106512 100644
--- a/web/src/features/connectors/amie/components/PacketInboxTable.tsx
+++ b/web/src/features/connectors/amie/components/PacketInboxTable.tsx
@@ -25,7 +25,13 @@ import { TableSkeleton } from "@/shared/ui/Loading";
 import { Button } from "@/shared/ui/button";
 import { Input } from "@/shared/ui/input";
 import { Label } from "@/shared/ui/label";
-import { PACKET_TYPES, type Packet, type PacketStatus } from "../types";
+import {
+  PACKET_STATUSES,
+  PACKET_TYPES,
+  type Packet,
+  type PacketStatus,
+  packetStatusLabel,
+} from "../types";
 import { ageHoursOf, formatDate } from "../utils";
 import { PacketStatusBadge } from "./PacketStatusBadge";
 
@@ -230,10 +236,11 @@ export function PacketInboxTable({
             className="rounded-md border bg-background px-3 py-1.5 text-sm"
           >
             <option value="all">All</option>
-            <option value="NEW">NEW</option>
-            <option value="DECODED">DECODED</option>
-            <option value="PROCESSED">PROCESSED</option>
-            <option value="FAILED">FAILED</option>
+            {PACKET_STATUSES.map((s) => (
+              <option key={s} value={s}>
+                {packetStatusLabel(s)}
+              </option>
+            ))}
           </select>
         </div>
 
diff --git a/web/src/features/connectors/amie/components/PacketStatusBadge.tsx b/web/src/features/connectors/amie/components/PacketStatusBadge.tsx
index f380433f3..e51cfb4ae 100644
--- a/web/src/features/connectors/amie/components/PacketStatusBadge.tsx
+++ b/web/src/features/connectors/amie/components/PacketStatusBadge.tsx
@@ -16,13 +16,16 @@
 // under the License.
 
 import { cn } from "@/lib/utils";
-import type { PacketStatus, ReplyStatus } from "../types";
+import { type PacketStatus, type ReplyStatus, packetStatusLabel } from "../types";
 
 const packetStyles: Record<PacketStatus, string> = {
   NEW: "bg-[color:var(--custos-blue-50)] text-[color:var(--custos-blue-700)]",
   DECODED: "bg-[color:var(--custos-amber-50)] text-[color:var(--custos-amber-700)]",
   PROCESSED: "bg-[color:var(--custos-green-50)] text-[color:var(--custos-green-700)]",
   FAILED: "bg-[color:var(--custos-red-50)] text-[color:var(--custos-red-700)]",
+  WAITING_APPROVAL: "bg-[color:var(--custos-purple-50)] text-[color:var(--custos-purple-700)]",
+  // A refusal is an admin's decision, not an error, so it stays neutral.
+  REFUSED: "bg-[color:var(--custos-gray-100)] text-[color:var(--custos-gray-700)]",
 };
 
 const replyStyles: Record<ReplyStatus, string> = {
@@ -52,7 +55,7 @@ export function PacketStatusBadge({
         className,
       )}
     >
-      {status}
+      {packetStatusLabel(status)}
       {loud ? <span aria-hidden="true">!</span> : null}
     </span>
   );
diff --git a/web/src/features/connectors/amie/components/PacketsTrendChart.tsx b/web/src/features/connectors/amie/components/PacketsTrendChart.tsx
index f19ecdb87..c6e8ab80e 100644
--- a/web/src/features/connectors/amie/components/PacketsTrendChart.tsx
+++ b/web/src/features/connectors/amie/components/PacketsTrendChart.tsx
@@ -19,15 +19,24 @@
 
 import * as React from "react";
 import { StackedAreaUsage } from "@/shared/charts/StackedAreaUsage";
-import type { PacketStatBucket, PacketStatus } from "../types";
+import { type PacketStatBucket, type PacketStatus, packetStatusLabel } from "../types";
 
-const STATUS_ORDER: PacketStatus[] = ["PROCESSED", "DECODED", "NEW", "FAILED"];
+const STATUS_ORDER: PacketStatus[] = [
+  "PROCESSED",
+  "DECODED",
+  "NEW",
+  "WAITING_APPROVAL",
+  "REFUSED",
+  "FAILED",
+];
 // Recharts paints area fill AND tooltip label in the same color on white.
 // Use 700-step so tooltip labels clear WCAG AA 4.5:1.
 const STATUS_COLORS: Record<PacketStatus, string> = {
   PROCESSED: "var(--custos-green-700)",
   DECODED: "var(--custos-amber-700)",
   NEW: "var(--custos-blue-700)",
+  WAITING_APPROVAL: "var(--custos-purple-700)",
+  REFUSED: "var(--custos-gray-500)",
   FAILED: "var(--custos-red-700)",
 };
 
@@ -42,10 +51,10 @@ export function PacketsTrendChart({ buckets, height = 220 }: PacketsTrendChartPr
     for (const b of buckets) {
       const row =
         days.get(b.date) ??
-        ({ date: b.date, NEW: 0, DECODED: 0, PROCESSED: 0, FAILED: 0 } as Record<
-          PacketStatus,
-          number
-        > & { date: string });
+        ({
+          date: b.date,
+          ...Object.fromEntries(STATUS_ORDER.map((s) => [s, 0])),
+        } as Record<PacketStatus, number> & { date: string });
       row[b.status] = (row[b.status] ?? 0) + b.count;
       days.set(b.date, row);
     }
@@ -79,7 +88,7 @@ export function PacketsTrendChart({ buckets, height = 220 }: PacketsTrendChartPr
               className="inline-block size-2 rounded-full"
               style={{ background: STATUS_COLORS[s] }}
             />
-            {s}
+            {packetStatusLabel(s)}
           </li>
         ))}
       </ul>
diff --git a/web/src/features/connectors/amie/schemas.ts b/web/src/features/connectors/amie/schemas.ts
index 0a0198e66..0bb865fe7 100644
--- a/web/src/features/connectors/amie/schemas.ts
+++ b/web/src/features/connectors/amie/schemas.ts
@@ -19,7 +19,14 @@
 
 import { z } from "zod";
 
-export const packetStatusSchema = z.enum(["NEW", "DECODED", "PROCESSED", "FAILED"]);
+export const packetStatusSchema = z.enum([
+  "NEW",
+  "DECODED",
+  "PROCESSED",
+  "FAILED",
+  "WAITING_APPROVAL",
+  "REFUSED",
+]);
 
 export const linkedEntityRefSchema = z.object({
   type: z.enum(["project", "account", "person", "user_merge"]),
diff --git a/web/src/features/connectors/amie/types.ts b/web/src/features/connectors/amie/types.ts
index a364d59d5..dd59a4c16 100644
--- a/web/src/features/connectors/amie/types.ts
+++ b/web/src/features/connectors/amie/types.ts
@@ -15,7 +15,26 @@
 // specific language governing permissions and limitations
 // under the License.
 
-export type PacketStatus = "NEW" | "DECODED" | "PROCESSED" | "FAILED";
+export type PacketStatus =
+  | "NEW"
+  | "DECODED"
+  | "PROCESSED"
+  | "FAILED"
+  | "WAITING_APPROVAL"
+  | "REFUSED";
+
+export const PACKET_STATUSES: PacketStatus[] = [
+  "NEW",
+  "DECODED",
+  "PROCESSED",
+  "FAILED",
+  "WAITING_APPROVAL",
+  "REFUSED",
+];
+
+export function packetStatusLabel(status: PacketStatus): string {
+  return status.replaceAll("_", " ");
+}
 
 export const PACKET_TYPES = [
   "request_project_create",
diff --git a/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx b/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
index 18ed075e4..9a1b43d1d 100644
--- a/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
+++ b/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
@@ -42,6 +42,25 @@ describe("ReviewClusterAccountDialog", () => {
     expect(onConfirm).toHaveBeenCalledWith("Not on the list");
   });
 
+  // Make sure a denial cannot be sent without a reason, since the backend
+  // refuses it and the reason is passed on to whoever requested the account.
+  it("keeps Deny disabled until a reason is typed", () => {
+    render(
+      <ReviewClusterAccountDialog
+        account={pending}
+        action="deny"
+        onOpenChange={vi.fn()}
+        onConfirm={vi.fn()}
+        isPending={false}
+      />,
+    );
+    expect(screen.getByRole("button", { name: "Deny" })).toBeDisabled();
+    fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "   " } });
+    expect(screen.getByRole("button", { name: "Deny" })).toBeDisabled();
+    fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "Not on the list" } });
+    expect(screen.getByRole("button", { name: "Deny" })).toBeEnabled();
+  });
+
   // Make sure the approval names the account the admin is about to create, so
   // a wrong row is caught before it goes to the cluster.
   it("restates the account before approving", () => {
diff --git a/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
index d55fcd1e5..f51857a85 100644
--- a/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
+++ b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
@@ -51,7 +51,7 @@ const copy: Record<ReviewAction, { title: string; description: string; button: s
   deny: {
     title: "Deny cluster account",
     description:
-      "No account is created on the cluster. The user keeps portal access and can be approved later.",
+      "No account is created on the cluster. The user keeps portal access and can be approved later. The reason is passed on to whoever requested the account.",
     button: "Deny",
   },
 };
@@ -95,10 +95,11 @@ export function ReviewClusterAccountDialog({
         ) : null}
         {action === "deny" ? (
           <div className="space-y-2">
-            <Label htmlFor="review-note">Reason (optional, kept in the audit log)</Label>
+            <Label htmlFor="review-note">Reason (required, kept in the audit log)</Label>
             <textarea
               id="review-note"
               rows={3}
+              required
               className="w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
               value={note}
               onChange={(e) => setNote(e.target.value)}
@@ -119,7 +120,7 @@ export function ReviewClusterAccountDialog({
           <Button
             type="button"
             variant={action === "deny" ? "destructive" : "default"}
-            disabled={isPending}
+            disabled={isPending || (action === "deny" && note.trim() === "")}
             onClick={() => onConfirm(note.trim())}
           >
             {isPending ? `${copy[action].button}…` : copy[action].button}
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index 6a3df6058..b1d574cf0 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -650,7 +650,7 @@ export const postComputeClusterUsersByIdApprove = <ThrowOnError extends boolean
 /**
  * Deny a cluster account
  *
- * Records the caller's decision and the note. No account is created on the cluster. The user keeps portal access.
+ * Records the caller's decision and the reason. No account is created on the cluster, and the reason is passed on to whoever requested the account. The user keeps portal access.
  */
 export const postComputeClusterUsersByIdDeny = <ThrowOnError extends boolean = false>(options: Options<PostComputeClusterUsersByIdDenyData, ThrowOnError>): RequestResult<PostComputeClusterUsersByIdDenyResponses, PostComputeClusterUsersByIdDenyErrors, ThrowOnError> => (options.client ?? client).post<PostComputeClusterUsersByIdDenyResponses, PostComputeClusterUsersByIdDenyErrors, ThrowOnError>({
     security: [{ name: 'Authorization', type: 'apiKey' }],
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index c1e4a02ef..513732b42 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -66,7 +66,7 @@ export type ClusterAccountResponse = {
     review_note?: string;
     reviewed_at?: string;
     /**
-     * The admin who approved or denied the account.
+     * The admin who approved or denied the cluster account.
      */
     reviewed_by?: string;
     user_id?: string;
@@ -326,7 +326,7 @@ export type ComputeClusterUser = {
     review_note?: string;
     reviewed_at?: string;
     /**
-     * The admin who approved or denied the account.
+     * The admin who approved or denied the cluster account.
      */
     reviewed_by?: string;
     user_id?: string;
@@ -2699,9 +2699,9 @@ export type PostComputeClusterUsersByIdApproveResponse = PostComputeClusterUsers
 
 export type PostComputeClusterUsersByIdDenyData = {
     /**
-     * Optional note
+     * Reason for the denial
      */
-    body?: DenyClusterAccountRequest;
+    body: DenyClusterAccountRequest;
     path: {
         /**
          * Compute cluster user ID
@@ -2713,6 +2713,12 @@ export type PostComputeClusterUsersByIdDenyData = {
 };
 
 export type PostComputeClusterUsersByIdDenyErrors = {
+    /**
+     * Reason missing
+     */
+    400: {
+        error?: string;
+    };
     /**
      * Not Found
      */
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index b2eda507e..1ea64e842 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -1175,7 +1175,7 @@ export const zPostComputeClusterUsersByIdApprovePath = z.object({
 export const zPostComputeClusterUsersByIdApproveResponse = zComputeClusterUser;
 
 /**
- * Optional note
+ * Reason for the denial
  */
 export const zPostComputeClusterUsersByIdDenyBody = zDenyClusterAccountRequest;
 
diff --git a/web/src/mocks/handlers/clusters.ts b/web/src/mocks/handlers/clusters.ts
index a5d7de396..aa062497b 100644
--- a/web/src/mocks/handlers/clusters.ts
+++ b/web/src/mocks/handlers/clusters.ts
@@ -58,10 +58,13 @@ export const clustersHandlers = [
       return HttpResponse.json({ error: "already reviewed" }, { status: 409 });
     }
     const body = (await request.json().catch(() => ({}))) as { note?: string };
+    if (!body.note?.trim()) {
+      return HttpResponse.json({ error: "a reason is required to deny" }, { status: 400 });
+    }
     found.approval_status = "DENIED";
     found.reviewed_at = new Date().toISOString();
     found.reviewed_by = "user-admin";
-    found.review_note = body.note || undefined;
+    found.review_note = body.note.trim();
     return HttpResponse.json(found);
   }),