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