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);
}),