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/22 19:22:38 UTC

(airavata-custos) branch master updated: Group pending account requests by allocation (#659)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 463737074 Group pending account requests by allocation (#659)
463737074 is described below

commit 463737074063f53e0649a708f953b59708d4bdb9
Author: Lahiru Jayathilake <la...@gmail.com>
AuthorDate: Thu Oct 22 15:22:32 2026 -0400

    Group pending account requests by allocation (#659)
    
    * Group pending account requests by allocation
    
    * Group account approvals by allocation in the portal
---
 api/core.openapi.yaml                              |  19 +-
 .../server/cluster_account_integration_test.go     | 119 +++++++++++-
 internal/server/compute_cluster.go                 |  10 +-
 internal/server/responses.go                       |  15 +-
 internal/store/compute_cluster_user_store.go       |  59 ++++--
 internal/store/store.go                            |   4 +-
 pkg/service/compute_cluster_user.go                |   6 +-
 .../AccountApprovalsContainer.tsx                  | 155 ++++++++++++---
 .../__tests__/AccountApprovalsContainer.test.tsx   |  85 ++++++++
 .../admin/users/management/UsersTableContainer.tsx |   2 +-
 .../__fixtures__/cluster-accounts.json             | 130 ++++++++++---
 .../__tests__/ClusterAccountsTable.test.tsx        |  96 ++++++---
 .../__tests__/ReviewClusterAccountDialog.test.tsx  |  64 +++---
 .../core/cluster-accounts/__tests__/api.test.ts    |   1 +
 web/src/features/core/cluster-accounts/api.ts      |   1 +
 .../cluster-accounts/components/AllocationRef.tsx  |  46 +++++
 .../components/ApprovalGroupHeader.tsx             | 103 ++++++++++
 .../components/ClusterAccountsTable.tsx            | 215 ++++++++++++++++-----
 .../components/ReviewClusterAccountDialog.tsx      | 110 +++++++++--
 web/src/features/core/cluster-accounts/queries.ts  |  11 ++
 .../core/projects/__fixtures__/projects.json       |   2 +
 web/src/mocks/handlers/clusters.ts                 |  13 +-
 22 files changed, 1054 insertions(+), 212 deletions(-)

diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index 92cd83f5e..c2aea2af5 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -161,6 +161,10 @@ definitions:
     properties:
       access_level:
         $ref: '#/definitions/ClusterAccessLevel'
+      allocation_id:
+        type: string
+      allocation_name:
+        type: string
       approval_status:
         $ref: '#/definitions/ClusterAccountApproval'
       cluster_name:
@@ -177,10 +181,14 @@ definitions:
         description: The username of the user on the compute cluster, which may be
           different from their Airavata Custos username.
         type: string
+      project_id:
+        type: string
       provisioned_at:
         description: When the account was provisioned into the registry; nil until
           then.
         type: string
+      requested_at:
+        type: string
       review_note:
         type: string
       reviewed_at:
@@ -188,6 +196,8 @@ definitions:
       reviewed_by:
         description: The admin who approved or denied the cluster account.
         type: string
+      reviewer_name:
+        type: string
       user_id:
         type: string
     type: object
@@ -2979,13 +2989,18 @@ paths:
       - Compute Allocation Usages
   /compute-cluster-users:
     get:
-      description: Cluster users with their user and cluster, newest first. Every
-        status unless one is given.
+      description: Cluster users with their user, cluster, and the user's newest allocation
+        on that cluster. PENDING comes grouped by allocation, the rest newest first.
+        Every status unless one is given.
       parameters:
       - description: PENDING | APPROVED | DENIED
         in: query
         name: approval_status
         type: string
+      - description: Matches name, email, username, allocation, or project
+        in: query
+        name: q
+        type: string
       - description: Page size
         in: query
         name: limit
diff --git a/internal/server/cluster_account_integration_test.go b/internal/server/cluster_account_integration_test.go
index 159a6bb00..bcc1f84ef 100644
--- a/internal/server/cluster_account_integration_test.go
+++ b/internal/server/cluster_account_integration_test.go
@@ -36,14 +36,24 @@ import (
 
 // seedClusterUser creates a cluster and a user, then a cluster user with no approval given, and returns it.
 func seedClusterUser(t *testing.T, database *sqlx.DB, svc *service.Service) *models.ComputeClusterUser {
+	t.Helper()
+	return seedClusterUserOn(t, database, svc, seedCluster(t, svc))
+}
+
+func seedCluster(t *testing.T, svc *service.Service) string {
 	t.Helper()
 	cluster, err := svc.CreateComputeCluster(t.Context(), &models.ComputeCluster{Name: "appr-" + uuid.NewString()[:8]})
 	if err != nil {
 		t.Fatalf("create cluster: %v", err)
 	}
+	return cluster.ID
+}
+
+func seedClusterUserOn(t *testing.T, database *sqlx.DB, svc *service.Service, clusterID string) *models.ComputeClusterUser {
+	t.Helper()
 	userID := seedUser(t, database, fmt.Sprintf("member-%s@example.edu", uuid.NewString()))
 	cu, err := svc.CreateComputeClusterUser(t.Context(), &models.ComputeClusterUser{
-		ComputeClusterID: cluster.ID,
+		ComputeClusterID: clusterID,
 		UserID:           userID,
 		LocalUsername:    "appr-" + uuid.NewString()[:8],
 	})
@@ -53,6 +63,113 @@ func seedClusterUser(t *testing.T, database *sqlx.DB, svc *service.Service) *mod
 	return cu
 }
 
+func seedAllocation(t *testing.T, database *sqlx.DB, svc *service.Service, clusterID, projectTitle string) *models.ComputeAllocation {
+	t.Helper()
+	pi := seedUser(t, database, fmt.Sprintf("pi-%s@example.edu", uuid.NewString()))
+	project, err := svc.CreateProject(t.Context(), &models.Project{Title: projectTitle, ProjectPIID: pi})
+	if err != nil {
+		t.Fatalf("create project: %v", err)
+	}
+	alloc, err := svc.CreateComputeAllocation(t.Context(), &models.ComputeAllocation{
+		Name: "alloc-" + uuid.NewString()[:8], ProjectID: project.ID, ComputeClusterID: clusterID,
+	})
+	if err != nil {
+		t.Fatalf("create allocation: %v", err)
+	}
+	return alloc
+}
+
+func addMember(t *testing.T, svc *service.Service, allocationID, userID string) {
+	t.Helper()
+	if _, err := svc.CreateComputeAllocationMembership(t.Context(), &models.ComputeAllocationMembership{
+		ComputeAllocationID: allocationID, UserID: userID,
+	}); err != nil {
+		t.Fatalf("create membership: %v", err)
+	}
+}
+
+func listClusterAccounts(t *testing.T, srv *Server, admin, query string) ClusterAccountListResponse {
+	t.Helper()
+	rr := httptest.NewRecorder()
+	srv.ServeHTTP(rr, withTestCaller(httptest.NewRequest(http.MethodGet, "/compute-cluster-users?"+query, nil), admin, models.ClustersRead))
+	if rr.Code != http.StatusOK {
+		t.Fatalf("status: got %d, want 200: %s", rr.Code, rr.Body.String())
+	}
+	var body ClusterAccountListResponse
+	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	return body
+}
+
+// Make sure a request shows the user's allocation on the same cluster, not one on another
+// cluster, since the account only gives access to its own cluster.
+func TestListClusterAccounts_ShowsAllocationOnSameCluster(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	admin := seedUser(t, database, fmt.Sprintf("admin-%s@example.edu", uuid.NewString()))
+	clusterID := seedCluster(t, svc)
+	cu := seedClusterUserOn(t, database, svc, clusterID)
+	here := seedAllocation(t, database, svc, clusterID, "Turbulent mixing")
+	addMember(t, svc, here.ID, cu.UserID)
+	elsewhere := seedAllocation(t, database, svc, seedCluster(t, svc), "Other cluster work")
+	addMember(t, svc, elsewhere.ID, cu.UserID)
+
+	body := listClusterAccounts(t, srv, admin, "approval_status=PENDING")
+	if len(body.Items) != 1 {
+		t.Fatalf("expected one account, got %+v", body)
+	}
+	got := body.Items[0]
+	if got.AllocationID == nil || *got.AllocationID != here.ID || got.ProjectID == nil || *got.ProjectID != here.ProjectID {
+		t.Fatalf("expected the allocation on the account's cluster, got %+v", got)
+	}
+}
+
+// Make sure pending requests come grouped by project and allocation with unlinked ones last,
+// even when they arrived interleaved, so a page never splits one allocation's rows apart.
+func TestListClusterAccounts_GroupsPendingByAllocation(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	admin := seedUser(t, database, fmt.Sprintf("admin-%s@example.edu", uuid.NewString()))
+	clusterID := seedCluster(t, svc)
+	alpha := seedAllocation(t, database, svc, clusterID, "Alpha project")
+	beta := seedAllocation(t, database, svc, clusterID, "Beta project")
+
+	seedClusterUserOn(t, database, svc, clusterID)
+	for _, alloc := range []*models.ComputeAllocation{beta, alpha, beta, alpha} {
+		cu := seedClusterUserOn(t, database, svc, clusterID)
+		addMember(t, svc, alloc.ID, cu.UserID)
+	}
+
+	body := listClusterAccounts(t, srv, admin, "approval_status=PENDING")
+	var got []string
+	for _, item := range body.Items {
+		id := "none"
+		if item.AllocationID != nil {
+			id = *item.AllocationID
+		}
+		got = append(got, id)
+	}
+	want := []string{alpha.ID, alpha.ID, beta.ID, beta.ID, "none"}
+	if fmt.Sprint(got) != fmt.Sprint(want) {
+		t.Fatalf("order: got %v, want %v", got, want)
+	}
+}
+
+// Make sure search matches the allocation name, so an admin can find every request for one allocation.
+func TestListClusterAccounts_SearchesByAllocation(t *testing.T) {
+	database, svc, srv := setupTestStack(t)
+	admin := seedUser(t, database, fmt.Sprintf("admin-%s@example.edu", uuid.NewString()))
+	clusterID := seedCluster(t, svc)
+	alloc := seedAllocation(t, database, svc, clusterID, "Searchable project")
+	member := seedClusterUserOn(t, database, svc, clusterID)
+	addMember(t, svc, alloc.ID, member.UserID)
+	seedClusterUserOn(t, database, svc, clusterID)
+
+	body := listClusterAccounts(t, srv, admin, "q="+alloc.Name)
+	if body.Total != 1 || len(body.Items) != 1 || body.Items[0].ID != member.ID {
+		t.Fatalf("expected only the member of %s, got %+v", alloc.Name, body)
+	}
+}
+
 // Make sure the list can be narrowed to the accounts waiting for approval and
 // carries the user's name, email and the cluster name the admin needs.
 func TestListClusterAccounts_FiltersByApprovalStatus(t *testing.T) {
diff --git a/internal/server/compute_cluster.go b/internal/server/compute_cluster.go
index b14b7d83e..a531b7c8c 100644
--- a/internal/server/compute_cluster.go
+++ b/internal/server/compute_cluster.go
@@ -189,11 +189,12 @@ func (s *Server) deleteComputeClusterUser(w http.ResponseWriter, r *http.Request
 }
 
 // @Summary	List cluster accounts by approval status
-// @Description	Cluster users with their user and cluster, newest first. Every status unless one is given.
+// @Description	Cluster users with their user, cluster, and the user's newest allocation on that cluster. PENDING comes grouped by allocation, the rest newest first. Every status unless one is given.
 // @Tags	Compute Cluster Users
 // @Security	BearerAuth
 // @Produce	json
 // @Param	approval_status	query	string	false	"PENDING | APPROVED | DENIED"
+// @Param	q	query	string	false	"Matches name, email, username, allocation, or project"
 // @Param	limit	query	integer	false	"Page size"
 // @Param	offset	query	integer	false	"Page offset"
 // @Success	200	{object}	ClusterAccountListResponse
@@ -203,7 +204,7 @@ func (s *Server) listClusterAccounts(w http.ResponseWriter, r *http.Request) {
 	q := r.URL.Query()
 	status := models.ClusterAccountApproval(q.Get("approval_status"))
 	limit, offset := common.Paged(q)
-	rows, total, err := s.svc.ListComputeClusterUsersByApproval(r.Context(), status, limit, offset)
+	rows, total, err := s.svc.ListComputeClusterUsersByApproval(r.Context(), status, q.Get("q"), limit, offset)
 	if err != nil {
 		common.WriteServiceError(w, err)
 		return
@@ -212,9 +213,14 @@ func (s *Server) listClusterAccounts(w http.ResponseWriter, r *http.Request) {
 	for _, row := range rows {
 		items = append(items, ClusterAccountResponse{
 			ComputeClusterUser: row.ComputeClusterUser,
+			RequestedAt:        row.RequestedAt,
 			DisplayName:        strings.TrimSpace(row.FirstName + " " + row.LastName),
 			Email:              row.Email,
 			ClusterName:        row.ClusterName,
+			ReviewerName:       row.ReviewerName,
+			AllocationID:       row.AllocationID,
+			AllocationName:     row.AllocationName,
+			ProjectID:          row.ProjectID,
 		})
 	}
 	common.WriteJSON(w, http.StatusOK, ClusterAccountListResponse{Items: items, Total: total})
diff --git a/internal/server/responses.go b/internal/server/responses.go
index 3b68fd43c..80572722b 100644
--- a/internal/server/responses.go
+++ b/internal/server/responses.go
@@ -18,6 +18,8 @@
 package server
 
 import (
+	"time"
+
 	"github.com/apache/airavata-custos/internal/store"
 	"github.com/apache/airavata-custos/pkg/models"
 )
@@ -63,12 +65,17 @@ type AllocationMembershipResponse struct {
 	Email       string `json:"email,omitempty"`
 }
 
-// ClusterAccountResponse is a cluster user with the user's name and email and the cluster name, for the review list.
+// ClusterAccountResponse is one row of the cluster account review list.
 type ClusterAccountResponse struct {
 	models.ComputeClusterUser
-	DisplayName string `json:"display_name"`
-	Email       string `json:"email"`
-	ClusterName string `json:"cluster_name"`
+	RequestedAt    time.Time `json:"requested_at"`
+	DisplayName    string    `json:"display_name"`
+	Email          string    `json:"email"`
+	ClusterName    string    `json:"cluster_name"`
+	ReviewerName   *string   `json:"reviewer_name,omitempty"`
+	AllocationID   *string   `json:"allocation_id,omitempty"`
+	AllocationName *string   `json:"allocation_name,omitempty"`
+	ProjectID      *string   `json:"project_id,omitempty"`
 }
 
 // ClusterAccountListResponse is the paginated list envelope for cluster accounts.
diff --git a/internal/store/compute_cluster_user_store.go b/internal/store/compute_cluster_user_store.go
index ea47e533b..3f5162797 100644
--- a/internal/store/compute_cluster_user_store.go
+++ b/internal/store/compute_cluster_user_store.go
@@ -32,10 +32,15 @@ import (
 // review list shows.
 type ComputeClusterUserWithUser struct {
 	models.ComputeClusterUser
-	FirstName   string `db:"first_name"`
-	LastName    string `db:"last_name"`
-	Email       string `db:"user_email"`
-	ClusterName string `db:"cluster_name"`
+	RequestedAt    time.Time `db:"created_at"`
+	FirstName      string    `db:"first_name"`
+	LastName       string    `db:"last_name"`
+	Email          string    `db:"user_email"`
+	ClusterName    string    `db:"cluster_name"`
+	ReviewerName   *string   `db:"reviewer_name"`
+	AllocationID   *string   `db:"allocation_id"`
+	AllocationName *string   `db:"allocation_name"`
+	ProjectID      *string   `db:"project_id"`
 }
 
 type pgComputeClusterUserStore struct {
@@ -147,25 +152,47 @@ func (s *pgComputeClusterUserStore) Create(ctx context.Context, tx *sql.Tx, c *m
 	return err
 }
 
-func (s *pgComputeClusterUserStore) ListByApprovalStatus(ctx context.Context, status models.ClusterAccountApproval, limit, offset int) ([]ComputeClusterUserWithUser, int, error) {
+// clusterAccountListFrom links each account to the user's newest allocation on
+// the same cluster.
+// TODO The request does not store its allocation, so this is inferred.
+const clusterAccountListFrom = `
+	  FROM compute_cluster_users cu
+	  JOIN users u            ON u.id = cu.user_id
+	  JOIN compute_clusters c ON c.id = cu.compute_cluster_id
+	  LEFT JOIN users r       ON r.id = cu.reviewed_by
+	  LEFT JOIN LATERAL (
+	       SELECT ca.id, ca.name, ca.project_id, p.title
+	         FROM compute_allocation_memberships m
+	         JOIN compute_allocations ca ON ca.id = m.compute_allocation_id
+	         JOIN projects p             ON p.id = ca.project_id
+	        WHERE m.user_id = cu.user_id AND ca.compute_cluster_id = cu.compute_cluster_id
+	        ORDER BY m.created_at DESC
+	        LIMIT 1) a ON true
+	 WHERE ($1 = '' OR cu.approval_status = $1)
+	   AND ($2 = '' OR CONCAT_WS(' ', u.first_name, u.last_name, u.email, cu.local_username, a.name, a.title) ILIKE '%' || $2 || '%')`
+
+func (s *pgComputeClusterUserStore) ListByApprovalStatus(ctx context.Context, status models.ClusterAccountApproval, query string, limit, offset int) ([]ComputeClusterUserWithUser, int, error) {
+	// Pending is worked one allocation at a time, so its rows come grouped.
+	order := `cu.created_at DESC, cu.id`
+	if status == models.ClusterAccountPending {
+		order = `a.id IS NULL, a.title, a.name, a.id, cu.created_at, cu.id`
+	}
 	var rows []ComputeClusterUserWithUser
 	err := s.db.SelectContext(ctx, &rows,
 		`SELECT cu.id, cu.compute_cluster_id, cu.user_id, cu.local_username, cu.access_level, cu.provisioned_at,
-		        cu.approval_status, cu.reviewed_at, cu.reviewed_by, cu.review_note,
-		        u.first_name, u.last_name, u.email AS user_email, c.name AS cluster_name
-		   FROM compute_cluster_users cu
-		   JOIN users u            ON u.id = cu.user_id
-		   JOIN compute_clusters c ON c.id = cu.compute_cluster_id
-		  WHERE $1 = '' OR cu.approval_status = $1
-		  ORDER BY cu.created_at DESC
-		  LIMIT $2 OFFSET $3`,
-		status, limit, offset)
+		        cu.approval_status, cu.reviewed_at, cu.reviewed_by, cu.review_note, cu.created_at,
+		        u.first_name, u.last_name, u.email AS user_email, c.name AS cluster_name,
+		        NULLIF(TRIM(CONCAT_WS(' ', r.first_name, r.last_name)), '') AS reviewer_name,
+		        a.id AS allocation_id, a.name AS allocation_name, a.project_id`+
+			clusterAccountListFrom+`
+		  ORDER BY `+order+`
+		  LIMIT $3 OFFSET $4`,
+		status, query, limit, offset)
 	if err != nil {
 		return nil, 0, err
 	}
 	var total int
-	if err := s.db.GetContext(ctx, &total,
-		`SELECT COUNT(*) FROM compute_cluster_users WHERE $1 = '' OR approval_status = $1`, status); err != nil {
+	if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*)`+clusterAccountListFrom, status, query); err != nil {
 		return nil, 0, err
 	}
 	return rows, total, nil
diff --git a/internal/store/store.go b/internal/store/store.go
index abd0c638e..01eecc757 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -99,8 +99,8 @@ type ComputeClusterUserStore interface {
 	FindByUser(ctx context.Context, userID string) ([]models.ComputeClusterUser, error)
 	// FindByUserTx returns the user's mappings locked FOR UPDATE in tx, ordered by compute_cluster_id.
 	FindByUserTx(ctx context.Context, tx *sql.Tx, userID string) ([]models.ComputeClusterUser, error)
-	// ListByApprovalStatus returns mappings joined with the user and cluster, newest first, plus the total. An empty status means any status.
-	ListByApprovalStatus(ctx context.Context, status models.ClusterAccountApproval, limit, offset int) ([]ComputeClusterUserWithUser, int, error)
+	// ListByApprovalStatus returns mappings joined with the user, cluster and allocation, plus the total. Empty status or query matches all.
+	ListByApprovalStatus(ctx context.Context, status models.ClusterAccountApproval, query string, limit, offset int) ([]ComputeClusterUserWithUser, int, error)
 	// Create inserts a new mapping within the provided transaction.
 	Create(ctx context.Context, tx *sql.Tx, c *models.ComputeClusterUser) error
 	// Review records the admin's decision on the mapping within the provided transaction.
diff --git a/pkg/service/compute_cluster_user.go b/pkg/service/compute_cluster_user.go
index 62ae9d2bc..3caee800f 100644
--- a/pkg/service/compute_cluster_user.go
+++ b/pkg/service/compute_cluster_user.go
@@ -139,15 +139,13 @@ func (s *Service) CreateComputeClusterUser(ctx context.Context, cu *models.Compu
 	return cu, nil
 }
 
-// ListComputeClusterUsersByApproval returns cluster users in the given
-// approval status with their user and cluster, newest first. An empty status means any status.
-func (s *Service) ListComputeClusterUsersByApproval(ctx context.Context, status models.ClusterAccountApproval, limit, offset int) ([]store.ComputeClusterUserWithUser, int, error) {
+func (s *Service) ListComputeClusterUsersByApproval(ctx context.Context, status models.ClusterAccountApproval, query string, limit, offset int) ([]store.ComputeClusterUserWithUser, int, error) {
 	switch status {
 	case "", models.ClusterAccountPending, models.ClusterAccountApproved, models.ClusterAccountDenied:
 	default:
 		return nil, 0, fmt.Errorf("%w: unknown approval status %q", ErrInvalidInput, status)
 	}
-	rows, total, err := s.clusterUsers.ListByApprovalStatus(ctx, status, limit, offset)
+	rows, total, err := s.clusterUsers.ListByApprovalStatus(ctx, status, strings.TrimSpace(query), limit, offset)
 	if err != nil {
 		return nil, 0, fmt.Errorf("list compute cluster users by approval: %w", err)
 	}
diff --git a/web/src/app/(portal)/admin/users/account-approvals/AccountApprovalsContainer.tsx b/web/src/app/(portal)/admin/users/account-approvals/AccountApprovalsContainer.tsx
index 5b9ad1222..7e1e11dab 100644
--- a/web/src/app/(portal)/admin/users/account-approvals/AccountApprovalsContainer.tsx
+++ b/web/src/app/(portal)/admin/users/account-approvals/AccountApprovalsContainer.tsx
@@ -24,24 +24,35 @@ import {
 } from "@/features/core/cluster-accounts/components/ReviewClusterAccountDialog";
 import {
   useApproveClusterAccount,
+  useApproveClusterAccounts,
   useClusterAccounts,
   useDenyClusterAccount,
 } from "@/features/core/cluster-accounts/queries";
 import type { ClusterAccountApproval, ClusterAccountResponse } from "@/generated/core/zod.gen";
 import { cn } from "@/lib/utils";
+import { ApiError } from "@/shared/api/client";
 import { useAbility } from "@/shared/casl/AbilityProvider";
+import { useDebounce } from "@/shared/hooks/useDebounce";
 import {
   replaceShallowSearchParams,
   useShallowSearchParams,
 } from "@/shared/hooks/useShallowSearchParams";
+import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Search } from "lucide-react";
 import * as React from "react";
 import { toast } from "sonner";
 
 const PAGE_SIZE = 25;
+// Pending is not paged so Approve all covers a whole allocation.
+const PENDING_LIMIT = 200;
 
 type Filter = ClusterAccountApproval | "ALL";
 
+type Review = { accounts: ClusterAccountResponse[]; action: ReviewAction; bulk: boolean };
+
 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." },
@@ -61,21 +72,32 @@ export function AccountApprovalsContainer() {
   const params = useShallowSearchParams();
   const filter = readFilter(params);
   const [page, setPage] = React.useState(1);
-  const [review, setReview] = React.useState<{
-    account: ClusterAccountResponse;
-    action: ReviewAction;
-  } | null>(null);
+  const [search, setSearch] = React.useState(() => params.get("q") ?? "");
+  const q = useDebounce(search.trim(), 300);
+  const [review, setReview] = React.useState<Review | null>(null);
   const [reviewError, setReviewError] = React.useState<string | null>(null);
+  const [failures, setFailures] = React.useState<Record<string, string>>({});
+
+  React.useEffect(() => {
+    const next = new URLSearchParams(window.location.search);
+    if (q) next.set("q", q);
+    else next.delete("q");
+    replaceShallowSearchParams(next);
+  }, [q]);
 
+  const grouped = filter === "PENDING";
   const query = useClusterAccounts(
     {
       ...(filter === "ALL" ? {} : { approval_status: filter }),
-      limit: PAGE_SIZE,
-      offset: (page - 1) * PAGE_SIZE,
+      ...(q ? { q } : {}),
+      ...(grouped
+        ? { limit: PENDING_LIMIT }
+        : { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
     },
     { enabled: canRead },
   );
   const approve = useApproveClusterAccount();
+  const approveMany = useApproveClusterAccounts();
   const deny = useDenyClusterAccount();
 
   function setFilter(next: Filter) {
@@ -89,23 +111,59 @@ export function AccountApprovalsContainer() {
   function openReview(action: ReviewAction) {
     return (account: ClusterAccountResponse) => {
       setReviewError(null);
-      setReview({ account, action });
+      setReview({ accounts: [account], action, bulk: false });
     };
   }
 
+  function openApproveAll(accounts: ClusterAccountResponse[]) {
+    setReviewError(null);
+    setFailures({});
+    setReview({ accounts, action: "approve", bulk: true });
+  }
+
+  async function handleApproveAll(accounts: ClusterAccountResponse[]) {
+    const results = await approveMany.mutateAsync(accounts.map((a) => a.id));
+    const failed: Record<string, string> = {};
+    results.forEach((result, i) => {
+      const account = accounts[i];
+      if (result.status === "fulfilled" || !account) return;
+      // 409: already approved by someone else.
+      if (result.reason instanceof ApiError && result.reason.status === 409) return;
+      failed[account.id] =
+        result.reason instanceof Error ? result.reason.message : "Approval failed.";
+    });
+    const failedAccounts = accounts.filter((a) => failed[a.id]);
+    if (failedAccounts.length === 0) {
+      const n = accounts.length;
+      toast.success(
+        `Approved ${n} cluster account${n === 1 ? "" : "s"} on ${accounts[0]?.cluster_name}.`,
+      );
+      setReview(null);
+      return;
+    }
+    toast.error(
+      `Approved ${accounts.length - failedAccounts.length} of ${accounts.length}. ${failedAccounts.length} failed.`,
+    );
+    setFailures(failed);
+    setReview({ accounts: failedAccounts, action: "approve", bulk: true });
+  }
+
   async function handleConfirm(note: string) {
     if (!review) return;
+    if (review.bulk) return handleApproveAll(review.accounts);
+    const [account] = review.accounts;
+    if (!account) return;
     setReviewError(null);
     try {
       if (review.action === "approve") {
-        await approve.mutateAsync(review.account.id);
+        await approve.mutateAsync(account.id);
         toast.success(
-          `Cluster account approved for ${review.account.local_username} on ${review.account.cluster_name}`,
+          `Cluster account approved for ${account.local_username} on ${account.cluster_name}`,
         );
       } else {
-        await deny.mutateAsync({ id: review.account.id, note });
+        await deny.mutateAsync({ id: account.id, note });
         toast.success(
-          `Cluster account denied for ${review.account.local_username} on ${review.account.cluster_name}`,
+          `Cluster account denied for ${account.local_username} on ${account.cluster_name}`,
         );
       }
       setReview(null);
@@ -120,6 +178,9 @@ export function AccountApprovalsContainer() {
 
   const emptyHeading =
     FILTERS.find((f) => f.value === filter)?.empty ?? "Nothing waiting for approval.";
+  const rows = query.data?.items ?? [];
+  const total = query.data?.total ?? 0;
+  const noMatch = q !== "" && !query.isLoading && !query.error && rows.length === 0;
 
   return (
     <div className="space-y-4">
@@ -150,31 +211,71 @@ export function AccountApprovalsContainer() {
         </p>
       </div>
 
-      <ClusterAccountsTable
-        rows={query.data?.items ?? []}
-        isLoading={query.isLoading}
-        error={query.error}
-        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}
-      />
+      <div className="relative max-w-lg">
+        <Search
+          aria-hidden
+          className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
+        />
+        <Input
+          type="search"
+          placeholder="Search by name, email, username, allocation or project"
+          aria-label="Search requests"
+          value={search}
+          onChange={(e) => {
+            setSearch(e.target.value);
+            setPage(1);
+          }}
+          className="h-9 pl-9"
+        />
+      </div>
+
+      {noMatch ? (
+        <EmptyState
+          heading={`No requests match "${q}".`}
+          cta={
+            <Button variant="outline" size="sm" onClick={() => setSearch("")}>
+              Clear search
+            </Button>
+          }
+        />
+      ) : (
+        <>
+          {grouped && total > PENDING_LIMIT ? (
+            <p className="text-sm text-muted-foreground">
+              Showing {PENDING_LIMIT} of {total}. Approve some to see the rest.
+            </p>
+          ) : null}
+          <ClusterAccountsTable
+            rows={rows}
+            isLoading={query.isLoading}
+            error={query.error}
+            onRetry={() => void query.refetch()}
+            canReview={canReview}
+            onApprove={openReview("approve")}
+            onDeny={openReview("deny")}
+            onApproveAll={openApproveAll}
+            grouped={grouped}
+            page={page}
+            pageSize={PAGE_SIZE}
+            total={total}
+            onPageChange={setPage}
+            emptyHeading={emptyHeading}
+          />
+        </>
+      )}
 
       {canReview ? (
         <ReviewClusterAccountDialog
-          account={review?.account ?? null}
+          accounts={review?.accounts ?? []}
           action={review?.action ?? "approve"}
+          bulk={review?.bulk ?? false}
           onOpenChange={(open) => {
             if (!open) setReview(null);
           }}
           onConfirm={handleConfirm}
-          isPending={approve.isPending || deny.isPending}
+          isPending={approve.isPending || approveMany.isPending || deny.isPending}
           error={reviewError}
+          failures={failures}
         />
       ) : null}
     </div>
diff --git a/web/src/app/(portal)/admin/users/account-approvals/__tests__/AccountApprovalsContainer.test.tsx b/web/src/app/(portal)/admin/users/account-approvals/__tests__/AccountApprovalsContainer.test.tsx
new file mode 100644
index 000000000..78385ff97
--- /dev/null
+++ b/web/src/app/(portal)/admin/users/account-approvals/__tests__/AccountApprovalsContainer.test.tsx
@@ -0,0 +1,85 @@
+// 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 fixture from "@/features/core/cluster-accounts/__fixtures__/cluster-accounts.json";
+import { clustersHandlers } from "@/mocks/handlers/clusters";
+import { projectsHandlers } from "@/mocks/handlers/projects";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { AccountApprovalsContainer } from "../AccountApprovalsContainer";
+
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
+vi.mock("sonner", () => ({ toast }));
+vi.mock("@/shared/casl/AbilityProvider", () => ({ useAbility: () => ({ can: () => true }) }));
+vi.mock("@/shared/hooks/useShallowSearchParams", () => ({
+  useShallowSearchParams: () => new URLSearchParams(),
+  replaceShallowSearchParams: vi.fn(),
+}));
+
+const server = setupServer(...clustersHandlers, ...projectsHandlers);
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const pending = fixture.filter((a) => a.approval_status === "PENDING");
+
+async function approveAll(johnReply: { status: number; error: string }) {
+  server.use(
+    http.get("*/api/v1/compute-cluster-users", () =>
+      HttpResponse.json({ items: pending, total: pending.length }),
+    ),
+    http.post<{ id: string }>("*/api/v1/compute-cluster-users/:id/approve", ({ params }) =>
+      params.id === "ccu-pending-2"
+        ? HttpResponse.json({ error: johnReply.error }, { status: johnReply.status })
+        : undefined,
+    ),
+  );
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  render(
+    <QueryClientProvider client={client}>
+      <AccountApprovalsContainer />
+    </QueryClientProvider>,
+  );
+  fireEvent.click(await screen.findByRole("button", { name: /^Approve all/ }));
+  fireEvent.click(screen.getByRole("button", { name: "Approve 3" }));
+}
+
+describe("AccountApprovalsContainer", () => {
+  // Make sure one failed approval in Approve all keeps only that account listed, with its reason.
+  it("reports the rest approved and keeps the failed one listed", async () => {
+    await approveAll({ status: 502, error: "cluster unreachable" });
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Approved 2 of 3. 1 failed."));
+    const list = screen.getByRole("list", { name: "Accounts to approve" });
+    expect(within(list).getAllByRole("listitem")).toHaveLength(1);
+    expect(within(list).getByText("custos-jroe")).toBeInTheDocument();
+    expect(within(list).getByText("cluster unreachable")).toBeInTheDocument();
+  });
+
+  // Make sure an account someone else approved first (409) counts as approved, not failed.
+  it("counts an already approved account as done", async () => {
+    await approveAll({ status: 409, error: "already approved" });
+
+    await waitFor(() =>
+      expect(toast.success).toHaveBeenCalledWith("Approved 3 cluster accounts on cluster-a."),
+    );
+    expect(screen.queryByRole("list", { name: "Accounts to approve" })).toBeNull();
+  });
+});
diff --git a/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
index ce8cb7839..06071b7bc 100644
--- a/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
+++ b/web/src/app/(portal)/admin/users/management/UsersTableContainer.tsx
@@ -213,7 +213,7 @@ export function UsersTableContainer() {
       ) : null}
       {canReviewAccounts ? (
         <ReviewClusterAccountDialog
-          account={review?.account ?? null}
+          accounts={review ? [review.account] : []}
           action={review?.action ?? "approve"}
           onOpenChange={(open) => {
             if (!open) setReview(null);
diff --git a/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json b/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json
index d81954462..7b99215f1 100644
--- a/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json
+++ b/web/src/features/core/cluster-accounts/__fixtures__/cluster-accounts.json
@@ -2,68 +2,152 @@
   {
     "id": "ccu-pending-1",
     "compute_cluster_id": "cluster-a",
-    "user_id": "user-jane-doe",
-    "local_username": "jdoe",
+    "user_id": "user-pi-001",
+    "local_username": "custos-jdoe",
     "access_level": "USER",
     "provisioned_at": null,
     "approval_status": "PENDING",
+    "requested_at": "2026-10-14T09:00:00Z",
+    "allocation_id": "alloc-001",
+    "allocation_name": "Genomic Sequencing - GPU Pool",
+    "project_id": "project-001",
     "display_name": "Jane Doe",
-    "email": "jdoe@example.edu",
+    "email": "jane.doe@example.edu",
     "cluster_name": "cluster-a"
   },
   {
     "id": "ccu-pending-2",
-    "compute_cluster_id": "cluster-b",
-    "user_id": "user-marcus-lee",
-    "local_username": "mlee",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-john-roe",
+    "local_username": "custos-jroe",
     "access_level": "USER",
     "provisioned_at": null,
     "approval_status": "PENDING",
-    "display_name": "Marcus Lee",
-    "email": "mlee@example.edu",
-    "cluster_name": "cluster-b"
+    "requested_at": "2026-10-14T10:30:00Z",
+    "allocation_id": "alloc-001",
+    "allocation_name": "Genomic Sequencing - GPU Pool",
+    "project_id": "project-001",
+    "display_name": "John Roe",
+    "email": "john.roe@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-pending-3",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-mary-major",
+    "local_username": "custos-mmajor",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "PENDING",
+    "requested_at": "2026-10-15T14:00:00Z",
+    "allocation_id": "alloc-001",
+    "allocation_name": "Genomic Sequencing - GPU Pool",
+    "project_id": "project-001",
+    "display_name": "Mary Major",
+    "email": "mary.major@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-pending-4",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-richard-roe",
+    "local_username": "custos-rroe",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "PENDING",
+    "requested_at": "2026-10-18T08:15:00Z",
+    "allocation_id": "alloc-003",
+    "allocation_name": "Plasma Simulation - GPU",
+    "project_id": "project-002",
+    "display_name": "Richard Roe",
+    "email": "richard.roe@example.edu",
+    "cluster_name": "cluster-a"
+  },
+  {
+    "id": "ccu-pending-5",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-alex-doe",
+    "local_username": "custos-adoe",
+    "access_level": "USER",
+    "provisioned_at": null,
+    "approval_status": "PENDING",
+    "requested_at": "2026-10-21T16:45:00Z",
+    "display_name": "Alex Doe",
+    "email": "alex.doe@example.edu",
+    "cluster_name": "cluster-a"
   },
   {
     "id": "ccu-approved-1",
     "compute_cluster_id": "cluster-a",
-    "user_id": "user-wei-zhang",
-    "local_username": "wzhang",
+    "user_id": "user-sam-doe",
+    "local_username": "custos-sdoe",
     "access_level": "USER",
     "provisioned_at": null,
     "approval_status": "APPROVED",
+    "requested_at": "2026-09-28T10:00:00Z",
     "reviewed_at": "2026-09-29T14:00:00Z",
     "reviewed_by": "user-admin",
-    "display_name": "Wei Zhang",
-    "email": "wzhang@example.edu",
+    "reviewer_name": "Pat Admin",
+    "allocation_id": "alloc-001",
+    "allocation_name": "Genomic Sequencing - GPU Pool",
+    "project_id": "project-001",
+    "display_name": "Sam Doe",
+    "email": "sam.doe@example.edu",
     "cluster_name": "cluster-a"
   },
   {
     "id": "ccu-active-1",
     "compute_cluster_id": "cluster-a",
-    "user_id": "user-tom-becker",
-    "local_username": "tbecker",
+    "user_id": "user-pi-002",
+    "local_username": "custos-jdoe2",
     "access_level": "USER",
     "provisioned_at": "2026-09-27T09:30:00Z",
     "approval_status": "APPROVED",
+    "requested_at": "2026-09-25T12:00:00Z",
     "reviewed_at": "2026-09-26T16:10:00Z",
     "reviewed_by": "user-admin",
-    "display_name": "Tom Becker",
-    "email": "tbecker@example.edu",
+    "reviewer_name": "Pat Admin",
+    "allocation_id": "alloc-003",
+    "allocation_name": "Plasma Simulation - GPU",
+    "project_id": "project-002",
+    "display_name": "John Doe",
+    "email": "john.doe@example.edu",
     "cluster_name": "cluster-a"
   },
   {
-    "id": "ccu-denied-1",
+    "id": "ccu-approved-2",
     "compute_cluster_id": "cluster-b",
-    "user_id": "user-sam-okafor",
-    "local_username": "sokafor",
+    "user_id": "user-kim-doe",
+    "local_username": "custos-kdoe",
+    "access_level": "USER",
+    "provisioned_at": "2026-09-21T09:00:00Z",
+    "approval_status": "APPROVED",
+    "requested_at": "2026-09-19T08:00:00Z",
+    "reviewed_at": "2026-09-20T11:20:00Z",
+    "reviewed_by": "user-admin",
+    "reviewer_name": "Pat Admin",
+    "display_name": "Kim Doe",
+    "email": "kim.doe@example.edu",
+    "cluster_name": "cluster-b"
+  },
+  {
+    "id": "ccu-denied-1",
+    "compute_cluster_id": "cluster-a",
+    "user_id": "user-lee-roe",
+    "local_username": "custos-lroe",
     "access_level": "USER",
     "provisioned_at": null,
     "approval_status": "DENIED",
+    "requested_at": "2026-09-22T15:00:00Z",
     "reviewed_at": "2026-09-24T11:00:00Z",
     "reviewed_by": "user-admin",
+    "reviewer_name": "Pat Admin",
     "review_note": "Not on the approved collaborator list.",
-    "display_name": "Sam Okafor",
-    "email": "sokafor@example.edu",
-    "cluster_name": "cluster-b"
+    "allocation_id": "alloc-003",
+    "allocation_name": "Plasma Simulation - GPU",
+    "project_id": "project-002",
+    "display_name": "Lee Roe",
+    "email": "lee.roe@example.edu",
+    "cluster_name": "cluster-a"
   }
 ]
diff --git a/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx b/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx
index aeaa17bbc..e342e9c21 100644
--- a/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx
+++ b/web/src/features/core/cluster-accounts/__tests__/ClusterAccountsTable.test.tsx
@@ -16,12 +16,21 @@
 // under the License.
 
 import { type ClusterAccountResponse, zClusterAccountResponse } from "@/generated/core/zod.gen";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { projectsHandlers } from "@/mocks/handlers/projects";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { setupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
 import fixture from "../__fixtures__/cluster-accounts.json";
 import { ClusterAccountsTable } from "../components/ClusterAccountsTable";
 
 const rows = zClusterAccountResponse.array().parse(fixture);
+const pending = rows.filter((r) => r.approval_status === "PENDING");
+
+const server = setupServer(...projectsHandlers);
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
 
 function byId(id: string): ClusterAccountResponse {
   const row = rows.find((r) => r.id === id);
@@ -35,49 +44,56 @@ function rowOf(text: string): HTMLTableRowElement {
   return row;
 }
 
-function renderTable(canReview: boolean, shown: ClusterAccountResponse[] = rows) {
+function renderTable(canReview: boolean, shown: ClusterAccountResponse[] = rows, grouped = false) {
   const onApprove = vi.fn();
   const onDeny = vi.fn();
+  const onApproveAll = vi.fn();
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
   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."
-    />,
+    <QueryClientProvider client={client}>
+      <ClusterAccountsTable
+        rows={shown}
+        isLoading={false}
+        error={null}
+        canReview={canReview}
+        onApprove={onApprove}
+        onDeny={onDeny}
+        onApproveAll={onApproveAll}
+        grouped={grouped}
+        page={1}
+        pageSize={25}
+        total={shown.length}
+        onPageChange={vi.fn()}
+        emptyHeading="Nothing waiting for approval."
+      />
+    </QueryClientProvider>,
   );
-  return { onApprove, onDeny };
+  return { onApprove, onDeny, onApproveAll };
 }
 
 describe("ClusterAccountsTable", () => {
   // Make sure only a pending account offers decisions; the rest show their approval status in that column.
   it("offers decisions on pending accounts and the status on the rest", () => {
     renderTable(true);
-    const pending = rowOf("jdoe");
-    expect(pending.textContent).toContain("Approve");
-    expect(pending.textContent).toContain("Deny");
+    const row = rowOf("custos-jdoe");
+    expect(row.textContent).toContain("Approve");
+    expect(row.textContent).toContain("Deny");
 
-    const denied = rowOf("sokafor");
+    const denied = rowOf("custos-lroe");
     expect(denied.querySelector("button")).toBeNull();
     expect(denied.textContent).toContain("Denied");
 
-    expect(rowOf("wzhang").textContent).toContain("Approved · provisioning");
-    expect(rowOf("tbecker").textContent).toContain("Active");
+    expect(rowOf("custos-sdoe").textContent).toContain("Approved · provisioning");
+    expect(rowOf("custos-jdoe2").textContent).toContain("Active");
   });
 
   // Make sure a reader without the write privilege sees the pending status but no decision buttons.
   it("hides the decisions without the review privilege", () => {
-    renderTable(false);
+    renderTable(false, pending, true);
     expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
     expect(screen.queryByRole("button", { name: "Deny" })).toBeNull();
-    expect(rowOf("jdoe").textContent).toContain("Pending approval");
+    expect(screen.queryByRole("button", { name: /^Approve all/ })).toBeNull();
+    expect(rowOf("custos-jdoe").textContent).toContain("Pending approval");
   });
 
   it("hands the clicked account to the approve and deny callbacks", () => {
@@ -94,4 +110,34 @@ describe("ClusterAccountsTable", () => {
     renderTable(false, [byId("ccu-denied-1")]);
     expect(screen.getByText("Not on the approved collaborator list.")).toBeInTheDocument();
   });
+
+  // Make sure each pending request sits under its own allocation's card, in server order.
+  it("groups pending requests by allocation in server order", () => {
+    const unlinked = pending.filter((r) => !r.allocation_id);
+    const linked = pending.filter((r) => r.allocation_id);
+    renderTable(true, [...linked, ...unlinked], true);
+
+    const sections = screen.getAllByRole("region");
+    const names = sections.map(
+      (s) => document.getElementById(s.getAttribute("aria-labelledby") ?? "")?.textContent,
+    );
+    expect(names).toEqual([
+      "Genomic Sequencing - GPU Pool",
+      "Plasma Simulation - GPU",
+      "No allocation",
+    ]);
+    expect(within(sections[0] as HTMLElement).getByText("custos-jroe")).toBeInTheDocument();
+    expect(within(sections[2] as HTMLElement).getByText("custos-adoe")).toBeInTheDocument();
+  });
+
+  // Make sure Approve all shows only on an allocation with two or more requests, and hands over just those.
+  it("offers Approve all only on a linked group with several requests", () => {
+    const { onApproveAll } = renderTable(true, pending, true);
+    const buttons = screen.getAllByRole("button", { name: /^Approve all/ });
+    expect(buttons).toHaveLength(1);
+
+    fireEvent.click(buttons[0] as HTMLElement);
+    const ids = (onApproveAll.mock.calls[0]?.[0] as ClusterAccountResponse[]).map((r) => r.id);
+    expect(ids).toEqual(["ccu-pending-1", "ccu-pending-2", "ccu-pending-3"]);
+  });
 });
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 acf077206..c0103a8ce 100644
--- a/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
+++ b/web/src/features/core/cluster-accounts/__tests__/ReviewClusterAccountDialog.test.tsx
@@ -16,28 +16,45 @@
 // under the License.
 
 import { zClusterAccountResponse } from "@/generated/core/zod.gen";
+import { projectsHandlers } from "@/mocks/handlers/projects";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { setupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
 import fixture from "../__fixtures__/cluster-accounts.json";
-import { ReviewClusterAccountDialog } from "../components/ReviewClusterAccountDialog";
+import {
+  type ReviewAction,
+  ReviewClusterAccountDialog,
+} from "../components/ReviewClusterAccountDialog";
 
 const [pending] = zClusterAccountResponse.array().parse(fixture);
 if (!pending) throw new Error("no cluster account fixture");
 
-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(
+const server = setupServer(...projectsHandlers);
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+function renderDialog(action: ReviewAction, onConfirm = vi.fn()) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  render(
+    <QueryClientProvider client={client}>
       <ReviewClusterAccountDialog
-        account={pending}
-        action="deny"
+        accounts={pending ? [pending] : []}
+        action={action}
         onOpenChange={vi.fn()}
         onConfirm={onConfirm}
         isPending={false}
-      />,
-    );
+      />
+    </QueryClientProvider>,
+  );
+  return onConfirm;
+}
+
+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 = renderDialog("deny");
     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");
@@ -46,15 +63,7 @@ describe("ReviewClusterAccountDialog", () => {
   // 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}
-      />,
-    );
+    renderDialog("deny");
     expect(screen.getByRole("button", { name: "Deny" })).toBeDisabled();
     fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "   " } });
     expect(screen.getByRole("button", { name: "Deny" })).toBeDisabled();
@@ -65,17 +74,10 @@ describe("ReviewClusterAccountDialog", () => {
   // 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();
+    renderDialog("approve");
+    expect(screen.getByText("custos-jdoe")).toBeInTheDocument();
     expect(screen.getByText("cluster-a")).toBeInTheDocument();
+    expect(screen.getByText("Genomic Sequencing - GPU Pool")).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
index 5f3117242..e6d6b63a9 100644
--- a/web/src/features/core/cluster-accounts/__tests__/api.test.ts
+++ b/web/src/features/core/cluster-accounts/__tests__/api.test.ts
@@ -43,6 +43,7 @@ const account = {
   display_name: "Jane Doe",
   email: "jdoe@example.edu",
   cluster_name: "cluster-a",
+  requested_at: "2026-10-14T09:00:00Z",
 };
 
 afterEach(() => fetchMock.mockReset());
diff --git a/web/src/features/core/cluster-accounts/api.ts b/web/src/features/core/cluster-accounts/api.ts
index 3d576077a..0c6dd884b 100644
--- a/web/src/features/core/cluster-accounts/api.ts
+++ b/web/src/features/core/cluster-accounts/api.ts
@@ -32,6 +32,7 @@ export async function listClusterAccounts(
 ): Promise<ClusterAccountListResponse> {
   const search = new URLSearchParams();
   if (params.approval_status) search.set("approval_status", params.approval_status);
+  if (params.q) search.set("q", params.q);
   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();
diff --git a/web/src/features/core/cluster-accounts/components/AllocationRef.tsx b/web/src/features/core/cluster-accounts/components/AllocationRef.tsx
new file mode 100644
index 000000000..373bd3056
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/components/AllocationRef.tsx
@@ -0,0 +1,46 @@
+// 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 { ProjectRef } from "@/features/core/projects/components/ProjectRef";
+import { useProject } from "@/features/core/projects/queries";
+import type { ClusterAccountResponse } from "@/generated/core/zod.gen";
+
+export function AllocationRef({
+  account,
+  withTitle = false,
+}: {
+  account: ClusterAccountResponse;
+  withTitle?: boolean;
+}) {
+  const { data: project } = useProject(account.project_id);
+  if (!account.allocation_id) return <span className="text-xs text-muted-foreground">None</span>;
+  return (
+    <div className="min-w-40 whitespace-normal">
+      <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
+        <span className="font-medium">{account.allocation_name}</span>
+        {project ? <ProjectRef project={project} /> : null}
+      </div>
+      {withTitle ? (
+        <div title={project?.title} className="max-w-[32ch] truncate text-xs text-muted-foreground">
+          {project?.title}
+        </div>
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/components/ApprovalGroupHeader.tsx b/web/src/features/core/cluster-accounts/components/ApprovalGroupHeader.tsx
new file mode 100644
index 000000000..2f3b09083
--- /dev/null
+++ b/web/src/features/core/cluster-accounts/components/ApprovalGroupHeader.tsx
@@ -0,0 +1,103 @@
+// 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 { ProjectRef } from "@/features/core/projects/components/ProjectRef";
+import type { ProjectResponse } from "@/generated/core/zod.gen";
+import { StatusBadge } from "@/shared/ui/StatusBadge";
+import { StatusIcon } from "@/shared/ui/StatusIcon";
+import { Button } from "@/shared/ui/button";
+import { CheckCheck, CircleHelp, Server } from "lucide-react";
+import Link from "next/link";
+
+export type ApprovalGroupHeaderProps = {
+  labelId: string;
+  allocationId?: string | undefined;
+  allocationName?: string | undefined;
+  project?: ProjectResponse | undefined;
+  cluster: string;
+  count: number;
+  onApproveAll?: (() => void) | undefined;
+};
+
+export function ApprovalGroupHeader({
+  labelId,
+  allocationId,
+  allocationName,
+  project,
+  cluster,
+  count,
+  onApproveAll,
+}: ApprovalGroupHeaderProps) {
+  const pill = <StatusBadge variant="warning" label={`${count} waiting`} />;
+
+  if (!allocationId) {
+    return (
+      <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+        <StatusIcon icon={CircleHelp} active={false} label="No allocation" />
+        <span id={labelId} className="text-sm font-semibold text-muted-foreground">
+          No allocation
+        </span>
+        <span className="min-w-0 flex-1 basis-48 text-xs text-muted-foreground">
+          Not linked to an allocation. Check who this person is before approving.
+        </span>
+        {pill}
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+      <StatusIcon
+        icon={Server}
+        active={project?.status === "ACTIVE"}
+        label={project ? `Project ${project.status.toLowerCase()}` : "Allocation"}
+      />
+      <Link
+        id={labelId}
+        href={`/allocations/${allocationId}`}
+        className="text-sm font-semibold text-foreground hover:underline"
+      >
+        {allocationName ?? allocationId}
+      </Link>
+      {project ? <ProjectRef project={project} /> : null}
+      <span
+        title={project?.title}
+        className="min-w-0 flex-1 basis-48 truncate text-xs text-muted-foreground"
+      >
+        {project?.title}
+      </span>
+      <span className="shrink-0 text-xs text-muted-foreground">
+        {project ? `PI ${project.project_pi_display_name ?? project.project_pi_id} · ` : null}
+        <span className="font-mono">{cluster}</span>
+      </span>
+      {pill}
+      {onApproveAll ? (
+        <Button
+          size="xs"
+          variant="success"
+          aria-label={`Approve all ${count} accounts in ${allocationName ?? allocationId}`}
+          onClick={onApproveAll}
+        >
+          <CheckCheck data-icon="inline-start" />
+          Approve all
+        </Button>
+      ) : null}
+    </div>
+  );
+}
diff --git a/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx b/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx
index 34611aa47..76ad93cf1 100644
--- a/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx
+++ b/web/src/features/core/cluster-accounts/components/ClusterAccountsTable.tsx
@@ -17,86 +17,108 @@
 
 "use client";
 
+import { useProject } from "@/features/core/projects/queries";
 import type { ClusterAccountResponse } from "@/generated/core/zod.gen";
 import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
 import { ErrorState } from "@/shared/ui/ErrorState";
 import { TableSkeleton } from "@/shared/ui/Loading";
 import { StatusBadge } from "@/shared/ui/StatusBadge";
+import { Badge } from "@/shared/ui/badge";
 import { Button } from "@/shared/ui/button";
+import * as React from "react";
+import { AllocationRef } from "./AllocationRef";
+import { ApprovalGroupHeader } from "./ApprovalGroupHeader";
 import { clusterAccountStatus } from "./ClusterAccountBadge";
 
-function formatDate(iso?: string | null): string {
+function formatDate(iso: string | undefined, withYear = true): 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" });
+  return date.toLocaleDateString(undefined, {
+    ...(withYear ? { year: "numeric" } : {}),
+    month: "short",
+    day: "numeric",
+  });
 }
 
-export type ClusterAccountsTableProps = {
-  rows: ClusterAccountResponse[];
-  isLoading: boolean;
-  error: Error | null;
-  onRetry?: () => void;
+type ColumnOptions = {
+  grouped: boolean;
+  piId?: string | undefined;
   canReview: boolean;
   onApprove: (row: ClusterAccountResponse) => void;
   onDeny: (row: ClusterAccountResponse) => 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<ClusterAccountResponse>> = [
+function buildColumns({ grouped, piId, canReview, onApprove, onDeny }: ColumnOptions) {
+  const columns: Array<DataTableColumn<ClusterAccountResponse> | false> = [
     {
       key: "user",
       header: "User",
       cell: (row) => (
         <div>
-          <div className="font-medium text-foreground">{row.display_name || row.email}</div>
+          <div className="flex items-center gap-2 font-medium text-foreground">
+            {row.display_name || row.email}
+            {piId && row.user_id === piId ? (
+              <Badge
+                variant="outline"
+                className="h-4 px-1.5 py-0 text-[10px] text-muted-foreground"
+              >
+                PI
+              </Badge>
+            ) : null}
+          </div>
           <div className="text-xs text-muted-foreground">{row.email}</div>
         </div>
       ),
     },
-    {
+    !grouped && {
+      key: "allocation",
+      header: "Allocation",
+      cell: (row) => <AllocationRef account={row} withTitle />,
+    },
+    !grouped && {
       key: "cluster",
       header: "Cluster",
-      cell: (row) => <span className="font-mono text-xs">{row.cluster_name}</span>,
+      cell: (row) => (
+        <span className="whitespace-nowrap 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: "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,
+      ...(grouped ? { width: "14rem" } : {}),
+      cell: (row) => (
+        <span className="whitespace-nowrap font-mono text-xs">{row.local_username}</span>
+      ),
     },
+    grouped
+      ? {
+          key: "requested",
+          header: "Waiting since",
+          width: "9rem",
+          cell: (row) => (
+            <span className="text-sm text-muted-foreground">
+              {formatDate(row.requested_at, false)}
+            </span>
+          ),
+        }
+      : {
+          key: "reviewed",
+          header: "Reviewed",
+          cell: (row) =>
+            row.reviewed_at ? (
+              <div className="text-xs text-muted-foreground">
+                <div className="whitespace-nowrap">{formatDate(row.reviewed_at)}</div>
+                {row.reviewer_name ? <div>by {row.reviewer_name}</div> : null}
+                {row.review_note ? <div className="max-w-[28ch]">{row.review_note}</div> : null}
+              </div>
+            ) : null,
+        },
     {
       key: "approval",
       header: "Approval",
       align: "right",
+      ...(grouped ? { width: "10rem" } : {}),
       interactive: true,
       cell: (row) => {
         if (canReview && row.approval_status === "PENDING") {
@@ -105,7 +127,7 @@ export function ClusterAccountsTable({
               <Button size="xs" variant="success" onClick={() => onApprove(row)}>
                 Approve
               </Button>
-              <Button size="xs" variant="outline" onClick={() => onDeny(row)}>
+              <Button size="xs" variant="destructive" onClick={() => onDeny(row)}>
                 Deny
               </Button>
             </div>
@@ -116,17 +138,110 @@ export function ClusterAccountsTable({
       },
     },
   ];
+  return columns.filter((c) => c !== false);
+}
+
+type GroupProps = Omit<ColumnOptions, "grouped" | "piId"> & {
+  rows: ClusterAccountResponse[];
+  onApproveAll: (rows: ClusterAccountResponse[]) => void;
+};
+
+function ApprovalGroup({ rows, onApproveAll, ...options }: GroupProps) {
+  const labelId = React.useId();
+  const head = rows[0];
+  const { data: project } = useProject(head?.project_id);
+  const piId = project?.project_pi_id;
+  const isPi = (row: ClusterAccountResponse) => Number(row.user_id === piId);
+  const sorted = [...rows].sort((a, b) => isPi(b) - isPi(a));
+  const canApproveAll = options.canReview && Boolean(head?.allocation_id) && rows.length >= 2;
+  return (
+    <section
+      aria-labelledby={labelId}
+      className="overflow-hidden rounded-lg border border-foreground/20 bg-card"
+    >
+      <div className="border-b border-border bg-muted px-4 py-2.5">
+        <ApprovalGroupHeader
+          labelId={labelId}
+          allocationId={head?.allocation_id}
+          allocationName={head?.allocation_name}
+          project={project}
+          cluster={head?.cluster_name ?? ""}
+          count={rows.length}
+          onApproveAll={canApproveAll ? () => onApproveAll(rows) : undefined}
+        />
+      </div>
+      <DataTable
+        className="rounded-none border-0 [&_th]:py-1.5 [&_thead]:bg-muted/40 [&_td:first-child]:pl-11 [&_th:first-child]:pl-11 [&_td]:align-middle"
+        columns={buildColumns({ ...options, grouped: true, piId })}
+        rows={sorted}
+        rowKey={(row) => row.id}
+      />
+    </section>
+  );
+}
+
+export type ClusterAccountsTableProps = {
+  rows: ClusterAccountResponse[];
+  isLoading: boolean;
+  error: Error | null;
+  onRetry?: () => void;
+  canReview: boolean;
+  onApprove: (row: ClusterAccountResponse) => void;
+  onDeny: (row: ClusterAccountResponse) => void;
+  page: number;
+  pageSize: number;
+  total: number;
+  onPageChange: (page: number) => void;
+  emptyHeading: string;
+  grouped?: boolean;
+  onApproveAll: (rows: ClusterAccountResponse[]) => void;
+};
+
+export function ClusterAccountsTable({
+  rows,
+  isLoading,
+  error,
+  onRetry,
+  canReview,
+  onApprove,
+  onDeny,
+  page,
+  pageSize,
+  total,
+  onPageChange,
+  emptyHeading,
+  grouped = false,
+  onApproveAll,
+}: ClusterAccountsTableProps) {
+  const options = { canReview, onApprove, onDeny };
+  const columns = buildColumns({ ...options, grouped });
 
   if (isLoading) return <TableSkeleton rows={6} columns={columns.length} />;
   if (error) return <ErrorState message={error.message} onRetry={onRetry} />;
 
+  if (!grouped || rows.length === 0) {
+    return (
+      <DataTable
+        columns={columns}
+        rows={rows}
+        rowKey={(row) => row.id}
+        empty={emptyHeading}
+        {...(grouped ? {} : { pagination: { page, pageSize, total, onPageChange } })}
+      />
+    );
+  }
+
+  const groups = [...Map.groupBy(rows, (row) => row.allocation_id)];
   return (
-    <DataTable
-      columns={columns}
-      rows={rows}
-      rowKey={(row) => row.id}
-      empty={emptyHeading}
-      pagination={{ page, pageSize, total, onPageChange }}
-    />
+    <div className="space-y-3">
+      {groups.map(([allocationId, groupRows]) => (
+        <ApprovalGroup
+          key={allocationId ?? "none"}
+          rows={groupRows}
+          onApproveAll={onApproveAll}
+          {...options}
+        />
+      ))}
+    </div>
   );
 }
diff --git a/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
index ac4ccf510..0c6e940d6 100644
--- a/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
+++ b/web/src/features/core/cluster-accounts/components/ReviewClusterAccountDialog.tsx
@@ -17,6 +17,7 @@
 
 "use client";
 
+import { useProject } from "@/features/core/projects/queries";
 import type { ClusterAccountResponse } from "@/generated/core/zod.gen";
 import { Button } from "@/shared/ui/button";
 import {
@@ -29,16 +30,19 @@ import {
 } from "@/shared/ui/dialog";
 import { Label } from "@/shared/ui/label";
 import * as React from "react";
+import { AllocationRef } from "./AllocationRef";
 
 export type ReviewAction = "approve" | "deny";
 
 export type ReviewClusterAccountDialogProps = {
-  account: ClusterAccountResponse | null;
+  accounts: ClusterAccountResponse[];
   action: ReviewAction;
+  bulk?: boolean;
   onOpenChange: (open: boolean) => void;
   onConfirm: (note: string) => void;
   isPending: boolean;
   error?: string | null;
+  failures?: Record<string, string>;
 };
 
 const copy: Record<ReviewAction, { title: string; description: string; button: string }> = {
@@ -56,30 +60,80 @@ const copy: Record<ReviewAction, { title: string; description: string; button: s
   },
 };
 
+const DETAILS =
+  "grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 rounded-lg bg-muted px-3 py-2 text-sm";
+const NOTICE =
+  "block rounded-lg bg-[color:var(--tone-warn-bg)] px-3 py-2 text-sm text-[color:var(--tone-warn-fg)]";
+
 export function ReviewClusterAccountDialog({
-  account,
+  accounts,
   action,
+  bulk = false,
   onOpenChange,
   onConfirm,
   isPending,
   error,
+  failures = {},
 }: ReviewClusterAccountDialogProps) {
   const [note, setNote] = React.useState("");
-  const open = account !== null;
+  const account = accounts[0];
+  const open = account !== undefined;
+  const { data: project } = useProject(account?.project_id);
 
   React.useEffect(() => {
     if (!open) setNote("");
   }, [open]);
 
+  const n = accounts.length;
+  const title = bulk ? `Approve ${n} cluster account${n === 1 ? "" : "s"}?` : copy[action].title;
+  const button = bulk ? `Approve ${n}` : copy[action].button;
+
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
       <DialogContent>
         <DialogHeader>
-          <DialogTitle>{copy[action].title}</DialogTitle>
-          <DialogDescription>{copy[action].description}</DialogDescription>
+          <DialogTitle>{title}</DialogTitle>
+          <DialogDescription>
+            {bulk && account
+              ? `Accounts are created on ${account.cluster_name} for everyone below in ${account.allocation_name}. Your name is recorded as the approver for each one.`
+              : 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">
+        {account && bulk ? (
+          <>
+            <dl className={DETAILS}>
+              <dt className="text-muted-foreground">Allocation</dt>
+              <dd>
+                <AllocationRef account={account} />
+              </dd>
+              <dt className="text-muted-foreground">Project</dt>
+              <dd>{project?.title}</dd>
+              <dt className="text-muted-foreground">PI</dt>
+              <dd>{project ? (project.project_pi_display_name ?? project.project_pi_id) : null}</dd>
+              <dt className="text-muted-foreground">Cluster</dt>
+              <dd className="font-mono text-xs">{account.cluster_name}</dd>
+            </dl>
+            <ul
+              aria-label="Accounts to approve"
+              className="max-h-80 divide-y divide-border overflow-y-auto rounded-lg border border-border text-sm"
+            >
+              {accounts.map((a) => (
+                <li key={a.id} className="px-3 py-2">
+                  <div className="flex items-center justify-between gap-3">
+                    <span>{a.display_name || a.email}</span>
+                    <span className="font-mono text-xs text-muted-foreground">
+                      {a.local_username}
+                    </span>
+                  </div>
+                  {failures[a.id] ? (
+                    <p className="text-xs text-[color:var(--tone-error-fg)]">{failures[a.id]}</p>
+                  ) : null}
+                </li>
+              ))}
+            </ul>
+          </>
+        ) : account ? (
+          <dl className={DETAILS}>
             <dt className="text-muted-foreground">User</dt>
             <dd>
               {account.display_name || account.email}
@@ -87,6 +141,14 @@ export function ReviewClusterAccountDialog({
                 <span className="text-muted-foreground"> ({account.email})</span>
               ) : null}
             </dd>
+            {account.allocation_id ? (
+              <>
+                <dt className="text-muted-foreground">Allocation</dt>
+                <dd>
+                  <AllocationRef account={account} />
+                </dd>
+              </>
+            ) : null}
             <dt className="text-muted-foreground">Cluster</dt>
             <dd className="font-mono text-xs">{account.cluster_name}</dd>
             <dt className="text-muted-foreground">Username</dt>
@@ -94,19 +156,25 @@ export function ReviewClusterAccountDialog({
           </dl>
         ) : null}
         {action === "deny" ? (
-          <div className="space-y-2">
-            <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)}
-            />
-          </div>
-        ) : (
-          <output className="block rounded-lg bg-[color:var(--tone-warn-bg)] px-3 py-2 text-sm text-[color:var(--tone-warn-fg)]">
+          <>
+            <p className={NOTICE}>
+              If ACCESS is waiting on this person, it is told no for every request, not only this
+              allocation. Approving later still creates the account, but ACCESS is not told again.
+            </p>
+            <div className="space-y-2">
+              <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)}
+              />
+            </div>
+          </>
+        ) : bulk ? null : (
+          <output className={NOTICE}>
             Your name is recorded as the approver in the audit log.
           </output>
         )}
@@ -121,7 +189,7 @@ export function ReviewClusterAccountDialog({
             disabled={isPending || (action === "deny" && note.trim() === "")}
             onClick={() => onConfirm(note.trim())}
           >
-            {isPending ? `${copy[action].button}…` : copy[action].button}
+            {isPending ? `${button}…` : button}
           </Button>
         </DialogFooter>
       </DialogContent>
diff --git a/web/src/features/core/cluster-accounts/queries.ts b/web/src/features/core/cluster-accounts/queries.ts
index b821540a8..6e6d91b00 100644
--- a/web/src/features/core/cluster-accounts/queries.ts
+++ b/web/src/features/core/cluster-accounts/queries.ts
@@ -73,6 +73,8 @@ export function useUserClusterAccounts(users: User[], enabled: boolean) {
             cluster_name: names.get(a.compute_cluster_id) ?? a.compute_cluster_id,
             display_name: [user.first_name, user.last_name].filter(Boolean).join(" "),
             email: user.email,
+            // The per-user route has no request date.
+            requested_at: "",
           })),
         ]),
       ),
@@ -88,6 +90,15 @@ export function useApproveClusterAccount() {
   });
 }
 
+// One call per account so a failure does not stop the rest.
+export function useApproveClusterAccounts() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: (ids: string[]) => Promise.allSettled(ids.map((id) => approveClusterAccount(id))),
+    onSettled: () => client.invalidateQueries({ queryKey: clusterAccountKeys.all }),
+  });
+}
+
 export function useDenyClusterAccount() {
   const client = useQueryClient();
   return useMutation({
diff --git a/web/src/features/core/projects/__fixtures__/projects.json b/web/src/features/core/projects/__fixtures__/projects.json
index aa0d973c5..728ff1737 100644
--- a/web/src/features/core/projects/__fixtures__/projects.json
+++ b/web/src/features/core/projects/__fixtures__/projects.json
@@ -6,6 +6,7 @@
     "origination": "ACCESS",
     "ref": "ACCESS-BIO130000",
     "project_pi_id": "user-pi-001",
+    "project_pi_display_name": "Jane Doe",
     "status": "ACTIVE",
     "created_time": "2026-04-01T00:00:00.000Z"
   },
@@ -16,6 +17,7 @@
     "origination": "ACCESS",
     "ref": "ACCESS-PHY220001",
     "project_pi_id": "user-pi-002",
+    "project_pi_display_name": "John Doe",
     "status": "ACTIVE",
     "created_time": "2026-04-12T00:00:00.000Z"
   },
diff --git a/web/src/mocks/handlers/clusters.ts b/web/src/mocks/handlers/clusters.ts
index 62115d753..e9052d928 100644
--- a/web/src/mocks/handlers/clusters.ts
+++ b/web/src/mocks/handlers/clusters.ts
@@ -18,6 +18,7 @@
 import clusterAccountsFixture from "@/features/core/cluster-accounts/__fixtures__/cluster-accounts.json";
 import clusterUsersFixture from "@/features/core/clusters/__fixtures__/cluster-users.json";
 import clustersFixture from "@/features/core/clusters/__fixtures__/clusters.json";
+import projects from "@/features/core/projects/__fixtures__/projects.json";
 import {
   zClusterAccountResponse,
   zComputeCluster,
@@ -37,9 +38,13 @@ export const clustersHandlers = [
   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;
+    const q = url.searchParams.get("q")?.toLowerCase() ?? "";
+    const rows = clusterAccounts.filter((a) => {
+      if (status && a.approval_status !== status) return false;
+      const title = projects.find((p) => p.id === a.project_id)?.title ?? "";
+      const hay = [a.display_name, a.email, a.local_username, a.allocation_name, title];
+      return hay.join(" ").toLowerCase().includes(q);
+    });
     return HttpResponse.json({ items: rows, total: rows.length });
   }),
 
@@ -52,6 +57,7 @@ export const clustersHandlers = [
     found.approval_status = "APPROVED";
     found.reviewed_at = new Date().toISOString();
     found.reviewed_by = "user-admin";
+    found.reviewer_name = "Pat Admin";
     found.review_note = undefined;
     return HttpResponse.json(found);
   }),
@@ -71,6 +77,7 @@ export const clustersHandlers = [
       found.approval_status = "DENIED";
       found.reviewed_at = new Date().toISOString();
       found.reviewed_by = "user-admin";
+      found.reviewer_name = "Pat Admin";
       found.review_note = body.note.trim();
       return HttpResponse.json(found);
     },