You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/09/30 22:09:34 UTC

(airavata-custos) branch restrict-user-onboarding-web created (now 38d5c1498)

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

lahirujayathilake pushed a change to branch restrict-user-onboarding-web
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git


      at 38d5c1498 Add admin cluster account review and approval UI/UX

This branch includes the following new commits:

     new 38d5c1498 Add admin cluster account review and approval UI/UX

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(airavata-custos) 01/01: Add admin cluster account review and approval UI/UX

Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch restrict-user-onboarding-web
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 38d5c149871f7b865b30e8fe89664be17119dd3e
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Sep 30 17:59:00 2026 -0400

    Add admin cluster account review and approval UI/UX
---
 web/src/app/(portal)/admin/users/UsersNav.tsx      |  26 +-
 .../admin/users/__tests__/UsersNav.test.tsx        |  23 ++
 .../cluster-accounts/ClusterAccountsContainer.tsx  | 181 +++++++++++
 .../(portal)/admin/users/cluster-accounts/page.tsx |  28 ++
 .../__fixtures__/cluster-accounts.json             |  69 +++++
 .../__tests__/ClusterAccountsTable.test.tsx        | 100 +++++++
 .../__tests__/ReviewClusterAccountDialog.test.tsx  |  62 ++++
 .../core/cluster-accounts/__tests__/api.test.ts    |  80 +++++
 web/src/features/core/cluster-accounts/api.ts      |  50 ++++
 .../components/ClusterAccountsTable.tsx            | 155 ++++++++++
 .../components/ReviewClusterAccountDialog.tsx      | 131 ++++++++
 web/src/features/core/cluster-accounts/queries.ts  |  62 ++++
 web/src/features/core/cluster-accounts/schemas.ts  |  53 ++++
 web/src/features/core/cluster-accounts/types.ts    |  24 ++
 web/src/generated/core/index.ts                    |   4 +-
 web/src/generated/core/sdk.gen.ts                  |  74 ++++-
 web/src/generated/core/types.gen.ts                | 333 ++++++++++++++++++++-
 web/src/generated/core/zod.gen.ts                  | 189 +++++++++++-
 web/src/mocks/handlers/clusters.ts                 |  39 +++
 web/src/shared/casl/abilities.ts                   |   1 +
 web/src/shared/ui/button.tsx                       |   7 +-
 21 files changed, 1672 insertions(+), 19 deletions(-)

diff --git a/web/src/app/(portal)/admin/users/UsersNav.tsx b/web/src/app/(portal)/admin/users/UsersNav.tsx
index ca8e233c9..1dbd1201e 100644
--- a/web/src/app/(portal)/admin/users/UsersNav.tsx
+++ b/web/src/app/(portal)/admin/users/UsersNav.tsx
@@ -19,11 +19,18 @@
 
 import Link from "next/link";
 import { usePathname } from "next/navigation";
+import { useClusterAccounts } from "@/features/core/cluster-accounts/queries";
 import { cn } from "@/lib/utils";
 import { useAbility } from "@/shared/casl/AbilityProvider";
 
 const TABS = [
   { href: "/admin/users/management", label: "User Management" },
+  {
+    href: "/admin/users/cluster-accounts",
+    label: "Cluster Accounts",
+    ability: { action: "read", subject: "Cluster" } as const,
+    badge: "pending-cluster-accounts",
+  },
   {
     href: "/admin/users/roles",
     label: "Role Management",
@@ -31,6 +38,22 @@ const TABS = [
   },
 ] as const;
 
+// Shows how many accounts wait for approval, so the admin sees it from the
+// other tabs too.
+function PendingClusterAccountsBadge() {
+  const query = useClusterAccounts({ approval_status: "PENDING", limit: 1 });
+  const count = query.data?.total ?? 0;
+  if (count === 0) return null;
+  return (
+    <span
+      aria-label={`${count} pending approval`}
+      className="rounded-full bg-[color:var(--tone-warn-bg)] px-2 py-0.5 text-[11px] font-semibold tabular-nums text-[color:var(--tone-warn-fg)]"
+    >
+      {count}
+    </span>
+  );
+}
+
 export function UsersNav({ rightSlot }: { rightSlot?: React.ReactNode }) {
   const pathname = usePathname();
   const ability = useAbility();
@@ -51,13 +74,14 @@ export function UsersNav({ rightSlot }: { rightSlot?: React.ReactNode }) {
               <Link
                 href={tab.href}
                 className={cn(
-                  "inline-flex items-center px-4 py-2 text-sm font-medium transition-colors",
+                  "inline-flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors",
                   active
                     ? "border-b-2 border-brand text-foreground"
                     : "border-b-2 border-transparent text-muted-foreground hover:text-foreground",
                 )}
               >
                 {tab.label}
+                {"badge" in tab ? <PendingClusterAccountsBadge /> : null}
               </Link>
             </li>
           );
diff --git a/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx b/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx
index 57dcc41ef..449471aa6 100644
--- a/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx
+++ b/web/src/app/(portal)/admin/users/__tests__/UsersNav.test.tsx
@@ -31,9 +31,32 @@ vi.mock("@/shared/casl/AbilityProvider", () => ({
   useAbility: () => defineAbilitiesFor(currentPrivileges),
 }));
 
+const pendingMocks = vi.hoisted(() => ({ total: 0 }));
+
+vi.mock("@/features/core/cluster-accounts/queries", () => ({
+  useClusterAccounts: () => ({ data: { items: [], total: pendingMocks.total } }),
+}));
+
 describe("UsersNav", () => {
   beforeEach(() => {
     currentPrivileges = [];
+    pendingMocks.total = 0;
+  });
+
+  // Make sure the approvals tab is only there for someone who can read
+  // clusters, and that it carries the number of accounts waiting.
+  it("shows Cluster Accounts with the pending count for cluster readers", () => {
+    currentPrivileges = ["core:users:read", "core:clusters:read"];
+    pendingMocks.total = 3;
+    render(<UsersNav />);
+    const tab = screen.getByRole("link", { name: /Cluster Accounts/ });
+    expect(tab).toHaveTextContent("3");
+  });
+
+  it("hides Cluster Accounts without clusters:read", () => {
+    currentPrivileges = ["core:users:read"];
+    render(<UsersNav />);
+    expect(screen.queryByRole("link", { name: /Cluster Accounts/ })).toBeNull();
   });
 
   it("hides Role Management without roles:manage", () => {
diff --git a/web/src/app/(portal)/admin/users/cluster-accounts/ClusterAccountsContainer.tsx b/web/src/app/(portal)/admin/users/cluster-accounts/ClusterAccountsContainer.tsx
new file mode 100644
index 000000000..42e25f2b6
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/cluster-accounts/ClusterAccountsContainer.tsx
@@ -0,0 +1,181 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { toast } from "sonner";
+import { ClusterAccountsTable } from "@/features/core/cluster-accounts/components/ClusterAccountsTable";
+import {
+  type ReviewAction,
+  ReviewClusterAccountDialog,
+} from "@/features/core/cluster-accounts/components/ReviewClusterAccountDialog";
+import {
+  useApproveClusterAccount,
+  useClusterAccounts,
+  useDenyClusterAccount,
+} from "@/features/core/cluster-accounts/queries";
+import type { ClusterAccount, ClusterAccountApproval } from "@/features/core/cluster-accounts/schemas";
+import { cn } from "@/lib/utils";
+import { useAbility } from "@/shared/casl/AbilityProvider";
+import {
+  replaceShallowSearchParams,
+  useShallowSearchParams,
+} from "@/shared/hooks/useShallowSearchParams";
+import { ErrorState } from "@/shared/ui/ErrorState";
+
+const PAGE_SIZE = 25;
+
+type Filter = ClusterAccountApproval | "ALL";
+
+const FILTERS: Array<{ value: Filter; label: string; empty: string }> = [
+  { value: "PENDING", label: "Pending approval", empty: "Nothing waiting for approval." },
+  { value: "APPROVED", label: "Approved", empty: "No approved accounts yet." },
+  { value: "DENIED", label: "Denied", empty: "No denied accounts." },
+  { value: "ALL", label: "All", empty: "No cluster accounts yet." },
+];
+
+function readFilter(params: URLSearchParams): Filter {
+  const raw = params.get("status");
+  return FILTERS.some((f) => f.value === raw) ? (raw as Filter) : "PENDING";
+}
+
+export function ClusterAccountsContainer() {
+  const ability = useAbility();
+  const canRead = ability.can("read", "Cluster");
+  const canReview = ability.can("manage", "Cluster");
+  const params = useShallowSearchParams();
+  const filter = readFilter(params);
+  const [page, setPage] = React.useState(1);
+  const [review, setReview] = React.useState<{ account: ClusterAccount; action: ReviewAction } | null>(
+    null,
+  );
+  const [reviewError, setReviewError] = React.useState<string | null>(null);
+
+  const query = useClusterAccounts(
+    {
+      approval_status: filter === "ALL" ? undefined : filter,
+      limit: PAGE_SIZE,
+      offset: (page - 1) * PAGE_SIZE,
+    },
+    { enabled: canRead },
+  );
+  const approve = useApproveClusterAccount();
+  const deny = useDenyClusterAccount();
+
+  function setFilter(next: Filter) {
+    const nextParams = new URLSearchParams(params);
+    if (next === "PENDING") nextParams.delete("status");
+    else nextParams.set("status", next);
+    replaceShallowSearchParams(nextParams);
+    setPage(1);
+  }
+
+  function openReview(action: ReviewAction) {
+    return (account: ClusterAccount) => {
+      setReviewError(null);
+      setReview({ account, action });
+    };
+  }
+
+  async function handleConfirm(note: string) {
+    if (!review) return;
+    setReviewError(null);
+    try {
+      if (review.action === "approve") {
+        await approve.mutateAsync(review.account.id);
+        toast.success(
+          `Cluster account approved for ${review.account.local_username} on ${review.account.cluster_name}`,
+        );
+      } else {
+        await deny.mutateAsync({ id: review.account.id, note });
+        toast.success(
+          `Cluster account denied for ${review.account.local_username} on ${review.account.cluster_name}`,
+        );
+      }
+      setReview(null);
+    } catch (err) {
+      setReviewError(err instanceof Error ? err.message : "The review did not go through.");
+    }
+  }
+
+  if (!canRead) {
+    return <ErrorState heading="Not permitted" message="You cannot view cluster accounts." />;
+  }
+
+  const emptyHeading =
+    FILTERS.find((f) => f.value === filter)?.empty ?? "Nothing waiting for approval.";
+
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-wrap items-center justify-between gap-3">
+        <fieldset
+          aria-label="Filter by status"
+          className="inline-flex overflow-hidden rounded-lg border border-border"
+        >
+          {FILTERS.map((f) => (
+            <button
+              key={f.value}
+              type="button"
+              aria-pressed={filter === f.value}
+              onClick={() => setFilter(f.value)}
+              className={cn(
+                "px-3 py-1.5 text-sm font-medium transition-colors",
+                filter === f.value
+                  ? "bg-muted text-foreground"
+                  : "text-muted-foreground hover:text-foreground",
+              )}
+            >
+              {f.label}
+            </button>
+          ))}
+        </fieldset>
+        <p className="text-sm text-muted-foreground">
+          An account is created on the cluster only after an admin approves it.
+        </p>
+      </div>
+
+      <ClusterAccountsTable
+        rows={query.data?.items ?? []}
+        isLoading={query.isLoading}
+        error={(query.error as Error | null) ?? null}
+        onRetry={() => void query.refetch()}
+        canReview={canReview}
+        onApprove={openReview("approve")}
+        onDeny={openReview("deny")}
+        page={page}
+        pageSize={PAGE_SIZE}
+        total={query.data?.total ?? 0}
+        onPageChange={setPage}
+        emptyHeading={emptyHeading}
+      />
+
+      {canReview ? (
+        <ReviewClusterAccountDialog
+          account={review?.account ?? null}
+          action={review?.action ?? "approve"}
+          onOpenChange={(open) => {
+            if (!open) setReview(null);
+          }}
+          onConfirm={handleConfirm}
+          isPending={approve.isPending || deny.isPending}
+          error={reviewError}
+        />
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/app/(portal)/admin/users/cluster-accounts/page.tsx b/web/src/app/(portal)/admin/users/cluster-accounts/page.tsx
new file mode 100644
index 000000000..0bc36b19a
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/cluster-accounts/page.tsx
@@ -0,0 +1,28 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { UsersNav } from "../UsersNav";
+import { ClusterAccountsContainer } from "./ClusterAccountsContainer";
+
+export default function ClusterAccountsPage() {
+  return (
+    <div className="space-y-4">
+      <UsersNav />
+      <ClusterAccountsContainer />
+    </div>
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json b/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json
new file mode 100644
index 000000000..d81954462
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json
@@ -0,0 +1,69 @@
+[
+  {
+    "id": "ccu-pending-1",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-jane-doe",
+    "local_username": "jdoe",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "PENDING",
+    "display_name": "Jane Doe",
+    "email": "jdoe@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-pending-2",
+    "compute_cluster_id": "cluster-b",
+    "user_id": "user-marcus-lee",
+    "local_username": "mlee",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "PENDING",
+    "display_name": "Marcus Lee",
+    "email": "mlee@example.edu",
+    "cluster_name": "cluster-b"
+  },
+  {
+    "id": "ccu-approved-1",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-wei-zhang",
+    "local_username": "wzhang",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "APPROVED",
+    "reviewed_at": "2026-09-29T14:00:00Z",
+    "reviewed_by": "user-admin",
+    "display_name": "Wei Zhang",
+    "email": "wzhang@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-active-1",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-tom-becker",
+    "local_username": "tbecker",
+    "access_level": "USER",
+    "provisioned_at": "2026-09-27T09:30:00Z",
+    "approval_status": "APPROVED",
+    "reviewed_at": "2026-09-26T16:10:00Z",
+    "reviewed_by": "user-admin",
+    "display_name": "Tom Becker",
+    "email": "tbecker@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-denied-1",
+    "compute_cluster_id": "cluster-b",
+    "user_id": "user-sam-okafor",
+    "local_username": "sokafor",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "DENIED",
+    "reviewed_at": "2026-09-24T11:00:00Z",
+    "reviewed_by": "user-admin",
+    "review_note": "Not on the approved collaborator list.",
+    "display_name": "Sam Okafor",
+    "email": "sokafor@example.edu",
+    "cluster_name": "cluster-b"
+  }
+]
diff --git a/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx b/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx
new file mode 100644
index 000000000..ef12e76e4
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx
@@ -0,0 +1,100 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import fixture from "../__fixtures__/cluster-accounts.json";
+import { ClusterAccountsTable, clusterAccountStatus } from "../components/ClusterAccountsTable";
+import type { ClusterAccount } from "../schemas";
+
+const rows = fixture as ClusterAccount[];
+const byId = (id: string) => rows.find((r) => r.id === id) as ClusterAccount;
+
+function renderTable(canReview: boolean, shown: ClusterAccount[] = rows) {
+  const onApprove = vi.fn();
+  const onDeny = vi.fn();
+  render(
+    <ClusterAccountsTable
+      rows={shown}
+      isLoading={false}
+      error={null}
+      canReview={canReview}
+      onApprove={onApprove}
+      onDeny={onDeny}
+      page={1}
+      pageSize={25}
+      total={shown.length}
+      onPageChange={vi.fn()}
+      emptyHeading="Nothing waiting for approval."
+    />,
+  );
+  return { onApprove, onDeny };
+}
+
+describe("clusterAccountStatus", () => {
+  // Make sure an approved account reads as provisioning until it exists on
+  // the cluster, so the admin can tell approval from a working login.
+  it("tells approved from active by provisioned_at", () => {
+    expect(clusterAccountStatus(byId("ccu-approved-1")).label).toBe("Approved · provisioning");
+    expect(clusterAccountStatus(byId("ccu-active-1")).label).toBe("Active");
+    expect(clusterAccountStatus(byId("ccu-pending-1")).label).toBe("Pending approval");
+    expect(clusterAccountStatus(byId("ccu-denied-1")).label).toBe("Denied");
+  });
+});
+
+describe("ClusterAccountsTable", () => {
+  // Make sure a pending account offers both decisions, a denied one can only
+  // be approved, and an approved one cannot be reviewed again.
+  it("offers the decisions each status allows", () => {
+    renderTable(true);
+    const pending = screen.getByText("jdoe").closest("tr") as HTMLElement;
+    expect(pending.querySelector("button")).not.toBeNull();
+    expect(pending.textContent).toContain("Deny");
+    expect(pending.textContent).toContain("Approve");
+
+    const denied = screen.getByText("sokafor").closest("tr") as HTMLElement;
+    expect(denied.textContent).toContain("Approve");
+    expect(denied.textContent).not.toContain("Deny");
+
+    const approved = screen.getByText("wzhang").closest("tr") as HTMLElement;
+    expect(approved.querySelector("button")).toBeNull();
+  });
+
+  // Make sure a reader without the write privilege sees the list but no
+  // decision buttons.
+  it("hides the decisions without the review privilege", () => {
+    renderTable(false);
+    expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
+    expect(screen.queryByRole("button", { name: "Deny" })).toBeNull();
+    expect(screen.getByText("jdoe")).toBeInTheDocument();
+  });
+
+  it("hands the clicked account to the approve and deny callbacks", () => {
+    const { onApprove, onDeny } = renderTable(true, [byId("ccu-pending-1")]);
+    fireEvent.click(screen.getByRole("button", { name: "Approve" }));
+    expect(onApprove).toHaveBeenCalledWith(expect.objectContaining({ id: "ccu-pending-1" }));
+    fireEvent.click(screen.getByRole("button", { name: "Deny" }));
+    expect(onDeny).toHaveBeenCalledWith(expect.objectContaining({ id: "ccu-pending-1" }));
+  });
+
+  // Make sure the reason an admin gave is visible on the denied row, since it
+  // is the only place the portal shows it.
+  it("shows the denial note on the row", () => {
+    renderTable(false, [byId("ccu-denied-1")]);
+    expect(screen.getByText("Not on the approved collaborator list.")).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx b/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
new file mode 100644
index 000000000..18ed075e4
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import fixture from "../__fixtures__/cluster-accounts.json";
+import { ReviewClusterAccountDialog } from "../components/ReviewClusterAccountDialog";
+import type { ClusterAccount } from "../schemas";
+
+const pending = (fixture as ClusterAccount[])[0] as ClusterAccount;
+
+describe("ReviewClusterAccountDialog", () => {
+  // Make sure the denial passes the typed reason through, since it ends up in
+  // the audit log.
+  it("sends the note with a denial", () => {
+    const onConfirm = vi.fn();
+    render(
+      <ReviewClusterAccountDialog
+        account={pending}
+        action="deny"
+        onOpenChange={vi.fn()}
+        onConfirm={onConfirm}
+        isPending={false}
+      />,
+    );
+    fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "  Not on the list " } });
+    fireEvent.click(screen.getByRole("button", { name: "Deny" }));
+    expect(onConfirm).toHaveBeenCalledWith("Not on the list");
+  });
+
+  // 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", () => {
+    render(
+      <ReviewClusterAccountDialog
+        account={pending}
+        action="approve"
+        onOpenChange={vi.fn()}
+        onConfirm={vi.fn()}
+        isPending={false}
+      />,
+    );
+    expect(screen.getByText("jdoe")).toBeInTheDocument();
+    expect(screen.getByText("cluster-a")).toBeInTheDocument();
+    expect(screen.getByRole("status")).toHaveTextContent("recorded as the approver");
+    expect(screen.queryByLabelText(/Reason/)).toBeNull();
+  });
+});
diff --git a/web/src/features/core/cluster-accounts/__tests__/api.test.ts b/web/src/features/core/cluster-accounts/__tests__/api.test.ts
new file mode 100644
index 000000000..d3c7555a3
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/__tests__/api.test.ts
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { approveClusterAccount, denyClusterAccount, listClusterAccounts } from "../api";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function mockResponse(status: number, body?: unknown): Response {
+  return new Response(body === undefined ? null : JSON.stringify(body), {
+    status,
+    headers: { "content-type": "application/json" },
+  });
+}
+
+const account = {
+  id: "ccu-1",
+  compute_cluster_id: "cluster-a",
+  user_id: "user-1",
+  local_username: "jdoe",
+  approval_status: "PENDING",
+  provisioned_at: null,
+  display_name: "Jane Doe",
+  email: "jdoe@example.edu",
+  cluster_name: "cluster-a",
+};
+
+afterEach(() => fetchMock.mockReset());
+
+describe("cluster accounts API", () => {
+  // Make sure the status filter reaches the backend and a null provisioned_at
+  // is accepted, since a pending account has no provisioned time yet.
+  it("lists accounts by approval status", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, { items: [account], total: 1 }));
+    const result = await listClusterAccounts({ approval_status: "PENDING", limit: 25 });
+    expect(result.items[0]?.id).toBe("ccu-1");
+    const url = String(fetchMock.mock.calls[0]?.[0]);
+    expect(url).toContain("/compute-cluster-users?");
+    expect(url).toContain("approval_status=PENDING");
+    expect(url).toContain("limit=25");
+  });
+
+  // Make sure a denial sends the note, since it is what the audit log keeps.
+  it("posts the note when denying", async () => {
+    fetchMock.mockResolvedValueOnce(
+      mockResponse(200, { id: "ccu-1", approval_status: "DENIED", provisioned_at: null }),
+    );
+    await denyClusterAccount("ccu-1", "Not on the list");
+    expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/compute-cluster-users/ccu-1/deny");
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
+      method: "POST",
+      body: JSON.stringify({ note: "Not on the list" }),
+    });
+  });
+
+  it("posts to the approve route", async () => {
+    fetchMock.mockResolvedValueOnce(
+      mockResponse(200, { id: "ccu-1", approval_status: "APPROVED", provisioned_at: null }),
+    );
+    const result = await approveClusterAccount("ccu-1");
+    expect(result.approval_status).toBe("APPROVED");
+    expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/compute-cluster-users/ccu-1/approve");
+    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
+  });
+});
diff --git a/web/src/features/core/cluster-accounts/api.ts b/web/src/features/core/cluster-accounts/api.ts
new file mode 100644
index 000000000..b512d644a
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/api.ts
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { apiFetch } from "@/shared/api/client";
+import {
+  type ClusterAccountListResponse,
+  type ReviewedClusterUser,
+  clusterAccountListResponseSchema,
+  reviewedClusterUserSchema,
+} from "./schemas";
+import type { ClusterAccountListParams } from "./types";
+
+export async function listClusterAccounts(
+  params: ClusterAccountListParams = {},
+): Promise<ClusterAccountListResponse> {
+  const search = new URLSearchParams();
+  if (params.approval_status) search.set("approval_status", params.approval_status);
+  if (typeof params.limit === "number") search.set("limit", String(params.limit));
+  if (typeof params.offset === "number") search.set("offset", String(params.offset));
+  const query = search.toString();
+  return clusterAccountListResponseSchema.parse(
+    await apiFetch(`/compute-cluster-users${query ? `?${query}` : ""}`),
+  );
+}
+
+export async function approveClusterAccount(id: string): Promise<ReviewedClusterUser> {
+  return reviewedClusterUserSchema.parse(
+    await apiFetch(`/compute-cluster-users/${id}/approve`, { method: "POST" }),
+  );
+}
+
+export async function denyClusterAccount(id: string, note: string): Promise<ReviewedClusterUser> {
+  return reviewedClusterUserSchema.parse(
+    await apiFetch(`/compute-cluster-users/${id}/deny`, { method: "POST", body: { note } }),
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx b/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx
new file mode 100644
index 000000000..5d09282c0
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { Button } from "@/shared/ui/button";
+import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { TableSkeleton } from "@/shared/ui/Loading";
+import { StatusBadge, type StatusBadgeVariant } from "@/shared/ui/StatusBadge";
+import type { ClusterAccount } from "../schemas";
+
+export function clusterAccountStatus(row: ClusterAccount): {
+  variant: StatusBadgeVariant;
+  label: string;
+} {
+  if (row.approval_status === "DENIED") return { variant: "rejected", label: "Denied" };
+  if (row.approval_status === "APPROVED") {
+    return row.provisioned_at
+      ? { variant: "active", label: "Active" }
+      : { variant: "pending", label: "Approved · provisioning" };
+  }
+  return { variant: "warning", label: "Pending approval" };
+}
+
+function formatDate(iso?: string | null): string {
+  if (!iso) return "";
+  const date = new Date(iso);
+  if (Number.isNaN(date.getTime())) return "";
+  return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+export type ClusterAccountsTableProps = {
+  rows: ClusterAccount[];
+  isLoading: boolean;
+  error: Error | null;
+  onRetry?: () => void;
+  canReview: boolean;
+  onApprove: (row: ClusterAccount) => void;
+  onDeny: (row: ClusterAccount) => void;
+  page: number;
+  pageSize: number;
+  total: number;
+  onPageChange: (page: number) => void;
+  emptyHeading: string;
+};
+
+export function ClusterAccountsTable({
+  rows,
+  isLoading,
+  error,
+  onRetry,
+  canReview,
+  onApprove,
+  onDeny,
+  page,
+  pageSize,
+  total,
+  onPageChange,
+  emptyHeading,
+}: ClusterAccountsTableProps) {
+  const columns: Array<DataTableColumn<ClusterAccount>> = [
+    {
+      key: "user",
+      header: "User",
+      cell: (row) => (
+        <div>
+          <div className="font-medium text-foreground">{row.display_name || row.email}</div>
+          <div className="text-xs text-muted-foreground">{row.email}</div>
+        </div>
+      ),
+    },
+    {
+      key: "cluster",
+      header: "Cluster",
+      cell: (row) => <span className="font-mono text-xs">{row.cluster_name}</span>,
+    },
+    {
+      key: "username",
+      header: "Username",
+      cell: (row) => <span className="font-mono text-xs">{row.local_username}</span>,
+    },
+    {
+      key: "status",
+      header: "Status",
+      cell: (row) => {
+        const status = clusterAccountStatus(row);
+        return <StatusBadge variant={status.variant} label={status.label} />;
+      },
+    },
+    {
+      key: "reviewed",
+      header: "Reviewed",
+      cell: (row) =>
+        row.reviewed_at ? (
+          <div className="text-xs text-muted-foreground">
+            <div>{formatDate(row.reviewed_at)}</div>
+            {row.review_note ? <div className="max-w-[28ch]">{row.review_note}</div> : null}
+          </div>
+        ) : null,
+    },
+  ];
+
+  if (canReview) {
+    columns.push({
+      key: "actions",
+      header: "",
+      align: "right",
+      interactive: true,
+      cell: (row) => {
+        if (row.approval_status === "APPROVED") return null;
+        return (
+          <div className="flex justify-end gap-2">
+            {row.approval_status === "PENDING" ? (
+              <Button size="sm" variant="destructive" onClick={() => onDeny(row)}>
+                Deny
+              </Button>
+            ) : null}
+            <Button size="sm" variant="success" onClick={() => onApprove(row)}>
+              Approve
+            </Button>
+          </div>
+        );
+      },
+    });
+  }
+
+  if (isLoading) return <TableSkeleton rows={6} columns={columns.length} />;
+  if (error) return <ErrorState message={error.message} onRetry={onRetry} />;
+  if (rows.length === 0) return <EmptyState heading={emptyHeading} />;
+
+  return (
+    <DataTable
+      columns={columns}
+      rows={rows}
+      rowKey={(row) => row.id}
+      pagination={{ page, pageSize, total, onPageChange }}
+    />
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
new file mode 100644
index 000000000..d55fcd1e5
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
@@ -0,0 +1,131 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Label } from "@/shared/ui/label";
+import type { ClusterAccount } from "../schemas";
+
+export type ReviewAction = "approve" | "deny";
+
+export type ReviewClusterAccountDialogProps = {
+  account: ClusterAccount | null;
+  action: ReviewAction;
+  onOpenChange: (open: boolean) => void;
+  onConfirm: (note: string) => void;
+  isPending: boolean;
+  error?: string | null;
+};
+
+const copy: Record<ReviewAction, { title: string; description: string; button: string }> = {
+  approve: {
+    title: "Approve cluster account",
+    description:
+      "This creates the account on the cluster and lets the user log in once provisioning finishes.",
+    button: "Approve",
+  },
+  deny: {
+    title: "Deny cluster account",
+    description:
+      "No account is created on the cluster. The user keeps portal access and can be approved later.",
+    button: "Deny",
+  },
+};
+
+export function ReviewClusterAccountDialog({
+  account,
+  action,
+  onOpenChange,
+  onConfirm,
+  isPending,
+  error,
+}: ReviewClusterAccountDialogProps) {
+  const [note, setNote] = React.useState("");
+  const open = account !== null;
+
+  React.useEffect(() => {
+    if (!open) setNote("");
+  }, [open]);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>{copy[action].title}</DialogTitle>
+          <DialogDescription>{copy[action].description}</DialogDescription>
+        </DialogHeader>
+        {account ? (
+          <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 rounded-lg bg-muted px-3 py-2 text-sm">
+            <dt className="text-muted-foreground">User</dt>
+            <dd>
+              {account.display_name || account.email}
+              {account.display_name ? (
+                <span className="text-muted-foreground"> ({account.email})</span>
+              ) : null}
+            </dd>
+            <dt className="text-muted-foreground">Cluster</dt>
+            <dd className="font-mono text-xs">{account.cluster_name}</dd>
+            <dt className="text-muted-foreground">Username</dt>
+            <dd className="font-mono text-xs">{account.local_username}</dd>
+          </dl>
+        ) : null}
+        {action === "deny" ? (
+          <div className="space-y-2">
+            <Label htmlFor="review-note">Reason (optional, kept in the audit log)</Label>
+            <textarea
+              id="review-note"
+              rows={3}
+              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)}
+            />
+          </div>
+        ) : (
+          <output className="block rounded-lg bg-[color:var(--tone-warn-bg)] px-3 py-2 text-sm text-[color:var(--tone-warn-fg)]">
+            Your name is recorded as the approver in the audit log.
+          </output>
+        )}
+        {error ? (
+          <p className="text-sm text-[color:var(--tone-error-fg)]">{error}</p>
+        ) : null}
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button
+            type="button"
+            variant={action === "deny" ? "destructive" : "default"}
+            disabled={isPending}
+            onClick={() => onConfirm(note.trim())}
+          >
+            {isPending ? `${copy[action].button}…` : copy[action].button}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/queries.ts b/web/src/features/core/cluster-accounts/queries.ts
new file mode 100644
index 000000000..c679262ce
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/queries.ts
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { approveClusterAccount, denyClusterAccount, listClusterAccounts } from "./api";
+import type { ClusterAccountListParams } from "./types";
+
+export const clusterAccountKeys = {
+  all: ["cluster-accounts"] as const,
+  list: (params: ClusterAccountListParams = {}) =>
+    [...clusterAccountKeys.all, "list", params] as const,
+};
+
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
+export function useClusterAccounts(
+  params: ClusterAccountListParams = {},
+  options: { enabled?: boolean } = {},
+) {
+  return useQuery({
+    queryKey: clusterAccountKeys.list(params),
+    queryFn: () => listClusterAccounts(params),
+    enabled: options.enabled ?? true,
+    ...DEFAULTS,
+  });
+}
+
+export function useApproveClusterAccount() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: (id: string) => approveClusterAccount(id),
+    onSuccess: () => client.invalidateQueries({ queryKey: clusterAccountKeys.all }),
+  });
+}
+
+export function useDenyClusterAccount() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: ({ id, note }: { id: string; note: string }) => denyClusterAccount(id, note),
+    onSuccess: () => client.invalidateQueries({ queryKey: clusterAccountKeys.all }),
+  });
+}
diff --git a/web/src/features/core/cluster-accounts/schemas.ts b/web/src/features/core/cluster-accounts/schemas.ts
new file mode 100644
index 000000000..8c9c2c975
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/schemas.ts
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { z } from "zod";
+import {
+  zClusterAccountApproval,
+  zClusterAccountResponse,
+  zComputeClusterUser,
+} from "@/generated/core/zod.gen";
+
+export const clusterAccountApprovalSchema = zClusterAccountApproval;
+export type ClusterAccountApproval = z.infer<typeof clusterAccountApprovalSchema>;
+
+// The generated schema marks every field optional and provisioned_at is null
+// until the account exists on the cluster.
+export const clusterAccountSchema = zClusterAccountResponse
+  .extend({ provisioned_at: z.string().nullish() })
+  .required({
+    id: true,
+    compute_cluster_id: true,
+    user_id: true,
+    local_username: true,
+    approval_status: true,
+    display_name: true,
+    email: true,
+    cluster_name: true,
+  });
+export type ClusterAccount = z.infer<typeof clusterAccountSchema>;
+
+export const clusterAccountListResponseSchema = z.object({
+  items: z.array(clusterAccountSchema),
+  total: z.number().int().nonnegative(),
+});
+export type ClusterAccountListResponse = z.infer<typeof clusterAccountListResponseSchema>;
+
+export const reviewedClusterUserSchema = zComputeClusterUser
+  .extend({ provisioned_at: z.string().nullish() })
+  .required({ id: true, approval_status: true });
+export type ReviewedClusterUser = z.infer<typeof reviewedClusterUserSchema>;
diff --git a/web/src/features/core/cluster-accounts/types.ts b/web/src/features/core/cluster-accounts/types.ts
new file mode 100644
index 000000000..49d917596
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/types.ts
@@ -0,0 +1,24 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import type { ClusterAccountApproval } from "./schemas";
+
+export type ClusterAccountListParams = {
+  approval_status?: ClusterAccountApproval;
+  limit?: number;
+  offset?: number;
+};
diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index 379e6c28b..4047abea5 100644
--- a/web/src/generated/core/index.ts
+++ b/web/src/generated/core/index.ts
@@ -1,4 +1,4 @@
 // This file is auto-generated by @hey-api/openapi-ts
 
-export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
-export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocation [...]
+export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
+export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, CallerRoleGrant, ClientOptions, ClusterAccessLevel, ClusterAccountApproval, ClusterAccountListResponse, ClusterAccountResponse, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAlloca [...]
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index 149b028f8..6a3df6058 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -2,7 +2,7 @@
 
 import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client';
 import { client } from './client.gen';
-import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
+import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
     /**
@@ -581,6 +581,17 @@ export const getComputeAllocationsByIdUsersByUserIdUsagesTotal = <ThrowOnError e
     ...options
 });
 
+/**
+ * List cluster accounts by approval status
+ *
+ * Cluster users with their user and cluster, newest first. Every status unless one is given.
+ */
+export const getComputeClusterUsers = <ThrowOnError extends boolean = false>(options?: Options<GetComputeClusterUsersData, ThrowOnError>): RequestResult<GetComputeClusterUsersResponses, GetComputeClusterUsersErrors, ThrowOnError> => (options?.client ?? client).get<GetComputeClusterUsersResponses, GetComputeClusterUsersErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/compute-cluster-users',
+    ...options
+});
+
 /**
  * Create a compute cluster user
  */
@@ -625,6 +636,32 @@ export const putComputeClusterUsersById = <ThrowOnError extends boolean = false>
     }
 });
 
+/**
+ * Approve a cluster account
+ *
+ * Records the caller as the approver and starts creating the account on the cluster.
+ */
+export const postComputeClusterUsersByIdApprove = <ThrowOnError extends boolean = false>(options: Options<PostComputeClusterUsersByIdApproveData, ThrowOnError>): RequestResult<PostComputeClusterUsersByIdApproveResponses, PostComputeClusterUsersByIdApproveErrors, ThrowOnError> => (options.client ?? client).post<PostComputeClusterUsersByIdApproveResponses, PostComputeClusterUsersByIdApproveErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/compute-cluster-users/{id}/approve',
+    ...options
+});
+
+/**
+ * Deny a cluster account
+ *
+ * Records the caller's decision and the note. No account is created on the cluster. 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' }],
+    url: '/compute-cluster-users/{id}/deny',
+    ...options,
+    headers: {
+        'Content-Type': 'application/json',
+        ...options.headers
+    }
+});
+
 /**
  * List compute clusters
  */
@@ -674,6 +711,39 @@ export const getComputeClustersByIdUsersByUserId = <ThrowOnError extends boolean
     ...options
 });
 
+/**
+ * List event deliveries
+ *
+ * Newest first, every status unless one is given. A pending delivery with no attempts means its subscriber is not loaded, so it waits until that connector starts.
+ */
+export const getEventsDeliveries = <ThrowOnError extends boolean = false>(options?: Options<GetEventsDeliveriesData, ThrowOnError>): RequestResult<GetEventsDeliveriesResponses, GetEventsDeliveriesErrors, ThrowOnError> => (options?.client ?? client).get<GetEventsDeliveriesResponses, GetEventsDeliveriesErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/events/deliveries',
+    ...options
+});
+
+/**
+ * Retry a failed event delivery
+ *
+ * Makes the delivery due again with a fresh attempt count. The worker picks it up on its next pass.
+ */
+export const postEventsDeliveriesByIdRetry = <ThrowOnError extends boolean = false>(options: Options<PostEventsDeliveriesByIdRetryData, ThrowOnError>): RequestResult<PostEventsDeliveriesByIdRetryResponses, PostEventsDeliveriesByIdRetryErrors, ThrowOnError> => (options.client ?? client).post<PostEventsDeliveriesByIdRetryResponses, PostEventsDeliveriesByIdRetryErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/events/deliveries/{id}/retry',
+    ...options
+});
+
+/**
+ * List event subscriptions
+ *
+ * Every saved subscription and whether its subscriber is loaded in this process.
+ */
+export const getEventsSubscriptions = <ThrowOnError extends boolean = false>(options?: Options<GetEventsSubscriptionsData, ThrowOnError>): RequestResult<GetEventsSubscriptionsResponses, unknown, ThrowOnError> => (options?.client ?? client).get<GetEventsSubscriptionsResponses, unknown, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/events/subscriptions',
+    ...options
+});
+
 /**
  * Get the caller's user record, effective privileges, and roles
  *
@@ -969,6 +1039,8 @@ export const getUsers = <ThrowOnError extends boolean = false>(options?: Options
 
 /**
  * Create a user
+ *
+ * Creates a researcher (both admin flags false) or an admin.
  */
 export const postUsers = <ThrowOnError extends boolean = false>(options: Options<PostUsersData, ThrowOnError>): RequestResult<PostUsersResponses, PostUsersErrors, ThrowOnError> => (options.client ?? client).post<PostUsersResponses, PostUsersErrors, 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 58c9f2310..c1e4a02ef 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -38,6 +38,40 @@ export type CallerRoleGrant = {
     role?: Role;
 };
 
+export type ClusterAccessLevel = 'USER' | 'ADMIN';
+
+export type ClusterAccountApproval = 'PENDING' | 'APPROVED' | 'DENIED';
+
+export type ClusterAccountListResponse = {
+    items?: Array<ClusterAccountResponse>;
+    total?: number;
+};
+
+export type ClusterAccountResponse = {
+    access_level?: ClusterAccessLevel;
+    approval_status?: ClusterAccountApproval;
+    cluster_name?: string;
+    compute_cluster_id?: string;
+    display_name?: string;
+    email?: string;
+    id?: string;
+    /**
+     * The username of the user on the compute cluster, which may be different from their Airavata Custos username.
+     */
+    local_username?: string;
+    /**
+     * When the account was provisioned into the registry; nil until then.
+     */
+    provisioned_at?: string;
+    review_note?: string;
+    reviewed_at?: string;
+    /**
+     * The admin who approved or denied the account.
+     */
+    reviewed_by?: string;
+    user_id?: string;
+};
+
 export type ComputeAllocation = {
     /**
      * The ID of the compute cluster where the allocation is provisioned.
@@ -277,15 +311,41 @@ export type ComputeCluster = {
 };
 
 export type ComputeClusterUser = {
+    access_level?: ClusterAccessLevel;
+    approval_status?: ClusterAccountApproval;
     compute_cluster_id?: string;
     id?: string;
     /**
      * The username of the user on the compute cluster, which may be different from their Airavata Custos username.
      */
     local_username?: string;
+    /**
+     * When the account was provisioned into the registry; nil until then.
+     */
+    provisioned_at?: string;
+    review_note?: string;
+    reviewed_at?: string;
+    /**
+     * The admin who approved or denied the account.
+     */
+    reviewed_by?: string;
     user_id?: string;
 };
 
+export type Event = {
+    created_at?: string;
+    event_type?: string;
+    id?: string;
+    payload?: Array<number>;
+    /**
+     * Subsystem that published the event.
+     */
+    source?: string;
+    trace_id?: string;
+};
+
+export type EventDeliveryStatus = 'PENDING' | 'SUCCEEDED' | 'FAILED';
+
 export type Organization = {
     id?: string;
     name?: string;
@@ -300,7 +360,20 @@ export type OrganizationListResponse = {
     total?: number;
 };
 
-export type PrivilegeKey = 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage';
+export type PendingDelivery = {
+    attempts?: number;
+    created_at?: string;
+    event?: Event;
+    event_id?: string;
+    finished_at?: string;
+    id?: string;
+    last_error?: string;
+    next_run_at?: string;
+    status?: EventDeliveryStatus;
+    subscriber?: string;
+};
+
+export type PrivilegeKey = 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage' | 'core:events:manage';
 
 export type Project = {
     created_time?: string;
@@ -369,6 +442,13 @@ export type Role = {
     name?: string;
 };
 
+export type Subscription = {
+    created_at?: string;
+    event_type?: string;
+    loaded?: boolean;
+    subscriber?: string;
+};
+
 export type TraceEvent = {
     created_at?: string;
     description?: string;
@@ -479,11 +559,33 @@ export type AttachResourceRequest = {
     resource_time?: number;
 };
 
+export type CreateComputeClusterUserRequest = {
+    compute_cluster_id?: string;
+    local_username?: string;
+    user_id?: string;
+};
+
 export type CreateRoleRequest = {
     description?: string;
     name?: string;
 };
 
+export type CreateUserRequest = {
+    allocation_id?: string;
+    cluster_admin?: boolean;
+    compute_cluster_id?: string;
+    email?: string;
+    first_name?: string;
+    last_name?: string;
+    organization_id?: string;
+    portal_admin?: boolean;
+    username?: string;
+};
+
+export type DenyClusterAccountRequest = {
+    note?: string;
+};
+
 export type GrantPrivilegeRequest = {
     privilege?: PrivilegeKey;
     reason?: string;
@@ -2384,11 +2486,51 @@ export type GetComputeAllocationsByIdUsersByUserIdUsagesTotalResponses = {
 
 export type GetComputeAllocationsByIdUsersByUserIdUsagesTotalResponse = GetComputeAllocationsByIdUsersByUserIdUsagesTotalResponses[keyof GetComputeAllocationsByIdUsersByUserIdUsagesTotalResponses];
 
+export type GetComputeClusterUsersData = {
+    body?: never;
+    path?: never;
+    query?: {
+        /**
+         * PENDING | APPROVED | DENIED
+         */
+        approval_status?: string;
+        /**
+         * Page size
+         */
+        limit?: number;
+        /**
+         * Page offset
+         */
+        offset?: number;
+    };
+    url: '/compute-cluster-users';
+};
+
+export type GetComputeClusterUsersErrors = {
+    /**
+     * Bad Request
+     */
+    400: {
+        error?: string;
+    };
+};
+
+export type GetComputeClusterUsersError = GetComputeClusterUsersErrors[keyof GetComputeClusterUsersErrors];
+
+export type GetComputeClusterUsersResponses = {
+    /**
+     * OK
+     */
+    200: ClusterAccountListResponse;
+};
+
+export type GetComputeClusterUsersResponse = GetComputeClusterUsersResponses[keyof GetComputeClusterUsersResponses];
+
 export type PostComputeClusterUsersData = {
     /**
      * Cluster user payload
      */
-    body: ComputeClusterUser;
+    body: CreateComputeClusterUserRequest;
     path?: never;
     query?: never;
     url: '/compute-cluster-users';
@@ -2480,7 +2622,7 @@ export type PutComputeClusterUsersByIdData = {
     /**
      * Cluster user payload
      */
-    body: ComputeClusterUser;
+    body: CreateComputeClusterUserRequest;
     path: {
         /**
          * Compute cluster user ID
@@ -2517,6 +2659,85 @@ export type PutComputeClusterUsersByIdResponses = {
 
 export type PutComputeClusterUsersByIdResponse = PutComputeClusterUsersByIdResponses[keyof PutComputeClusterUsersByIdResponses];
 
+export type PostComputeClusterUsersByIdApproveData = {
+    body?: never;
+    path: {
+        /**
+         * Compute cluster user ID
+         */
+        id: string;
+    };
+    query?: never;
+    url: '/compute-cluster-users/{id}/approve';
+};
+
+export type PostComputeClusterUsersByIdApproveErrors = {
+    /**
+     * Not Found
+     */
+    404: {
+        error?: string;
+    };
+    /**
+     * Already approved
+     */
+    409: {
+        error?: string;
+    };
+};
+
+export type PostComputeClusterUsersByIdApproveError = PostComputeClusterUsersByIdApproveErrors[keyof PostComputeClusterUsersByIdApproveErrors];
+
+export type PostComputeClusterUsersByIdApproveResponses = {
+    /**
+     * OK
+     */
+    200: ComputeClusterUser;
+};
+
+export type PostComputeClusterUsersByIdApproveResponse = PostComputeClusterUsersByIdApproveResponses[keyof PostComputeClusterUsersByIdApproveResponses];
+
+export type PostComputeClusterUsersByIdDenyData = {
+    /**
+     * Optional note
+     */
+    body?: DenyClusterAccountRequest;
+    path: {
+        /**
+         * Compute cluster user ID
+         */
+        id: string;
+    };
+    query?: never;
+    url: '/compute-cluster-users/{id}/deny';
+};
+
+export type PostComputeClusterUsersByIdDenyErrors = {
+    /**
+     * Not Found
+     */
+    404: {
+        error?: string;
+    };
+    /**
+     * Already approved or denied
+     */
+    409: {
+        error?: string;
+    };
+};
+
+export type PostComputeClusterUsersByIdDenyError = PostComputeClusterUsersByIdDenyErrors[keyof PostComputeClusterUsersByIdDenyErrors];
+
+export type PostComputeClusterUsersByIdDenyResponses = {
+    /**
+     * OK
+     */
+    200: ComputeClusterUser;
+};
+
+export type PostComputeClusterUsersByIdDenyResponse = PostComputeClusterUsersByIdDenyResponses[keyof PostComputeClusterUsersByIdDenyResponses];
+
 export type GetComputeClustersData = {
     body?: never;
     path?: never;
@@ -2674,6 +2895,98 @@ export type GetComputeClustersByIdUsersByUserIdResponses = {
 
 export type GetComputeClustersByIdUsersByUserIdResponse = GetComputeClustersByIdUsersByUserIdResponses[keyof GetComputeClustersByIdUsersByUserIdResponses];
 
+export type GetEventsDeliveriesData = {
+    body?: never;
+    path?: never;
+    query?: {
+        /**
+         * PENDING | SUCCEEDED | FAILED
+         */
+        status?: string;
+        /**
+         * Page size (default 50, max 200)
+         */
+        limit?: number;
+    };
+    url: '/events/deliveries';
+};
+
+export type GetEventsDeliveriesErrors = {
+    /**
+     * Invalid status or limit
+     */
+    400: {
+        error?: string;
+    };
+};
+
+export type GetEventsDeliveriesError = GetEventsDeliveriesErrors[keyof GetEventsDeliveriesErrors];
+
+export type GetEventsDeliveriesResponses = {
+    /**
+     * OK
+     */
+    200: {
+        items?: Array<PendingDelivery>;
+    };
+};
+
+export type GetEventsDeliveriesResponse = GetEventsDeliveriesResponses[keyof GetEventsDeliveriesResponses];
+
+export type PostEventsDeliveriesByIdRetryData = {
+    body?: never;
+    path: {
+        /**
+         * Delivery id
+         */
+        id: string;
+    };
+    query?: never;
+    url: '/events/deliveries/{id}/retry';
+};
+
+export type PostEventsDeliveriesByIdRetryErrors = {
+    /**
+     * Delivery not found
+     */
+    404: {
+        error?: string;
+    };
+    /**
+     * Delivery has not failed
+     */
+    409: {
+        error?: string;
+    };
+};
+
+export type PostEventsDeliveriesByIdRetryError = PostEventsDeliveriesByIdRetryErrors[keyof PostEventsDeliveriesByIdRetryErrors];
+
+export type PostEventsDeliveriesByIdRetryResponses = {
+    /**
+     * No Content
+     */
+    204: unknown;
+};
+
+export type GetEventsSubscriptionsData = {
+    body?: never;
+    path?: never;
+    query?: never;
+    url: '/events/subscriptions';
+};
+
+export type GetEventsSubscriptionsResponses = {
+    /**
+     * OK
+     */
+    200: {
+        items?: Array<Subscription>;
+    };
+};
+
+export type GetEventsSubscriptionsResponse = GetEventsSubscriptionsResponses[keyof GetEventsSubscriptionsResponses];
+
 export type GetMeData = {
     body?: never;
     path?: never;
@@ -2794,7 +3107,7 @@ export type GetPrivilegesByKeyHoldersData = {
         /**
          * Privilege key
          */
-        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage';
+        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage' | 'core:events:manage';
     };
     query?: never;
     url: '/privileges/{key}/holders';
@@ -3324,7 +3637,7 @@ export type DeleteRolesByIdPrivilegesByKeyData = {
         /**
          * Privilege key
          */
-        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage';
+        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage' | 'core:events:manage';
     };
     query?: never;
     url: '/roles/{id}/privileges/{key}';
@@ -3619,7 +3932,7 @@ export type PostUsersData = {
     /**
      * User payload
      */
-    body: User;
+    body: CreateUserRequest;
     path?: never;
     query?: never;
     url: '/users';
@@ -3632,6 +3945,12 @@ export type PostUsersErrors = {
     400: {
         error?: string;
     };
+    /**
+     * Conflict
+     */
+    409: {
+        error?: string;
+    };
 };
 
 export type PostUsersError = PostUsersErrors[keyof PostUsersErrors];
@@ -3950,7 +4269,7 @@ export type DeleteUsersByIdPrivilegesByKeyData = {
         /**
          * Privilege key
          */
-        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage';
+        key: 'core:clusters:read' | 'core:clusters:write' | 'core:allocations:read' | 'core:allocations:write' | 'core:projects:read' | 'core:projects:write' | 'core:users:read' | 'core:users:write' | 'core:organizations:read' | 'core:organizations:write' | 'core:traces:read' | 'core:privileges:grant' | 'core:roles:manage' | 'core:events:manage';
     };
     query?: never;
     url: '/users/{id}/privileges/{key}';
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index 959dbf89d..b2eda507e 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -31,6 +31,43 @@ export const zAllocationMembershipResponse = z.object({
 
 export type allocationMembershipResponseZodType = z.infer<typeof zAllocationMembershipResponse>;
 
+export const zClusterAccessLevel = z.enum(['USER', 'ADMIN']);
+
+export type clusterAccessLevelZodType = z.infer<typeof zClusterAccessLevel>;
+
+export const zClusterAccountApproval = z.enum([
+    'PENDING',
+    'APPROVED',
+    'DENIED'
+]);
+
+export type clusterAccountApprovalZodType = z.infer<typeof zClusterAccountApproval>;
+
+export const zClusterAccountResponse = z.object({
+    access_level: zClusterAccessLevel.optional(),
+    approval_status: zClusterAccountApproval.optional(),
+    cluster_name: z.string().optional(),
+    compute_cluster_id: z.string().optional(),
+    display_name: z.string().optional(),
+    email: z.string().optional(),
+    id: z.string().optional(),
+    local_username: z.string().optional(),
+    provisioned_at: z.string().optional(),
+    review_note: z.string().optional(),
+    reviewed_at: z.string().optional(),
+    reviewed_by: z.string().optional(),
+    user_id: z.string().optional()
+});
+
+export type clusterAccountResponseZodType = z.infer<typeof zClusterAccountResponse>;
+
+export const zClusterAccountListResponse = z.object({
+    items: z.array(zClusterAccountResponse).optional(),
+    total: z.int().optional()
+});
+
+export type clusterAccountListResponseZodType = z.infer<typeof zClusterAccountListResponse>;
+
 export const zComputeAllocation = z.object({
     compute_cluster_id: z.string().optional(),
     end_time: z.string().optional(),
@@ -173,14 +210,39 @@ export const zComputeCluster = z.object({
 export type computeClusterZodType = z.infer<typeof zComputeCluster>;
 
 export const zComputeClusterUser = z.object({
+    access_level: zClusterAccessLevel.optional(),
+    approval_status: zClusterAccountApproval.optional(),
     compute_cluster_id: z.string().optional(),
     id: z.string().optional(),
     local_username: z.string().optional(),
+    provisioned_at: z.string().optional(),
+    review_note: z.string().optional(),
+    reviewed_at: z.string().optional(),
+    reviewed_by: z.string().optional(),
     user_id: z.string().optional()
 });
 
 export type computeClusterUserZodType = z.infer<typeof zComputeClusterUser>;
 
+export const zEvent = z.object({
+    created_at: z.string().optional(),
+    event_type: z.string().optional(),
+    id: z.string().optional(),
+    payload: z.array(z.int()).optional(),
+    source: z.string().optional(),
+    trace_id: z.string().optional()
+});
+
+export type eventZodType = z.infer<typeof zEvent>;
+
+export const zEventDeliveryStatus = z.enum([
+    'PENDING',
+    'SUCCEEDED',
+    'FAILED'
+]);
+
+export type eventDeliveryStatusZodType = z.infer<typeof zEventDeliveryStatus>;
+
 export const zOrganization = z.object({
     id: z.string().optional(),
     name: z.string().optional(),
@@ -196,6 +258,21 @@ export const zOrganizationListResponse = z.object({
 
 export type organizationListResponseZodType = z.infer<typeof zOrganizationListResponse>;
 
+export const zPendingDelivery = z.object({
+    attempts: z.int().optional(),
+    created_at: z.string().optional(),
+    event: zEvent.optional(),
+    event_id: z.string().optional(),
+    finished_at: z.string().optional(),
+    id: z.string().optional(),
+    last_error: z.string().optional(),
+    next_run_at: z.string().optional(),
+    status: zEventDeliveryStatus.optional(),
+    subscriber: z.string().optional()
+});
+
+export type pendingDeliveryZodType = z.infer<typeof zPendingDelivery>;
+
 export const zPrivilegeKey = z.enum([
     'core:clusters:read',
     'core:clusters:write',
@@ -209,7 +286,8 @@ export const zPrivilegeKey = z.enum([
     'core:organizations:write',
     'core:traces:read',
     'core:privileges:grant',
-    'core:roles:manage'
+    'core:roles:manage',
+    'core:events:manage'
 ]);
 
 export type privilegeKeyZodType = z.infer<typeof zPrivilegeKey>;
@@ -295,6 +373,15 @@ export const zCallerRoleGrant = z.object({
 
 export type callerRoleGrantZodType = z.infer<typeof zCallerRoleGrant>;
 
+export const zSubscription = z.object({
+    created_at: z.string().optional(),
+    event_type: z.string().optional(),
+    loaded: z.boolean().optional(),
+    subscriber: z.string().optional()
+});
+
+export type subscriptionZodType = z.infer<typeof zSubscription>;
+
 export const zTraceEvent = z.object({
     created_at: z.string().optional(),
     description: z.string().optional(),
@@ -432,6 +519,14 @@ export const zAttachResourceRequest = z.object({
 
 export type attachResourceRequestZodType = z.infer<typeof zAttachResourceRequest>;
 
+export const zCreateComputeClusterUserRequest = z.object({
+    compute_cluster_id: z.string().optional(),
+    local_username: z.string().optional(),
+    user_id: z.string().optional()
+});
+
+export type createComputeClusterUserRequestZodType = z.infer<typeof zCreateComputeClusterUserRequest>;
+
 export const zCreateRoleRequest = z.object({
     description: z.string().optional(),
     name: z.string().optional()
@@ -439,6 +534,26 @@ export const zCreateRoleRequest = z.object({
 
 export type createRoleRequestZodType = z.infer<typeof zCreateRoleRequest>;
 
+export const zCreateUserRequest = z.object({
+    allocation_id: z.string().optional(),
+    cluster_admin: z.boolean().optional(),
+    compute_cluster_id: z.string().optional(),
+    email: z.string().optional(),
+    first_name: z.string().optional(),
+    last_name: z.string().optional(),
+    organization_id: z.string().optional(),
+    portal_admin: z.boolean().optional(),
+    username: z.string().optional()
+});
+
+export type createUserRequestZodType = z.infer<typeof zCreateUserRequest>;
+
+export const zDenyClusterAccountRequest = z.object({
+    note: z.string().optional()
+});
+
+export type denyClusterAccountRequestZodType = z.infer<typeof zDenyClusterAccountRequest>;
+
 export const zGrantPrivilegeRequest = z.object({
     privilege: zPrivilegeKey.optional(),
     reason: z.string().optional()
@@ -1002,10 +1117,21 @@ export const zGetComputeAllocationsByIdUsersByUserIdUsagesTotalPath = z.object({
  */
 export const zGetComputeAllocationsByIdUsersByUserIdUsagesTotalResponse = zUserAllocationSuTotalResponse;
 
+export const zGetComputeClusterUsersQuery = z.object({
+    approval_status: z.string().optional(),
+    limit: z.int().optional(),
+    offset: z.int().optional()
+});
+
+/**
+ * OK
+ */
+export const zGetComputeClusterUsersResponse = zClusterAccountListResponse;
+
 /**
  * Cluster user payload
  */
-export const zPostComputeClusterUsersBody = zComputeClusterUser;
+export const zPostComputeClusterUsersBody = zCreateComputeClusterUserRequest;
 
 /**
  * Created
@@ -1028,7 +1154,7 @@ export const zGetComputeClusterUsersByIdResponse = zComputeClusterUser;
 /**
  * Cluster user payload
  */
-export const zPutComputeClusterUsersByIdBody = zComputeClusterUser;
+export const zPutComputeClusterUsersByIdBody = zCreateComputeClusterUserRequest;
 
 export const zPutComputeClusterUsersByIdPath = z.object({
     id: z.string()
@@ -1039,6 +1165,29 @@ export const zPutComputeClusterUsersByIdPath = z.object({
  */
 export const zPutComputeClusterUsersByIdResponse = zComputeClusterUser;
 
+export const zPostComputeClusterUsersByIdApprovePath = z.object({
+    id: z.string()
+});
+
+/**
+ * OK
+ */
+export const zPostComputeClusterUsersByIdApproveResponse = zComputeClusterUser;
+
+/**
+ * Optional note
+ */
+export const zPostComputeClusterUsersByIdDenyBody = zDenyClusterAccountRequest;
+
+export const zPostComputeClusterUsersByIdDenyPath = z.object({
+    id: z.string()
+});
+
+/**
+ * OK
+ */
+export const zPostComputeClusterUsersByIdDenyResponse = zComputeClusterUser;
+
 /**
  * OK
  */
@@ -1082,6 +1231,29 @@ export const zGetComputeClustersByIdUsersByUserIdPath = z.object({
  */
 export const zGetComputeClustersByIdUsersByUserIdResponse = zComputeClusterUser;
 
+export const zGetEventsDeliveriesQuery = z.object({
+    status: z.string().optional(),
+    limit: z.int().optional()
+});
+
+/**
+ * OK
+ */
+export const zGetEventsDeliveriesResponse = z.object({
+    items: z.array(zPendingDelivery).optional()
+});
+
+export const zPostEventsDeliveriesByIdRetryPath = z.object({
+    id: z.string()
+});
+
+/**
+ * OK
+ */
+export const zGetEventsSubscriptionsResponse = z.object({
+    items: z.array(zSubscription).optional()
+});
+
 /**
  * OK
  */
@@ -1130,7 +1302,8 @@ export const zGetPrivilegesByKeyHoldersPath = z.object({
         'core:organizations:write',
         'core:traces:read',
         'core:privileges:grant',
-        'core:roles:manage'
+        'core:roles:manage',
+        'core:events:manage'
     ])
 });
 
@@ -1279,7 +1452,8 @@ export const zDeleteRolesByIdPrivilegesByKeyPath = z.object({
         'core:organizations:write',
         'core:traces:read',
         'core:privileges:grant',
-        'core:roles:manage'
+        'core:roles:manage',
+        'core:events:manage'
     ])
 });
 
@@ -1359,7 +1533,7 @@ export const zGetUsersResponse = zUserListResponse;
 /**
  * User payload
  */
-export const zPostUsersBody = zUser;
+export const zPostUsersBody = zCreateUserRequest;
 
 /**
  * Created
@@ -1468,7 +1642,8 @@ export const zDeleteUsersByIdPrivilegesByKeyPath = z.object({
         'core:organizations:write',
         'core:traces:read',
         'core:privileges:grant',
-        'core:roles:manage'
+        'core:roles:manage',
+        'core:events:manage'
     ])
 });
 
diff --git a/web/src/mocks/handlers/clusters.ts b/web/src/mocks/handlers/clusters.ts
index f1288b501..a5d7de396 100644
--- a/web/src/mocks/handlers/clusters.ts
+++ b/web/src/mocks/handlers/clusters.ts
@@ -16,16 +16,55 @@
 // under the License.
 
 import { http, HttpResponse } from "msw";
+import clusterAccountsFixture from "@/features/core/cluster-accounts/__fixtures__/cluster-accounts.json";
+import type { ClusterAccount } from "@/features/core/cluster-accounts/schemas";
 import clusterUsersFixture from "@/features/core/clusters/__fixtures__/cluster-users.json";
 import clustersFixture from "@/features/core/clusters/__fixtures__/clusters.json";
 import type { ComputeCluster, ComputeClusterUser } from "@/features/core/clusters/schemas";
 
 const clusters = clustersFixture as ComputeCluster[];
 const clusterUsers = clusterUsersFixture as ComputeClusterUser[];
+const clusterAccounts = clusterAccountsFixture as ClusterAccount[];
 
 export const clustersHandlers = [
   http.get("*/api/v1/compute-clusters", () => HttpResponse.json(clusters)),
 
+  http.get("*/api/v1/compute-cluster-users", ({ request }) => {
+    const url = new URL(request.url);
+    const status = url.searchParams.get("approval_status");
+    const rows = status
+      ? clusterAccounts.filter((a) => a.approval_status === status)
+      : clusterAccounts;
+    return HttpResponse.json({ items: rows, total: rows.length });
+  }),
+
+  http.post("*/api/v1/compute-cluster-users/:id/approve", ({ params }) => {
+    const found = clusterAccounts.find((a) => a.id === String(params.id));
+    if (!found) return HttpResponse.json({ error: "cluster user not found" }, { status: 404 });
+    if (found.approval_status === "APPROVED") {
+      return HttpResponse.json({ error: "already approved" }, { status: 409 });
+    }
+    found.approval_status = "APPROVED";
+    found.reviewed_at = new Date().toISOString();
+    found.reviewed_by = "user-admin";
+    found.review_note = undefined;
+    return HttpResponse.json(found);
+  }),
+
+  http.post("*/api/v1/compute-cluster-users/:id/deny", async ({ params, request }) => {
+    const found = clusterAccounts.find((a) => a.id === String(params.id));
+    if (!found) return HttpResponse.json({ error: "cluster user not found" }, { status: 404 });
+    if (found.approval_status !== "PENDING") {
+      return HttpResponse.json({ error: "already reviewed" }, { status: 409 });
+    }
+    const body = (await request.json().catch(() => ({}))) as { note?: string };
+    found.approval_status = "DENIED";
+    found.reviewed_at = new Date().toISOString();
+    found.reviewed_by = "user-admin";
+    found.review_note = body.note || undefined;
+    return HttpResponse.json(found);
+  }),
+
   http.get("*/api/v1/compute-clusters/:id", ({ params }) => {
     const id = String(params.id);
     const found = clusters.find((c) => c.id === id);
diff --git a/web/src/shared/casl/abilities.ts b/web/src/shared/casl/abilities.ts
index 4e63752a6..306e219dd 100644
--- a/web/src/shared/casl/abilities.ts
+++ b/web/src/shared/casl/abilities.ts
@@ -42,6 +42,7 @@ export const PRIVILEGE_ABILITY_MAP: Record<string, PrivilegeRule[]> = {
   ],
   "core:privileges:grant": [{ action: "manage", subject: "PrivilegeGrant" }],
   "core:roles:manage": [{ action: "manage", subject: "Role" }],
+  "core:events:manage": [{ action: "manage", subject: "EventDelivery" }],
   "amie:packets:read": [{ action: "read", subject: "AMIE" }],
   "amie:packets:write": [{ action: "manage", subject: "AMIE" }],
   "amie:replies:read": [{ action: "read", subject: "AMIE" }],
diff --git a/web/src/shared/ui/button.tsx b/web/src/shared/ui/button.tsx
index e71f63e85..54fbbc047 100644
--- a/web/src/shared/ui/button.tsx
+++ b/web/src/shared/ui/button.tsx
@@ -25,6 +25,7 @@ import { cn } from "@/lib/utils";
 // - outline → white card with border
 // - ghost   → text-only, hover tinted
 // - destructive → soft red tint (not a fill)
+// - success → soft green tint, the approve side of destructive
 // - brand   → blue CTA for wizard "Continue" / proposal "Submit"
 // - link    → inline text link
 const buttonVariants = cva(
@@ -39,8 +40,12 @@ const buttonVariants = cva(
           "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
         ghost:
           "text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+        // Tone tokens carry their own dark values, and the hover deepens the
+        // tint by mixing in the foreground so it holds in both themes.
         destructive:
-          "bg-[color:var(--custos-red-50)] text-[color:var(--custos-red-700)] hover:bg-[color:var(--custos-red-100)] focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
+          "bg-[color:var(--tone-error-bg)] text-[color:var(--tone-error-fg)] hover:bg-[color-mix(in_srgb,var(--tone-error-bg),var(--tone-error-fg)_14%)] focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
+        success:
+          "bg-[color:var(--tone-ok-bg)] text-[color:var(--tone-ok-fg)] hover:bg-[color-mix(in_srgb,var(--tone-ok-bg),var(--tone-ok-fg)_14%)]",
         brand: "bg-brand text-brand-foreground hover:bg-brand/90",
         link: "text-brand underline-offset-4 hover:underline",
       },