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

(airavata-custos) branch web-changes updated (28da9d67d -> 89cecac75)

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

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


    omit 28da9d67d Add user profile settings page
    omit effd100b2 Add allocation history tab and fix sign-out for CILogon
    omit 73ce24a4f Restrict the member role change in the allocations and projects
    omit ac6632a15 Show allocation and usage aggregates on the resources tab
    omit 451b55eb8 Regenerate portal SDK for the resource summary endpoint
    omit 0d0a2a651 Sample data for projects, clusters, and resources
    omit 4a84a6010 Clusters, resources, and resource rate management UI components
    omit c90aafcf3 Add organizations and resources admin pages to the portal
    omit e671b2d89 Add endpoint to update a user's name
    omit bf9d788bd Add resource summary endpoint and return empty lists as JSON arrays
    omit ce9eb17f4  Add list endpoints for users and organizations
    omit 9bb720266 make middle_name column non-nullable and default to empty string
    omit 417825a57 Introduce privilege keys for all resources and enforce them on every route
     add 7ca01e8bd Introduce privilege keys for all resources and enforce them on every route (#514)
     add ea9dca7e8  Add list endpoints for users and organizations
     add d0496b0d8 Add resource summary endpoint and return empty lists as JSON arrays
     add 0e4fd7868 Add endpoint to update a user's name
     new 7f5d8b792 Add organizations and resources admin pages to the portal
     new 918722dbc Clusters, resources, and resource rate management UI components
     new 80c30c745 Sample data for projects, clusters, and resources
     new 1d87a84ac Regenerate portal SDK for the resource summary endpoint
     new 40e1eda9d Show allocation and usage aggregates on the resources tab
     new 6eca06864 Restrict the member role change in the allocations and projects
     new e54fdb27d Add allocation history tab and fix sign-out for CILogon
     new 89cecac75 Add user profile settings page

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (28da9d67d)
            \
             N -- N -- N   refs/heads/web-changes (89cecac75)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

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


Summary of changes:


(airavata-custos) 07/08: Add allocation history tab and fix sign-out for CILogon

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

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

commit e54fdb27d8f09a651675b26d8058a66081000d2c
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 16:36:23 2026 -0400

    Add allocation history tab and fix sign-out for CILogon
---
 .../api/auth/end-session/__tests__/route.test.ts   |  82 ++++++++++++++++
 web/src/app/api/auth/end-session/route.ts          |  36 +++++--
 .../core/allocations/__fixtures__/diffs.json       |  59 ++++++++++++
 .../core/allocations/__fixtures__/usage.json       |  34 +++++++
 .../__tests__/AllocationHistoryTab.test.tsx        |  93 ++++++++++++++++++
 .../__tests__/AllocationMembersTab.test.tsx        |  80 +++++++++++++++
 .../__tests__/AllocationOverviewTab.test.tsx       |  93 ++++++++++++++++++
 .../__tests__/AllocationUsageTab.test.tsx          |  94 ++++++++++++++++++
 .../components/AllocationHistoryTab.tsx            | 107 +++++++++++++++++++++
 .../projects/__tests__/ProjectMembersTab.test.tsx  |  69 +++++++++++++
 10 files changed, 738 insertions(+), 9 deletions(-)

diff --git a/web/src/app/api/auth/end-session/__tests__/route.test.ts b/web/src/app/api/auth/end-session/__tests__/route.test.ts
new file mode 100644
index 000000000..b8da9d4a5
--- /dev/null
+++ b/web/src/app/api/auth/end-session/__tests__/route.test.ts
@@ -0,0 +1,82 @@
+// 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 { NextRequest } from "next/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/shared/auth/auth", () => ({
+  auth: vi.fn(async () => ({ idToken: "id-token-abc" })),
+}));
+
+vi.mock("@/lib/env", () => ({
+  serverEnv: {
+    OIDC_ISSUER_URL: "https://idp.example.org",
+    OIDC_CLIENT_ID: "client-123",
+  },
+}));
+
+import { GET } from "../route";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function discoveryResponse(body: unknown, ok = true): Response {
+  return new Response(JSON.stringify(body), { status: ok ? 200 : 500 });
+}
+
+function request() {
+  return new NextRequest("https://portal.example.org/api/auth/end-session?callbackUrl=/sign-in");
+}
+
+afterEach(() => {
+  fetchMock.mockReset();
+});
+
+describe("end-session route", () => {
+  it("round-trips through the IdP when discovery advertises end_session_endpoint", async () => {
+    fetchMock.mockResolvedValueOnce(
+      discoveryResponse({ end_session_endpoint: "https://idp.example.org/logout" }),
+    );
+    const res = await GET(request());
+    const location = new URL(res.headers.get("location") as string);
+    expect(location.origin + location.pathname).toBe("https://idp.example.org/logout");
+    expect(location.searchParams.get("id_token_hint")).toBe("id-token-abc");
+    expect(location.searchParams.get("post_logout_redirect_uri")).toBe(
+      "https://portal.example.org/sign-in",
+    );
+  });
+
+  it("redirects straight to the callback when the issuer has no end_session_endpoint", async () => {
+    fetchMock.mockResolvedValueOnce(discoveryResponse({}));
+    const res = await GET(request());
+    expect(res.headers.get("location")).toBe("https://portal.example.org/sign-in");
+  });
+
+  it("redirects locally when discovery fails", async () => {
+    fetchMock.mockRejectedValueOnce(new Error("network"));
+    const res = await GET(request());
+    expect(res.headers.get("location")).toBe("https://portal.example.org/sign-in");
+  });
+
+  it("clears the session cookie regardless of the redirect target", async () => {
+    fetchMock.mockResolvedValueOnce(discoveryResponse({}));
+    const res = await GET(request());
+    const cookie = res.cookies.get("custos.session-token");
+    expect(cookie?.value).toBe("");
+    expect(cookie?.maxAge).toBe(0);
+  });
+});
diff --git a/web/src/app/api/auth/end-session/route.ts b/web/src/app/api/auth/end-session/route.ts
index ff8451b70..4b01045d6 100644
--- a/web/src/app/api/auth/end-session/route.ts
+++ b/web/src/app/api/auth/end-session/route.ts
@@ -34,6 +34,20 @@ function clearAuthCookies(res: NextResponse) {
   }
 }
 
+// Discover the end-session endpoint; some issuers (CILogon) don't have one.
+async function discoverEndSessionEndpoint(issuer: string): Promise<string | null> {
+  try {
+    const res = await fetch(`${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, {
+      cache: "no-store",
+    });
+    if (!res.ok) return null;
+    const doc = (await res.json()) as { end_session_endpoint?: unknown };
+    return typeof doc.end_session_endpoint === "string" ? doc.end_session_endpoint : null;
+  } catch {
+    return null;
+  }
+}
+
 export async function GET(request: NextRequest) {
   const callbackUrl = request.nextUrl.searchParams.get("callbackUrl") ?? "/sign-in";
   const origin = request.nextUrl.origin;
@@ -41,17 +55,21 @@ export async function GET(request: NextRequest) {
 
   const session = await auth();
   const idToken = session?.idToken ?? null;
-  const endSession = new URL(
-    `${serverEnv.OIDC_ISSUER_URL.replace(/\/$/, "")}/protocol/openid-connect/logout`,
-  );
-  endSession.searchParams.set("post_logout_redirect_uri", postLogout);
-  if (idToken) {
-    endSession.searchParams.set("id_token_hint", idToken);
-  } else {
-    endSession.searchParams.set("client_id", serverEnv.OIDC_CLIENT_ID);
+  const endpoint = await discoverEndSessionEndpoint(serverEnv.OIDC_ISSUER_URL);
+
+  let redirectTo = postLogout;
+  if (endpoint) {
+    const url = new URL(endpoint);
+    url.searchParams.set("post_logout_redirect_uri", postLogout);
+    if (idToken) {
+      url.searchParams.set("id_token_hint", idToken);
+    } else {
+      url.searchParams.set("client_id", serverEnv.OIDC_CLIENT_ID);
+    }
+    redirectTo = url.toString();
   }
 
-  const res = NextResponse.redirect(endSession.toString());
+  const res = NextResponse.redirect(redirectTo);
   clearAuthCookies(res);
   return res;
 }
diff --git a/web/src/features/core/allocations/__fixtures__/diffs.json b/web/src/features/core/allocations/__fixtures__/diffs.json
new file mode 100644
index 000000000..566f5e2da
--- /dev/null
+++ b/web/src/features/core/allocations/__fixtures__/diffs.json
@@ -0,0 +1,59 @@
+{
+  "alloc-001": [
+    {
+      "id": "diff-001",
+      "compute_allocation_id": "alloc-001",
+      "diff_type": "ALLOCATION_STATUS_CHANGE",
+      "new_su_amount": 250000,
+      "status": "ACTIVE",
+      "timestamp": "2026-04-01T00:00:00.000Z",
+      "description": "Allocation activated for the funding period."
+    },
+    {
+      "id": "diff-002",
+      "compute_allocation_id": "alloc-001",
+      "diff_type": "USAGE_UPDATE",
+      "new_su_amount": 250000,
+      "status": "ACTIVE",
+      "timestamp": "2026-05-15T08:30:00.000Z",
+      "description": "Recorded usage from a batch of GPU jobs."
+    },
+    {
+      "id": "diff-003",
+      "compute_allocation_id": "alloc-001",
+      "diff_type": "USAGE_UPDATE",
+      "new_su_amount": 250000,
+      "status": "ACTIVE",
+      "timestamp": "2026-06-20T14:05:00.000Z"
+    }
+  ],
+  "alloc-003": [
+    {
+      "id": "diff-101",
+      "compute_allocation_id": "alloc-003",
+      "diff_type": "ALLOCATION_STATUS_CHANGE",
+      "new_su_amount": 150000,
+      "status": "ACTIVE",
+      "timestamp": "2026-04-12T00:00:00.000Z",
+      "description": "Allocation activated."
+    },
+    {
+      "id": "diff-102",
+      "compute_allocation_id": "alloc-003",
+      "diff_type": "USAGE_UPDATE",
+      "new_su_amount": 350000,
+      "status": "ACTIVE",
+      "timestamp": "2026-05-20T09:15:00.000Z",
+      "description": "SU amount raised after an approved change request."
+    },
+    {
+      "id": "diff-103",
+      "compute_allocation_id": "alloc-003",
+      "diff_type": "ALLOCATION_STATUS_CHANGE",
+      "new_su_amount": 350000,
+      "status": "INACTIVE",
+      "timestamp": "2026-06-25T16:40:00.000Z",
+      "description": "Paused pending end-of-quarter review."
+    }
+  ]
+}
diff --git a/web/src/features/core/allocations/__fixtures__/usage.json b/web/src/features/core/allocations/__fixtures__/usage.json
new file mode 100644
index 000000000..0a6102a4a
--- /dev/null
+++ b/web/src/features/core/allocations/__fixtures__/usage.json
@@ -0,0 +1,34 @@
+{
+  "alloc-003": [
+    {
+      "id": "use-001",
+      "compute_allocation_id": "alloc-003",
+      "used_raw_amount": 1200,
+      "used_su_amount": 60000,
+      "last_updated": "2026-05-18T11:00:00.000Z",
+      "user_id": "user-pi-002",
+      "job_id": "job-5501",
+      "compute_allocation_resource_id": "res-a-gpu"
+    },
+    {
+      "id": "use-002",
+      "compute_allocation_id": "alloc-003",
+      "used_raw_amount": 800,
+      "used_su_amount": 20000,
+      "last_updated": "2026-05-22T09:30:00.000Z",
+      "user_id": "user-pi-002",
+      "job_id": "job-5502",
+      "compute_allocation_resource_id": "res-a-gpu"
+    },
+    {
+      "id": "use-003",
+      "compute_allocation_id": "alloc-003",
+      "used_raw_amount": 4000,
+      "used_su_amount": 24000,
+      "last_updated": "2026-06-01T15:45:00.000Z",
+      "user_id": "user-collab-009",
+      "job_id": "job-5510",
+      "compute_allocation_resource_id": "res-c-cpu"
+    }
+  ]
+}
diff --git a/web/src/features/core/allocations/__tests__/AllocationHistoryTab.test.tsx b/web/src/features/core/allocations/__tests__/AllocationHistoryTab.test.tsx
new file mode 100644
index 000000000..76e3fcdb5
--- /dev/null
+++ b/web/src/features/core/allocations/__tests__/AllocationHistoryTab.test.tsx
@@ -0,0 +1,93 @@
+// 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 { render, screen, within } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AllocationDiff, ComputeAllocation } from "../schemas";
+
+let diffs: AllocationDiff[] = [];
+
+vi.mock("../queries", () => ({
+  useAllocationDiffs: () => ({ data: diffs, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+import { AllocationHistoryTab } from "../components/AllocationHistoryTab";
+
+const allocation: ComputeAllocation = {
+  id: "alloc-001",
+  project_id: "project-001",
+  name: "Genomic GPU Pool",
+  status: "ACTIVE",
+  compute_cluster_id: "cluster-a",
+  initial_su_amount: 250000,
+  start_time: "2026-04-01T00:00:00.000Z",
+  end_time: "2027-03-31T00:00:00.000Z",
+};
+
+beforeEach(() => {
+  diffs = [
+    {
+      id: "diff-a",
+      compute_allocation_id: "alloc-001",
+      diff_type: "ALLOCATION_STATUS_CHANGE",
+      new_su_amount: 250000,
+      status: "ACTIVE",
+      timestamp: "2026-04-01T00:00:00.000Z",
+      description: "Activated for the funding period.",
+    },
+    {
+      id: "diff-b",
+      compute_allocation_id: "alloc-001",
+      diff_type: "USAGE_UPDATE",
+      new_su_amount: 250000,
+      status: "ACTIVE",
+      timestamp: "2026-06-20T14:05:00.000Z",
+    },
+    {
+      id: "diff-c",
+      compute_allocation_id: "alloc-001",
+      diff_type: "RESOURCE_ATTACHED",
+      status: "INACTIVE",
+      timestamp: "2026-05-15T08:30:00.000Z",
+    },
+  ];
+});
+
+describe("<AllocationHistoryTab />", () => {
+  it("lists diffs newest first", () => {
+    render(<AllocationHistoryTab allocation={allocation} />);
+    const rows = screen.getAllByRole("row").slice(1); // drop the header row
+    expect(within(rows[0] as HTMLElement).getByText("USAGE_UPDATE")).toBeInTheDocument();
+    expect(within(rows[1] as HTMLElement).getByText("RESOURCE_ATTACHED")).toBeInTheDocument();
+    expect(within(rows[2] as HTMLElement).getByText("ALLOCATION_STATUS_CHANGE")).toBeInTheDocument();
+  });
+
+  it("renders the status badge and a placeholder for a missing SU amount", () => {
+    render(<AllocationHistoryTab allocation={allocation} />);
+    expect(screen.getByText("INACTIVE")).toBeInTheDocument();
+    const resourceRow = screen.getByText("RESOURCE_ATTACHED").closest("tr");
+    expect(within(resourceRow as HTMLElement).getByText("-")).toBeInTheDocument();
+  });
+
+  it("shows the empty state when there is no history", () => {
+    diffs = [];
+    render(<AllocationHistoryTab allocation={allocation} />);
+    expect(
+      screen.getByRole("heading", { name: /no history recorded for this allocation/i }),
+    ).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/allocations/__tests__/AllocationMembersTab.test.tsx b/web/src/features/core/allocations/__tests__/AllocationMembersTab.test.tsx
new file mode 100644
index 000000000..c9b8eff55
--- /dev/null
+++ b/web/src/features/core/allocations/__tests__/AllocationMembersTab.test.tsx
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { ComputeAllocation } from "../schemas";
+
+const members = [
+  {
+    id: "m-1",
+    compute_allocation_id: "alloc-001",
+    user_id: "u-1",
+    display_name: "Paula Ivey",
+    email: "paula@sample.example.edu",
+    role: "PI",
+    membership_status: "ACTIVE",
+  },
+  {
+    id: "m-2",
+    compute_allocation_id: "alloc-001",
+    user_id: "u-2",
+    display_name: "Carl Osei",
+    email: "carl@sample.example.edu",
+    role: "CO_PI",
+    membership_status: "ACTIVE",
+  },
+  {
+    id: "m-3",
+    compute_allocation_id: "alloc-001",
+    user_id: "u-3",
+    display_name: "Mina Frey",
+    email: "mina@sample.example.edu",
+    role: "MEMBER",
+    membership_status: "ACTIVE",
+  },
+];
+
+const mutation = { mutate: vi.fn(), isPending: false };
+vi.mock("../queries", () => ({
+  useAllocationMembers: () => ({ data: members, isLoading: false, error: null, refetch: vi.fn() }),
+  useAddMember: () => mutation,
+  useUpdateMember: () => mutation,
+  useRemoveMember: () => mutation,
+}));
+
+import { AllocationMembersTab } from "../components/AllocationMembersTab";
+
+const allocation = { id: "alloc-001", name: "Test Allocation" } as ComputeAllocation;
+
+describe("<AllocationMembersTab />", () => {
+  it("offers no Edit or Remove for PI or Co-PI members", () => {
+    render(<AllocationMembersTab allocation={allocation} canManage={true} />);
+    for (const name of ["Paula Ivey", "Carl Osei"]) {
+      expect(screen.queryByRole("button", { name: `Edit ${name}` })).not.toBeInTheDocument();
+      expect(screen.queryByRole("button", { name: `Remove ${name}` })).not.toBeInTheDocument();
+    }
+    expect(screen.getByRole("button", { name: "Edit Mina Frey" })).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Remove Mina Frey" })).toBeInTheDocument();
+  });
+
+  it("offers no actions without manage permission", () => {
+    render(<AllocationMembersTab allocation={allocation} canManage={false} />);
+    expect(screen.queryByRole("button", { name: /^Remove / })).not.toBeInTheDocument();
+    expect(screen.queryByRole("button", { name: /^Edit / })).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/allocations/__tests__/AllocationOverviewTab.test.tsx b/web/src/features/core/allocations/__tests__/AllocationOverviewTab.test.tsx
new file mode 100644
index 000000000..3113d278f
--- /dev/null
+++ b/web/src/features/core/allocations/__tests__/AllocationOverviewTab.test.tsx
@@ -0,0 +1,93 @@
+// 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AttachedResource, ComputeAllocation } from "../schemas";
+
+let attached: AttachedResource[] = [];
+
+vi.mock("../queries", () => ({
+  useAllocationResources: () => ({
+    data: attached,
+    isLoading: false,
+    error: null,
+    refetch: vi.fn(),
+  }),
+}));
+
+vi.mock("@/features/core/resources/queries", () => ({
+  // res-a-gpu has an effective rate; res-b-gpu 404s (query error, no data).
+  useEffectiveRate: (id: string) =>
+    id === "res-a-gpu"
+      ? { data: { rate: 0.5 }, isError: false }
+      : { data: undefined, isError: true },
+}));
+
+import { AllocationOverviewTab } from "../components/AllocationOverviewTab";
+
+const allocation: ComputeAllocation = {
+  id: "alloc-001",
+  project_id: "project-001",
+  name: "Genomic GPU Pool",
+  status: "ACTIVE",
+  compute_cluster_id: "cluster-a",
+  initial_su_amount: 250000,
+  start_time: "2026-04-01T00:00:00.000Z",
+  end_time: "2027-03-31T00:00:00.000Z",
+};
+
+beforeEach(() => {
+  attached = [
+    {
+      id: "res-a-gpu",
+      name: "ClusterA GPU",
+      resource_type: "GPU",
+      resource_amount: 50000,
+      compute_cluster_id: "cluster-a",
+    },
+    {
+      id: "res-b-gpu",
+      name: "ClusterB GPU A100",
+      resource_type: "GPU",
+      resource_amount: 16000,
+      compute_cluster_id: "cluster-b",
+    },
+  ];
+});
+
+describe("<AllocationOverviewTab />", () => {
+  it("renders attached resource names, types, and amounts", () => {
+    render(<AllocationOverviewTab allocation={allocation} />);
+    expect(screen.getByText("ClusterA GPU")).toBeInTheDocument();
+    expect(screen.getByText("ClusterB GPU A100")).toBeInTheDocument();
+    expect(screen.getByText("50,000")).toBeInTheDocument();
+  });
+
+  it("renders the effective rate, omitting it when the lookup 404s", () => {
+    render(<AllocationOverviewTab allocation={allocation} />);
+    expect(screen.getByText("0.5 SU/unit")).toBeInTheDocument();
+    // res-b-gpu has no effective rate, so only one rate line shows.
+    expect(screen.getAllByText(/SU\/unit/)).toHaveLength(1);
+  });
+
+  it("shows the empty state when nothing is attached", () => {
+    attached = [];
+    render(<AllocationOverviewTab allocation={allocation} />);
+    expect(screen.getByText(/no resources attached/i)).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/allocations/__tests__/AllocationUsageTab.test.tsx b/web/src/features/core/allocations/__tests__/AllocationUsageTab.test.tsx
new file mode 100644
index 000000000..11da594e5
--- /dev/null
+++ b/web/src/features/core/allocations/__tests__/AllocationUsageTab.test.tsx
@@ -0,0 +1,94 @@
+// 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { AllocationUsage, ComputeAllocation } from "../schemas";
+
+let usage: AllocationUsage[] = [];
+
+vi.mock("../queries", () => ({
+  useAllocationUsage: () => ({ data: usage, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+vi.mock("@/features/core/resources/queries", () => ({
+  useResourceSummaries: () => ({
+    data: [
+      { id: "res-a-gpu", name: "ClusterA GPU" },
+      { id: "res-c-cpu", name: "ClusterC RM" },
+    ],
+    isLoading: false,
+    error: null,
+    refetch: vi.fn(),
+  }),
+}));
+
+import { AllocationUsageTab } from "../components/AllocationUsageTab";
+
+const allocation: ComputeAllocation = {
+  id: "alloc-003",
+  project_id: "project-002",
+  name: "Plasma GPU",
+  status: "ACTIVE",
+  compute_cluster_id: "cluster-a",
+  initial_su_amount: 250000,
+  start_time: "2026-04-12T00:00:00.000Z",
+  end_time: "2027-04-11T00:00:00.000Z",
+};
+
+function usageRow(overrides: Partial<AllocationUsage>): AllocationUsage {
+  return {
+    id: "use-x",
+    compute_allocation_id: "alloc-003",
+    used_raw_amount: 0,
+    used_su_amount: 0,
+    last_updated: "2026-05-18T11:00:00.000Z",
+    user_id: "user-pi-002",
+    job_id: "job-1",
+    compute_allocation_resource_id: "res-a-gpu",
+    ...overrides,
+  };
+}
+
+beforeEach(() => {
+  usage = [
+    usageRow({ id: "use-001", used_su_amount: 60000, compute_allocation_resource_id: "res-a-gpu" }),
+    usageRow({ id: "use-002", used_su_amount: 20000, compute_allocation_resource_id: "res-a-gpu" }),
+    usageRow({ id: "use-003", used_su_amount: 24000, compute_allocation_resource_id: "res-c-cpu" }),
+  ];
+});
+
+describe("<AllocationUsageTab />", () => {
+  it("renders a per-resource bar with summed SU, one per resource", () => {
+    render(<AllocationUsageTab allocation={allocation} />);
+    expect(screen.getByText("ClusterA GPU")).toBeInTheDocument();
+    expect(screen.getByText("80,000 SU")).toBeInTheDocument();
+    expect(screen.getByText("ClusterC RM")).toBeInTheDocument();
+    expect(screen.getByText("24,000 SU")).toBeInTheDocument();
+  });
+
+  it("keeps the total usage bar", () => {
+    render(<AllocationUsageTab allocation={allocation} />);
+    expect(screen.getByText("104,000 / 250,000 SUs")).toBeInTheDocument();
+  });
+
+  it("shows the empty state when there is no usage", () => {
+    usage = [];
+    render(<AllocationUsageTab allocation={allocation} />);
+    expect(screen.getByRole("heading", { name: /no usage recorded/i })).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/allocations/components/AllocationHistoryTab.tsx b/web/src/features/core/allocations/components/AllocationHistoryTab.tsx
new file mode 100644
index 000000000..f94b17012
--- /dev/null
+++ b/web/src/features/core/allocations/components/AllocationHistoryTab.tsx
@@ -0,0 +1,107 @@
+// 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 { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { TableSkeleton } from "@/shared/ui/Loading";
+import { StatusBadge, statusBadgeVariantFromAllocationStatus } from "@/shared/ui/StatusBadge";
+import { useAllocationDiffs } from "../queries";
+import type { AllocationDiff, ComputeAllocation } from "../schemas";
+
+export type AllocationHistoryTabProps = {
+  allocation: ComputeAllocation;
+};
+
+function formatDateTime(iso: string): string {
+  try {
+    return new Date(iso).toLocaleString(undefined, {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  } catch {
+    return iso;
+  }
+}
+
+export function AllocationHistoryTab({ allocation }: AllocationHistoryTabProps) {
+  const query = useAllocationDiffs(allocation.id);
+
+  if (query.isLoading) return <TableSkeleton rows={3} columns={5} />;
+  if (query.error) {
+    return <ErrorState message={(query.error as Error).message} onRetry={() => query.refetch()} />;
+  }
+  const rows = [...(query.data ?? [])].sort(
+    (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
+  );
+
+  if (rows.length === 0) {
+    return <EmptyState heading="No history recorded for this allocation." />;
+  }
+
+  const columns: Array<DataTableColumn<AllocationDiff>> = [
+    {
+      key: "when",
+      header: "When",
+      cell: (row) => (
+        <span className="text-sm text-muted-foreground">{formatDateTime(row.timestamp)}</span>
+      ),
+    },
+    {
+      key: "type",
+      header: "Type",
+      cell: (row) => <span className="text-sm">{row.diff_type}</span>,
+    },
+    {
+      key: "su",
+      header: "New SU amount",
+      align: "right",
+      cell: (row) => (
+        <span className="tabular-nums">
+          {typeof row.new_su_amount === "number"
+            ? new Intl.NumberFormat().format(row.new_su_amount)
+            : "-"}
+        </span>
+      ),
+    },
+    {
+      key: "status",
+      header: "Status",
+      cell: (row) => (
+        <StatusBadge
+          variant={statusBadgeVariantFromAllocationStatus(row.status)}
+          label={row.status}
+        />
+      ),
+    },
+    {
+      key: "description",
+      header: "Description",
+      cell: (row) =>
+        row.description ? (
+          <span className="text-sm text-muted-foreground">{row.description}</span>
+        ) : null,
+    },
+  ];
+
+  return <DataTable columns={columns} rows={rows} rowKey={(row) => row.id} />;
+}
diff --git a/web/src/features/core/projects/__tests__/ProjectMembersTab.test.tsx b/web/src/features/core/projects/__tests__/ProjectMembersTab.test.tsx
new file mode 100644
index 000000000..39a2a44d6
--- /dev/null
+++ b/web/src/features/core/projects/__tests__/ProjectMembersTab.test.tsx
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+function member(over: Record<string, unknown>) {
+  return {
+    id: "pm-x",
+    project_id: "p-1",
+    user_id: "u-x",
+    email: "x@sample.example.edu",
+    display_name: "Member X",
+    role: "MEMBER",
+    status: "ACTIVE",
+    added_time: "2026-01-01T00:00:00.000Z",
+    allocations: [],
+    ...over,
+  };
+}
+
+const members = [
+  member({ id: "pm-1", display_name: "Ada Pi", role: "PI" }),
+  member({ id: "pm-2", display_name: "Ben Copi", role: "CO_PI" }),
+  member({ id: "pm-3", display_name: "Cira Member", role: "MEMBER" }),
+];
+
+const mutation = { mutate: vi.fn(), isPending: false };
+vi.mock("../queries", () => ({
+  useProjectMembers: () => ({ data: members, isLoading: false, error: null, refetch: vi.fn() }),
+  useUpdateProjectMember: () => mutation,
+  useRemoveProjectMember: () => mutation,
+}));
+
+import { ProjectMembersTab } from "../components/ProjectMembersTab";
+
+describe("<ProjectMembersTab />", () => {
+  it("offers no Edit or Remove for PI or Co-PI members", () => {
+    render(<ProjectMembersTab projectId="p-1" canManage={true} />);
+    for (const name of ["Ada Pi", "Ben Copi"]) {
+      expect(screen.queryByRole("button", { name: `Edit ${name}` })).not.toBeInTheDocument();
+      expect(screen.queryByRole("button", { name: `Remove ${name}` })).not.toBeInTheDocument();
+    }
+    expect(screen.getByRole("button", { name: "Edit Cira Member" })).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "Remove Cira Member" })).toBeInTheDocument();
+  });
+
+  it("limits the edit role picker to Member and Allocation Manager", () => {
+    render(<ProjectMembersTab projectId="p-1" canManage={true} />);
+    fireEvent.click(screen.getByRole("button", { name: "Edit Cira Member" }));
+    const select = screen.getByLabelText("Role");
+    const options = within(select).getAllByRole("option").map((o) => o.textContent);
+    expect(options).toEqual(["Allocation Manager", "Member"]);
+  });
+});


(airavata-custos) 01/08: Add organizations and resources admin pages to the portal

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

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

commit 7f5d8b792bb580b14fc8b993c75e14fa08526fed
Author: lahiruj <la...@gmail.com>
AuthorDate: Tue Jul 7 21:39:19 2026 -0400

    Add organizations and resources admin pages to the portal
---
 .../organizations/OrganizationsListContainer.tsx   |  88 +++++++++++++
 .../(portal)/admin/organizations/page.tsx}         |  24 ++--
 .../admin/resources/ResourcesClustersTabs.tsx      |  47 +++++++
 .../__tests__/ResourcesClustersTabs.test.tsx       |  74 +++++++++++
 .../(portal)/admin/resources/page.tsx}             |  24 ++--
 .../core/clusters/__fixtures__/cluster-users.json  |  38 ++++++
 .../core/clusters/__fixtures__/clusters.json       |   6 +
 .../core/clusters/__tests__/ClustersTab.test.tsx   |  70 +++++++++++
 .../features/core/clusters/__tests__/api.test.ts   |  64 ++++++++++
 .../core/clusters/__tests__/queries.test.ts}       |  27 ++--
 .../core/clusters/__tests__/schemas.test.ts        |  50 ++++++++
 .../handlers.ts => features/core/clusters/api.ts}  |  37 +++---
 .../clusters/components/ClusterUsersDrawer.tsx     |  79 ++++++++++++
 .../core/clusters/components/ClustersTab.tsx       |  94 ++++++++++++++
 web/src/features/core/clusters/queries.ts          |  60 +++++++++
 .../core/clusters/schemas.ts}                      |  29 +++--
 .../organizations/__fixtures__/organizations.json  |  52 ++++++++
 .../__tests__/CreateOrganizationDialog.test.tsx    |  69 +++++++++++
 .../__tests__/OrganizationsList.test.tsx           |  80 ++++++++++++
 .../core/organizations/__tests__/api.test.ts       |  76 ++++++++++++
 .../core/organizations/__tests__/queries.test.ts}  |  27 ++--
 .../core/organizations/__tests__/schemas.test.ts   |  63 ++++++++++
 web/src/features/core/organizations/api.ts         |  46 +++++++
 .../components/CreateOrganizationDialog.tsx        | 111 +++++++++++++++++
 .../organizations/components/OrganizationsList.tsx |  98 +++++++++++++++
 web/src/features/core/organizations/queries.ts     |  55 ++++++++
 web/src/features/core/organizations/schemas.ts     |  41 ++++++
 .../core/organizations/types.ts}                   |  20 +--
 .../core/resources/__fixtures__/rates.json         |  37 ++++++
 .../core/resources/__fixtures__/resources.json     |  51 ++++++++
 .../__tests__/ResourcesRatesDrawer.test.tsx        |  82 ++++++++++++
 .../core/resources/__tests__/ResourcesTab.test.tsx | 101 +++++++++++++++
 .../features/core/resources/__tests__/api.test.ts  |  71 +++++++++++
 .../core/resources/__tests__/queries.test.ts}      |  27 ++--
 .../core/resources/__tests__/schemas.test.ts       |  67 ++++++++++
 web/src/features/core/resources/api.ts             |  41 ++++++
 .../resources/components/ResourcesRatesDrawer.tsx  | 113 +++++++++++++++++
 .../core/resources/components/ResourcesTab.tsx     | 138 +++++++++++++++++++++
 web/src/features/core/resources/queries.ts         |  61 +++++++++
 web/src/features/core/resources/schemas.ts         |  50 ++++++++
 .../mocks/__tests__/fixtures-vs-schemas.test.ts    |  39 ++++++
 web/src/mocks/handlers.ts                          |   6 +
 web/src/mocks/handlers/clusters.ts                 |  45 +++++++
 web/src/mocks/handlers/organizations.ts            |  55 ++++++++
 web/src/mocks/handlers/privileges.ts               |  19 ++-
 web/src/mocks/handlers/resources.ts                |  48 +++++++
 web/src/shared/api/__tests__/client.test.ts        |  28 ++++-
 web/src/shared/api/client.ts                       |  12 +-
 web/src/shared/layout/__tests__/Sidebar.test.tsx   |  61 +++++++++
 web/src/shared/layout/nav.ts                       |  16 +++
 web/tests/admin-organizations.e2e.ts               |  37 ++++++
 web/tests/admin-read-only.e2e.ts                   |  53 ++++++++
 web/tests/admin-viewer.e2e.ts                      |  45 +++++++
 web/tests/allocation-members-edit.e2e.ts           |   2 +-
 web/tests/fixtures/auth.ts                         |  62 +++++++--
 55 files changed, 2779 insertions(+), 137 deletions(-)

diff --git a/web/src/app/(portal)/admin/organizations/OrganizationsListContainer.tsx b/web/src/app/(portal)/admin/organizations/OrganizationsListContainer.tsx
new file mode 100644
index 000000000..b21bc4a04
--- /dev/null
+++ b/web/src/app/(portal)/admin/organizations/OrganizationsListContainer.tsx
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { toast } from "sonner";
+import { CreateOrganizationDialog } from "@/features/core/organizations/components/CreateOrganizationDialog";
+import { OrganizationsList } from "@/features/core/organizations/components/OrganizationsList";
+import { useCreateOrganization, useOrganizations } from "@/features/core/organizations/queries";
+import type { CreateOrganizationPayload } from "@/features/core/organizations/schemas";
+import { useAbility } from "@/shared/casl/AbilityProvider";
+import { Button } from "@/shared/ui/button";
+
+const PAGE_SIZE = 50;
+
+export function OrganizationsListContainer() {
+  const ability = useAbility();
+  const canCreate = ability.can("manage", "Organization");
+
+  const [page, setPage] = React.useState(1);
+  const [dialogOpen, setDialogOpen] = React.useState(false);
+  const [createError, setCreateError] = React.useState<string | null>(null);
+
+  const query = useOrganizations({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE });
+  const createMutation = useCreateOrganization();
+
+  async function handleCreate(payload: CreateOrganizationPayload) {
+    setCreateError(null);
+    try {
+      await createMutation.mutateAsync(payload);
+      toast.success("Organization created");
+      setDialogOpen(false);
+    } catch (err) {
+      setCreateError(err instanceof Error ? err.message : "Failed to create organization");
+    }
+  }
+
+  return (
+    <>
+      <OrganizationsList
+        rows={query.data?.items ?? []}
+        isLoading={query.isLoading}
+        error={(query.error as Error | null) ?? null}
+        onRetry={() => query.refetch()}
+        page={page}
+        pageSize={PAGE_SIZE}
+        total={query.data?.total ?? 0}
+        onPageChange={setPage}
+        headerCta={
+          canCreate ? (
+            <Button
+              onClick={() => {
+                setCreateError(null);
+                setDialogOpen(true);
+              }}
+            >
+              + Create organization
+            </Button>
+          ) : null
+        }
+      />
+      {canCreate ? (
+        <CreateOrganizationDialog
+          open={dialogOpen}
+          onOpenChange={setDialogOpen}
+          onSubmit={handleCreate}
+          isPending={createMutation.isPending}
+          error={createError}
+        />
+      ) : null}
+    </>
+  );
+}
diff --git a/web/src/mocks/handlers.ts b/web/src/app/(portal)/admin/organizations/page.tsx
similarity index 59%
copy from web/src/mocks/handlers.ts
copy to web/src/app/(portal)/admin/organizations/page.tsx
index daaf9750b..9dfd695ae 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/app/(portal)/admin/organizations/page.tsx
@@ -15,19 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import type { Metadata } from "next";
+import { OrganizationsListContainer } from "./OrganizationsListContainer";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+export const metadata: Metadata = {
+  title: "Organizations",
+};
+
+export default function AdminOrganizationsPage() {
+  return <OrganizationsListContainer />;
+}
diff --git a/web/src/app/(portal)/admin/resources/ResourcesClustersTabs.tsx b/web/src/app/(portal)/admin/resources/ResourcesClustersTabs.tsx
new file mode 100644
index 000000000..d8602a6ea
--- /dev/null
+++ b/web/src/app/(portal)/admin/resources/ResourcesClustersTabs.tsx
@@ -0,0 +1,47 @@
+// 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 { ClustersTab } from "@/features/core/clusters/components/ClustersTab";
+import { ResourcesTab } from "@/features/core/resources/components/ResourcesTab";
+import { useAbility } from "@/shared/casl/AbilityProvider";
+import { TabsRouter, type TabsRouterTab } from "@/shared/ui/TabsRouter";
+
+export function ResourcesClustersTabs() {
+  const ability = useAbility();
+
+  const tabs: TabsRouterTab[] = [];
+  if (ability.can("read", "Cluster")) {
+    tabs.push({ value: "clusters", label: "Clusters", content: <ClustersTab /> });
+  }
+  if (ability.can("read", "Allocation")) {
+    tabs.push({ value: "resources", label: "Resources", content: <ResourcesTab /> });
+  }
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="font-display text-[28px] font-bold leading-tight">Resources &amp; Clusters</h1>
+        <p className="mt-1 text-sm text-muted-foreground">
+          Compute clusters and their allocation resources and rates.
+        </p>
+      </div>
+      {tabs[0] ? <TabsRouter defaultValue={tabs[0].value} tabs={tabs} /> : null}
+    </div>
+  );
+}
diff --git a/web/src/app/(portal)/admin/resources/__tests__/ResourcesClustersTabs.test.tsx b/web/src/app/(portal)/admin/resources/__tests__/ResourcesClustersTabs.test.tsx
new file mode 100644
index 000000000..20825fe19
--- /dev/null
+++ b/web/src/app/(portal)/admin/resources/__tests__/ResourcesClustersTabs.test.tsx
@@ -0,0 +1,74 @@
+// 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Privilege } from "@/features/core/identity/types";
+import { defineAbilitiesFor } from "@/shared/casl/abilities";
+
+let currentPrivileges: Privilege[] = [];
+
+vi.mock("@/shared/casl/AbilityProvider", () => ({
+  useAbility: () => defineAbilitiesFor(currentPrivileges),
+}));
+
+// Render only the tab labels/content so the gate logic is tested without
+// pulling in each tab's own data queries.
+vi.mock("@/shared/ui/TabsRouter", () => ({
+  TabsRouter: ({ tabs }: { tabs: Array<{ value: string; label: string }> }) => (
+    <div>
+      {tabs.map((t) => (
+        <div key={t.value}>tab:{t.label}</div>
+      ))}
+    </div>
+  ),
+}));
+vi.mock("@/features/core/clusters/components/ClustersTab", () => ({
+  ClustersTab: () => <div>clusters-tab</div>,
+}));
+vi.mock("@/features/core/resources/components/ResourcesTab", () => ({
+  ResourcesTab: () => <div>resources-tab</div>,
+}));
+
+import { ResourcesClustersTabs } from "../ResourcesClustersTabs";
+
+beforeEach(() => {
+  currentPrivileges = [];
+});
+
+describe("<ResourcesClustersTabs />", () => {
+  it("shows both tabs when the ability can read Cluster and Allocation", () => {
+    currentPrivileges = ["core:clusters:read", "core:allocations:read"];
+    render(<ResourcesClustersTabs />);
+    expect(screen.getByText("tab:Clusters")).toBeInTheDocument();
+    expect(screen.getByText("tab:Resources")).toBeInTheDocument();
+  });
+
+  it("hides the Resources tab without allocation read", () => {
+    currentPrivileges = ["core:clusters:read"];
+    render(<ResourcesClustersTabs />);
+    expect(screen.getByText("tab:Clusters")).toBeInTheDocument();
+    expect(screen.queryByText("tab:Resources")).not.toBeInTheDocument();
+  });
+
+  it("hides the Clusters tab without cluster read", () => {
+    currentPrivileges = ["core:allocations:read"];
+    render(<ResourcesClustersTabs />);
+    expect(screen.queryByText("tab:Clusters")).not.toBeInTheDocument();
+    expect(screen.getByText("tab:Resources")).toBeInTheDocument();
+  });
+});
diff --git a/web/src/mocks/handlers.ts b/web/src/app/(portal)/admin/resources/page.tsx
similarity index 59%
copy from web/src/mocks/handlers.ts
copy to web/src/app/(portal)/admin/resources/page.tsx
index daaf9750b..6565382ee 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/app/(portal)/admin/resources/page.tsx
@@ -15,19 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import type { Metadata } from "next";
+import { ResourcesClustersTabs } from "./ResourcesClustersTabs";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+export const metadata: Metadata = {
+  title: "Resources & Clusters",
+};
+
+export default function AdminResourcesPage() {
+  return <ResourcesClustersTabs />;
+}
diff --git a/web/src/features/core/clusters/__fixtures__/cluster-users.json b/web/src/features/core/clusters/__fixtures__/cluster-users.json
new file mode 100644
index 000000000..88ac10a6e
--- /dev/null
+++ b/web/src/features/core/clusters/__fixtures__/cluster-users.json
@@ -0,0 +1,38 @@
+[
+  {
+    "id": "ccu-anvil-ada",
+    "compute_cluster_id": "cluster-anvil",
+    "user_id": "user-ada-lovelace",
+    "local_username": "alovelace"
+  },
+  {
+    "id": "ccu-anvil-grace",
+    "compute_cluster_id": "cluster-anvil",
+    "user_id": "user-grace-hopper",
+    "local_username": "ghopper"
+  },
+  {
+    "id": "ccu-anvil-dennis",
+    "compute_cluster_id": "cluster-anvil",
+    "user_id": "user-dennis-ritchie",
+    "local_username": "dritchie"
+  },
+  {
+    "id": "ccu-bridges2-ada",
+    "compute_cluster_id": "cluster-bridges2",
+    "user_id": "user-ada-lovelace",
+    "local_username": "ada"
+  },
+  {
+    "id": "ccu-expanse-katherine",
+    "compute_cluster_id": "cluster-expanse",
+    "user_id": "user-katherine-johnson",
+    "local_username": "kjohnson"
+  },
+  {
+    "id": "ccu-delta-donald",
+    "compute_cluster_id": "cluster-delta",
+    "user_id": "user-donald-knuth",
+    "local_username": "dknuth"
+  }
+]
diff --git a/web/src/features/core/clusters/__fixtures__/clusters.json b/web/src/features/core/clusters/__fixtures__/clusters.json
new file mode 100644
index 000000000..6715d7894
--- /dev/null
+++ b/web/src/features/core/clusters/__fixtures__/clusters.json
@@ -0,0 +1,6 @@
+[
+  { "id": "cluster-anvil", "name": "Anvil" },
+  { "id": "cluster-bridges2", "name": "Bridges-2" },
+  { "id": "cluster-expanse", "name": "Expanse" },
+  { "id": "cluster-delta", "name": "Delta" }
+]
diff --git a/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
new file mode 100644
index 000000000..eb83300f1
--- /dev/null
+++ b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
@@ -0,0 +1,70 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ComputeCluster, ComputeClusterUser } from "../schemas";
+
+let clusters: ComputeCluster[] = [];
+let users: ComputeClusterUser[] = [];
+
+vi.mock("../queries", () => ({
+  useClusters: () => ({ data: clusters, isLoading: false, error: null, refetch: vi.fn() }),
+  useClusterUsers: () => ({ data: users, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+import { ClustersTab } from "../components/ClustersTab";
+
+beforeEach(() => {
+  clusters = [
+    { id: "cluster-anvil", name: "Anvil" },
+    { id: "cluster-bridges2", name: "Bridges-2" },
+  ];
+  users = [
+    {
+      id: "ccu-1",
+      compute_cluster_id: "cluster-anvil",
+      user_id: "user-ada-lovelace",
+      local_username: "alovelace",
+    },
+  ];
+});
+
+describe("<ClustersTab />", () => {
+  it("filters clusters client-side by name", () => {
+    render(<ClustersTab />);
+    fireEvent.change(screen.getByLabelText("Search clusters"), { target: { value: "bridges" } });
+    expect(screen.queryByText("Anvil")).not.toBeInTheDocument();
+    expect(screen.getByText("Bridges-2")).toBeInTheDocument();
+  });
+
+  it("opens a drawer with local users, rendering the user id as plain text", () => {
+    render(<ClustersTab />);
+    fireEvent.click(screen.getByRole("button", { name: "Anvil" }));
+
+    expect(screen.getByText("Cluster: Anvil")).toBeInTheDocument();
+    expect(screen.getByText("alovelace")).toBeInTheDocument();
+    expect(screen.getByText("user-ada-lovelace")).toBeInTheDocument();
+    expect(screen.queryByRole("link", { name: "user-ada-lovelace" })).not.toBeInTheDocument();
+  });
+
+  it("shows the empty state when no clusters are registered", () => {
+    clusters = [];
+    render(<ClustersTab />);
+    expect(screen.getByRole("heading", { name: /no clusters registered/i })).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/clusters/__tests__/api.test.ts b/web/src/features/core/clusters/__tests__/api.test.ts
new file mode 100644
index 000000000..7e0069b9d
--- /dev/null
+++ b/web/src/features/core/clusters/__tests__/api.test.ts
@@ -0,0 +1,64 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { getCluster, listClusterUsers, listClusters } from "../api";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function mockResponse(status: number, body: unknown): Response {
+  const text = typeof body === "string" ? body : JSON.stringify(body);
+  return new Response(text, { status, headers: { "content-type": "application/json" } });
+}
+
+afterEach(() => {
+  fetchMock.mockReset();
+});
+
+const cluster = { id: "cluster-anvil", name: "Anvil" };
+const clusterUser = {
+  id: "ccu-1",
+  compute_cluster_id: "cluster-anvil",
+  user_id: "user-ada-lovelace",
+  local_username: "alovelace",
+};
+
+describe("listClusters", () => {
+  it("parses a bare array of clusters", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [cluster]));
+    const out = await listClusters();
+    expect(out).toHaveLength(1);
+    expect(out[0]?.name).toBe("Anvil");
+  });
+});
+
+describe("getCluster", () => {
+  it("parses a single cluster", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, cluster));
+    const out = await getCluster("cluster-anvil");
+    expect(out.id).toBe("cluster-anvil");
+  });
+});
+
+describe("listClusterUsers", () => {
+  it("parses a bare array of cluster users", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [clusterUser]));
+    const out = await listClusterUsers("cluster-anvil");
+    expect(out[0]?.local_username).toBe("alovelace");
+  });
+});
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/clusters/__tests__/queries.test.ts
similarity index 59%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/clusters/__tests__/queries.test.ts
index daaf9750b..e602a7fef 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/clusters/__tests__/queries.test.ts
@@ -15,19 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import { describe, expect, it } from "vitest";
+import { clusterKeys } from "../queries";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+describe("clusterKeys", () => {
+  it("namespaces under 'clusters'", () => {
+    expect(clusterKeys.all).toEqual(["clusters"]);
+  });
+
+  it("detail and users keys carry the id", () => {
+    expect(clusterKeys.detail("cluster-anvil")).toEqual(["clusters", "detail", "cluster-anvil"]);
+    expect(clusterKeys.users("cluster-anvil")).toEqual(["clusters", "users", "cluster-anvil"]);
+  });
+});
diff --git a/web/src/features/core/clusters/__tests__/schemas.test.ts b/web/src/features/core/clusters/__tests__/schemas.test.ts
new file mode 100644
index 000000000..65fe62c0e
--- /dev/null
+++ b/web/src/features/core/clusters/__tests__/schemas.test.ts
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { describe, expect, it } from "vitest";
+import { computeClusterSchema, computeClusterUserSchema } from "../schemas";
+
+describe("computeClusterSchema", () => {
+  it("accepts a cluster with id and name", () => {
+    expect(computeClusterSchema.parse({ id: "cluster-anvil", name: "Anvil" }).name).toBe("Anvil");
+  });
+
+  it("rejects a cluster missing its name", () => {
+    expect(computeClusterSchema.safeParse({ id: "cluster-anvil" }).success).toBe(false);
+  });
+});
+
+describe("computeClusterUserSchema", () => {
+  it("accepts a fully populated cluster user", () => {
+    const parsed = computeClusterUserSchema.parse({
+      id: "ccu-1",
+      compute_cluster_id: "cluster-anvil",
+      user_id: "user-ada-lovelace",
+      local_username: "alovelace",
+    });
+    expect(parsed.local_username).toBe("alovelace");
+  });
+
+  it("rejects a cluster user missing its local username", () => {
+    const result = computeClusterUserSchema.safeParse({
+      id: "ccu-1",
+      compute_cluster_id: "cluster-anvil",
+      user_id: "user-ada-lovelace",
+    });
+    expect(result.success).toBe(false);
+  });
+});
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/clusters/api.ts
similarity index 51%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/clusters/api.ts
index daaf9750b..5b6cf7356 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/clusters/api.ts
@@ -15,19 +15,26 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import { z } from "zod";
+import { apiFetch } from "@/shared/api/client";
+import {
+  type ComputeCluster,
+  type ComputeClusterUser,
+  computeClusterSchema,
+  computeClusterUserSchema,
+} from "./schemas";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+export async function listClusters(): Promise<ComputeCluster[]> {
+  const raw = await apiFetch("/compute-clusters");
+  return z.array(computeClusterSchema).parse(raw);
+}
+
+export async function getCluster(id: string): Promise<ComputeCluster> {
+  const raw = await apiFetch(`/compute-clusters/${id}`);
+  return computeClusterSchema.parse(raw);
+}
+
+export async function listClusterUsers(id: string): Promise<ComputeClusterUser[]> {
+  const raw = await apiFetch(`/compute-clusters/${id}/users`);
+  return z.array(computeClusterUserSchema).parse(raw);
+}
diff --git a/web/src/features/core/clusters/components/ClusterUsersDrawer.tsx b/web/src/features/core/clusters/components/ClusterUsersDrawer.tsx
new file mode 100644
index 000000000..b68c4f3fe
--- /dev/null
+++ b/web/src/features/core/clusters/components/ClusterUsersDrawer.tsx
@@ -0,0 +1,79 @@
+// 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 { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { CardSkeleton } from "@/shared/ui/Loading";
+import { SideDrawer } from "@/shared/ui/SideDrawer";
+import { useClusterUsers } from "../queries";
+import type { ComputeClusterUser } from "../schemas";
+
+export type ClusterUsersDrawerProps = {
+  clusterId: string | null;
+  clusterName: string | null;
+  onOpenChange: (open: boolean) => void;
+};
+
+export function ClusterUsersDrawer({
+  clusterId,
+  clusterName,
+  onOpenChange,
+}: ClusterUsersDrawerProps) {
+  const usersQuery = useClusterUsers(clusterId ?? undefined);
+  const users = usersQuery.data ?? [];
+
+  return (
+    <SideDrawer
+      open={Boolean(clusterId)}
+      onOpenChange={onOpenChange}
+      title={clusterName ? `Cluster: ${clusterName}` : "Cluster"}
+      width="lg"
+    >
+      {usersQuery.isLoading ? (
+        <CardSkeleton />
+      ) : usersQuery.error ? (
+        <ErrorState
+          message={(usersQuery.error as Error).message}
+          onRetry={() => usersQuery.refetch()}
+        />
+      ) : users.length === 0 ? (
+        <EmptyState heading="No local accounts on this cluster." />
+      ) : (
+        <table className="w-full text-left text-sm">
+          <thead className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+            <tr>
+              <th className="py-2 pr-4 font-medium">Local username</th>
+              <th className="py-2 font-medium">User ID</th>
+            </tr>
+          </thead>
+          <tbody>
+            {users.map((user: ComputeClusterUser) => (
+              <tr key={user.id} className="border-t border-border/60">
+                <td className="py-2 pr-4 font-mono text-xs text-foreground">
+                  {user.local_username}
+                </td>
+                <td className="py-2 font-mono text-xs text-muted-foreground">{user.user_id}</td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      )}
+    </SideDrawer>
+  );
+}
diff --git a/web/src/features/core/clusters/components/ClustersTab.tsx b/web/src/features/core/clusters/components/ClustersTab.tsx
new file mode 100644
index 000000000..def2dd5dc
--- /dev/null
+++ b/web/src/features/core/clusters/components/ClustersTab.tsx
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { Input } from "@/shared/ui/input";
+import { CardSkeleton } from "@/shared/ui/Loading";
+import { useClusters } from "../queries";
+import type { ComputeCluster } from "../schemas";
+import { ClusterUsersDrawer } from "./ClusterUsersDrawer";
+
+export function ClustersTab() {
+  const query = useClusters();
+  const [search, setSearch] = React.useState("");
+  const [selected, setSelected] = React.useState<ComputeCluster | null>(null);
+
+  const rows = query.data ?? [];
+  const filtered = React.useMemo(() => {
+    const needle = search.trim().toLowerCase();
+    if (!needle) return rows;
+    return rows.filter((row) => `${row.name} ${row.id}`.toLowerCase().includes(needle));
+  }, [rows, search]);
+
+  const columns: Array<DataTableColumn<ComputeCluster>> = [
+    {
+      key: "name",
+      header: "Cluster",
+      cell: (row) => <span className="font-medium text-foreground">{row.name}</span>,
+    },
+    {
+      key: "id",
+      header: "ID",
+      cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.id}</span>,
+    },
+  ];
+
+  return (
+    <div className="space-y-4">
+      <div className="rounded-lg border border-border bg-card p-4">
+        <Input
+          type="search"
+          placeholder="Search clusters"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          aria-label="Search clusters"
+          className="sm:w-72"
+        />
+      </div>
+
+      {query.isLoading ? (
+        <CardSkeleton />
+      ) : query.error ? (
+        <ErrorState message={(query.error as Error).message} onRetry={() => query.refetch()} />
+      ) : rows.length === 0 ? (
+        <EmptyState heading="No clusters registered." />
+      ) : filtered.length === 0 ? (
+        <EmptyState heading="No clusters match this search." />
+      ) : (
+        <DataTable
+          columns={columns}
+          rows={filtered}
+          rowKey={(row) => row.id}
+          onRowClick={(row) => setSelected(row)}
+        />
+      )}
+
+      <ClusterUsersDrawer
+        clusterId={selected?.id ?? null}
+        clusterName={selected?.name ?? null}
+        onOpenChange={(open) => {
+          if (!open) setSelected(null);
+        }}
+      />
+    </div>
+  );
+}
diff --git a/web/src/features/core/clusters/queries.ts b/web/src/features/core/clusters/queries.ts
new file mode 100644
index 000000000..3531b7894
--- /dev/null
+++ b/web/src/features/core/clusters/queries.ts
@@ -0,0 +1,60 @@
+// 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 { useQuery } from "@tanstack/react-query";
+import { getCluster, listClusterUsers, listClusters } from "./api";
+
+export const clusterKeys = {
+  all: ["clusters"] as const,
+  list: () => [...clusterKeys.all, "list"] as const,
+  detail: (id: string) => [...clusterKeys.all, "detail", id] as const,
+  users: (id: string) => [...clusterKeys.all, "users", id] as const,
+};
+
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
+export function useClusters() {
+  return useQuery({
+    queryKey: clusterKeys.list(),
+    queryFn: listClusters,
+    ...DEFAULTS,
+  });
+}
+
+export function useCluster(id: string | undefined) {
+  return useQuery({
+    queryKey: id ? clusterKeys.detail(id) : [...clusterKeys.all, "detail", "none"],
+    queryFn: () => getCluster(id as string),
+    enabled: Boolean(id),
+    ...DEFAULTS,
+  });
+}
+
+export function useClusterUsers(id: string | undefined) {
+  return useQuery({
+    queryKey: id ? clusterKeys.users(id) : [...clusterKeys.all, "users", "none"],
+    queryFn: () => listClusterUsers(id as string),
+    enabled: Boolean(id),
+    ...DEFAULTS,
+  });
+}
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/clusters/schemas.ts
similarity index 55%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/clusters/schemas.ts
index daaf9750b..615d7ba3a 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/clusters/schemas.ts
@@ -15,19 +15,18 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import type { z } from "zod";
+import { zComputeCluster, zComputeClusterUser } from "@/generated/core/zod.gen";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+// The generated schema marks every field optional; the backend always returns
+// these, so tighten them here where the UI reads them.
+export const computeClusterSchema = zComputeCluster.required({ id: true, name: true });
+export type ComputeCluster = z.infer<typeof computeClusterSchema>;
+
+export const computeClusterUserSchema = zComputeClusterUser.required({
+  id: true,
+  compute_cluster_id: true,
+  user_id: true,
+  local_username: true,
+});
+export type ComputeClusterUser = z.infer<typeof computeClusterUserSchema>;
diff --git a/web/src/features/core/organizations/__fixtures__/organizations.json b/web/src/features/core/organizations/__fixtures__/organizations.json
new file mode 100644
index 000000000..98899cdf3
--- /dev/null
+++ b/web/src/features/core/organizations/__fixtures__/organizations.json
@@ -0,0 +1,52 @@
+[
+  {
+    "id": "org-gatech",
+    "name": "Georgia Institute of Technology",
+    "originated_id": "GATECH"
+  },
+  {
+    "id": "org-tacc",
+    "name": "Texas Advanced Computing Center",
+    "originated_id": "TACC"
+  },
+  {
+    "id": "org-ncsa",
+    "name": "National Center for Supercomputing Applications",
+    "originated_id": "NCSA"
+  },
+  {
+    "id": "org-psc",
+    "name": "Pittsburgh Supercomputing Center",
+    "originated_id": "PSC"
+  },
+  {
+    "id": "org-sdsc",
+    "name": "San Diego Supercomputer Center",
+    "originated_id": "SDSC"
+  },
+  {
+    "id": "org-purdue",
+    "name": "Purdue University",
+    "originated_id": "PURDUE"
+  },
+  {
+    "id": "org-indiana",
+    "name": "Indiana University",
+    "originated_id": "IU"
+  },
+  {
+    "id": "org-jhu",
+    "name": "Johns Hopkins University",
+    "originated_id": "JHU"
+  },
+  {
+    "id": "org-uky",
+    "name": "University of Kentucky",
+    "originated_id": "UKY"
+  },
+  {
+    "id": "org-osc",
+    "name": "Ohio Supercomputer Center",
+    "originated_id": "OSC"
+  }
+]
diff --git a/web/src/features/core/organizations/__tests__/CreateOrganizationDialog.test.tsx b/web/src/features/core/organizations/__tests__/CreateOrganizationDialog.test.tsx
new file mode 100644
index 000000000..e96a11795
--- /dev/null
+++ b/web/src/features/core/organizations/__tests__/CreateOrganizationDialog.test.tsx
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CreateOrganizationDialog } from "../components/CreateOrganizationDialog";
+
+describe("CreateOrganizationDialog", () => {
+  it("keeps submit disabled until a name is entered", () => {
+    const onSubmit = vi.fn();
+    render(
+      <CreateOrganizationDialog
+        open
+        onOpenChange={() => {}}
+        onSubmit={onSubmit}
+        isPending={false}
+      />,
+    );
+    const submit = screen.getByRole("button", { name: "Create organization" });
+    expect(submit).toBeDisabled();
+    fireEvent.change(screen.getByLabelText("Name"), {
+      target: { value: "Georgia Institute of Technology" },
+    });
+    expect(submit).toBeEnabled();
+  });
+
+  it("submits name and optional originated_id", () => {
+    const onSubmit = vi.fn();
+    render(
+      <CreateOrganizationDialog
+        open
+        onOpenChange={() => {}}
+        onSubmit={onSubmit}
+        isPending={false}
+      />,
+    );
+    fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Purdue University" } });
+    fireEvent.change(screen.getByLabelText("Originated ID"), { target: { value: "PURDUE" } });
+    fireEvent.click(screen.getByRole("button", { name: "Create organization" }));
+    expect(onSubmit).toHaveBeenCalledWith({ name: "Purdue University", originated_id: "PURDUE" });
+  });
+
+  it("renders the backend error verbatim", () => {
+    render(
+      <CreateOrganizationDialog
+        open
+        onOpenChange={() => {}}
+        onSubmit={() => {}}
+        isPending={false}
+        error="organization already exists"
+      />,
+    );
+    expect(screen.getByText("organization already exists")).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/organizations/__tests__/OrganizationsList.test.tsx b/web/src/features/core/organizations/__tests__/OrganizationsList.test.tsx
new file mode 100644
index 000000000..0adc8bbd7
--- /dev/null
+++ b/web/src/features/core/organizations/__tests__/OrganizationsList.test.tsx
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { Button } from "@/shared/ui/button";
+import { OrganizationsList } from "../components/OrganizationsList";
+import type { Organization } from "../schemas";
+
+const org: Organization = {
+  id: "org-gatech",
+  name: "Georgia Institute of Technology",
+  originated_id: "GATECH",
+};
+
+function renderList(overrides: Partial<React.ComponentProps<typeof OrganizationsList>> = {}) {
+  const props = {
+    rows: [org],
+    isLoading: false,
+    error: null,
+    page: 1,
+    pageSize: 50,
+    total: 1,
+    onPageChange: vi.fn(),
+    ...overrides,
+  };
+  return { ...render(<OrganizationsList {...props} />), props };
+}
+
+describe("<OrganizationsList />", () => {
+  it("renders a row with the organization fields", () => {
+    renderList();
+    expect(screen.getByText(org.name)).toBeInTheDocument();
+    expect(screen.getByText(org.originated_id)).toBeInTheDocument();
+    expect(screen.getByText(org.id)).toBeInTheDocument();
+  });
+
+  it("shows the empty state when there are no rows", () => {
+    renderList({ rows: [], total: 0 });
+    expect(screen.getByRole("heading", { name: /no organizations yet/i })).toBeInTheDocument();
+  });
+
+  it("renders the create CTA only when one is provided", () => {
+    const { rerender } = renderList();
+    expect(screen.queryByRole("button", { name: /create organization/i })).not.toBeInTheDocument();
+    rerender(
+      <OrganizationsList
+        rows={[org]}
+        isLoading={false}
+        error={null}
+        page={1}
+        pageSize={50}
+        total={1}
+        onPageChange={vi.fn()}
+        headerCta={<Button>+ Create organization</Button>}
+      />,
+    );
+    expect(screen.getByRole("button", { name: /create organization/i })).toBeInTheDocument();
+  });
+
+  it("surfaces an error state with a retry callback", () => {
+    const onRetry = vi.fn();
+    renderList({ error: new Error("boom"), onRetry });
+    expect(screen.getByText(/boom/)).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/organizations/__tests__/api.test.ts b/web/src/features/core/organizations/__tests__/api.test.ts
new file mode 100644
index 000000000..d1c30045f
--- /dev/null
+++ b/web/src/features/core/organizations/__tests__/api.test.ts
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createOrganization, listOrganizations } from "../api";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function mockResponse(status: number, body: unknown): Response {
+  const text = typeof body === "string" ? body : JSON.stringify(body);
+  return new Response(text, { status, headers: { "content-type": "application/json" } });
+}
+
+afterEach(() => {
+  fetchMock.mockReset();
+});
+
+const validOrg = {
+  id: "org-gatech",
+  name: "Georgia Institute of Technology",
+  originated_id: "GATECH",
+};
+
+describe("listOrganizations", () => {
+  it("round-trips limit/offset and returns the envelope", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, { items: [validOrg], total: 1 }));
+    const result = await listOrganizations({ limit: 50, offset: 50 });
+    expect(result.total).toBe(1);
+    expect(result.items).toHaveLength(1);
+    const calledUrl = fetchMock.mock.calls[0]?.[0] as string;
+    expect(calledUrl).toContain("limit=50");
+    expect(calledUrl).toContain("offset=50");
+  });
+
+  it("omits the query string when no params are given", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, { items: [], total: 0 }));
+    await listOrganizations();
+    const calledUrl = fetchMock.mock.calls[0]?.[0] as string;
+    expect(calledUrl).not.toContain("?");
+  });
+
+  it("rejects on schema mismatch (total missing)", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, { items: [validOrg] }));
+    await expect(listOrganizations()).rejects.toThrow();
+  });
+});
+
+describe("createOrganization", () => {
+  it("POSTs the validated payload and parses the response", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(201, validOrg));
+    const out = await createOrganization({ name: "Georgia Institute of Technology" });
+    expect(out.id).toBe("org-gatech");
+    expect(fetchMock.mock.calls[0]?.[0]).toContain("/organizations");
+    expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
+  });
+
+  it("rejects an empty name before calling fetch", async () => {
+    await expect(createOrganization({ name: "" })).rejects.toThrow();
+    expect(fetchMock).not.toHaveBeenCalled();
+  });
+});
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/organizations/__tests__/queries.test.ts
similarity index 59%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/organizations/__tests__/queries.test.ts
index daaf9750b..cdb7ff3a0 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/organizations/__tests__/queries.test.ts
@@ -15,19 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import { describe, expect, it } from "vitest";
+import { organizationKeys } from "../queries";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+describe("organizationKeys", () => {
+  it("namespaces under 'organizations'", () => {
+    expect(organizationKeys.all).toEqual(["organizations"]);
+  });
+
+  it("list key carries the params", () => {
+    const params = { limit: 50, offset: 0 };
+    expect(organizationKeys.list(params)).toEqual(["organizations", "list", params]);
+  });
+});
diff --git a/web/src/features/core/organizations/__tests__/schemas.test.ts b/web/src/features/core/organizations/__tests__/schemas.test.ts
new file mode 100644
index 000000000..e72dface5
--- /dev/null
+++ b/web/src/features/core/organizations/__tests__/schemas.test.ts
@@ -0,0 +1,63 @@
+// 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 { describe, expect, it } from "vitest";
+import {
+  createOrganizationPayloadSchema,
+  organizationListEnvelopeSchema,
+  organizationSchema,
+} from "../schemas";
+
+const validOrg = {
+  id: "org-gatech",
+  name: "Georgia Institute of Technology",
+  originated_id: "GATECH",
+};
+
+describe("organizationSchema", () => {
+  it("accepts a backend-shaped organization", () => {
+    expect(organizationSchema.safeParse(validOrg).success).toBe(true);
+  });
+  it("rejects a missing name", () => {
+    expect(organizationSchema.safeParse({ id: "org-1", originated_id: "X" }).success).toBe(false);
+  });
+});
+
+describe("organizationListEnvelopeSchema", () => {
+  it("accepts a valid envelope", () => {
+    expect(
+      organizationListEnvelopeSchema.safeParse({ items: [validOrg], total: 1 }).success,
+    ).toBe(true);
+  });
+  it("rejects a negative total", () => {
+    expect(organizationListEnvelopeSchema.safeParse({ items: [], total: -1 }).success).toBe(false);
+  });
+});
+
+describe("createOrganizationPayloadSchema", () => {
+  it("requires name 1+ chars", () => {
+    expect(createOrganizationPayloadSchema.safeParse({ name: "" }).success).toBe(false);
+  });
+  it("accepts a name-only payload", () => {
+    expect(createOrganizationPayloadSchema.safeParse({ name: "GT" }).success).toBe(true);
+  });
+  it("accepts an optional originated_id", () => {
+    expect(
+      createOrganizationPayloadSchema.safeParse({ name: "GT", originated_id: "GATECH" }).success,
+    ).toBe(true);
+  });
+});
diff --git a/web/src/features/core/organizations/api.ts b/web/src/features/core/organizations/api.ts
new file mode 100644
index 000000000..8db5e2ed5
--- /dev/null
+++ b/web/src/features/core/organizations/api.ts
@@ -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.
+
+import { apiFetch } from "@/shared/api/client";
+import {
+  type CreateOrganizationPayload,
+  type Organization,
+  type OrganizationListEnvelope,
+  createOrganizationPayloadSchema,
+  organizationListEnvelopeSchema,
+  organizationSchema,
+} from "./schemas";
+import type { OrganizationListParams } from "./types";
+
+export async function listOrganizations(
+  params: OrganizationListParams = {},
+): Promise<OrganizationListEnvelope> {
+  const search = new URLSearchParams();
+  if (typeof params.limit === "number") search.set("limit", String(params.limit));
+  if (typeof params.offset === "number") search.set("offset", String(params.offset));
+  const qs = search.toString();
+  const raw = await apiFetch(`/organizations${qs ? `?${qs}` : ""}`);
+  return organizationListEnvelopeSchema.parse(raw);
+}
+
+export async function createOrganization(
+  payload: CreateOrganizationPayload,
+): Promise<Organization> {
+  const validated = createOrganizationPayloadSchema.parse(payload);
+  const raw = await apiFetch("/organizations", { method: "POST", body: validated });
+  return organizationSchema.parse(raw);
+}
diff --git a/web/src/features/core/organizations/components/CreateOrganizationDialog.tsx b/web/src/features/core/organizations/components/CreateOrganizationDialog.tsx
new file mode 100644
index 000000000..d3ce048eb
--- /dev/null
+++ b/web/src/features/core/organizations/components/CreateOrganizationDialog.tsx
@@ -0,0 +1,111 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
+import type { CreateOrganizationPayload } from "../schemas";
+
+export type CreateOrganizationDialogProps = {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onSubmit: (payload: CreateOrganizationPayload) => void;
+  isPending: boolean;
+  error?: string | null;
+};
+
+export function CreateOrganizationDialog({
+  open,
+  onOpenChange,
+  onSubmit,
+  isPending,
+  error,
+}: CreateOrganizationDialogProps) {
+  const [name, setName] = React.useState("");
+  const [originatedId, setOriginatedId] = React.useState("");
+
+  React.useEffect(() => {
+    if (!open) {
+      setName("");
+      setOriginatedId("");
+    }
+  }, [open]);
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>Create organization</DialogTitle>
+          <DialogDescription>
+            Register an institution or resource provider.
+          </DialogDescription>
+        </DialogHeader>
+        <form
+          className="space-y-4"
+          onSubmit={(e) => {
+            e.preventDefault();
+            if (!name.trim()) return;
+            const originated = originatedId.trim();
+            onSubmit({ name: name.trim(), originated_id: originated || undefined });
+          }}
+        >
+          <div className="space-y-2">
+            <Label htmlFor="create-org-name">Name</Label>
+            <Input
+              id="create-org-name"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="Georgia Institute of Technology"
+              required
+            />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="create-org-originated-id">Originated ID</Label>
+            <Input
+              id="create-org-originated-id"
+              value={originatedId}
+              onChange={(e) => setOriginatedId(e.target.value)}
+              placeholder="Optional, e.g. ACCESS org code"
+            />
+          </div>
+          {error ? (
+            <p className="text-sm text-[color:var(--custos-red-700)]">{error}</p>
+          ) : null}
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={isPending || !name.trim()}>
+              {isPending ? "Creating…" : "Create organization"}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/features/core/organizations/components/OrganizationsList.tsx b/web/src/features/core/organizations/components/OrganizationsList.tsx
new file mode 100644
index 000000000..de8e439c2
--- /dev/null
+++ b/web/src/features/core/organizations/components/OrganizationsList.tsx
@@ -0,0 +1,98 @@
+// 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 type * as React from "react";
+import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { CardSkeleton } from "@/shared/ui/Loading";
+import type { Organization } from "../schemas";
+
+export type OrganizationsListProps = {
+  rows: Organization[];
+  isLoading: boolean;
+  error: Error | null;
+  onRetry?: () => void;
+  page: number;
+  pageSize: number;
+  total: number;
+  onPageChange: (page: number) => void;
+  headerCta?: React.ReactNode;
+};
+
+export function OrganizationsList({
+  rows,
+  isLoading,
+  error,
+  onRetry,
+  page,
+  pageSize,
+  total,
+  onPageChange,
+  headerCta,
+}: OrganizationsListProps) {
+  const columns: Array<DataTableColumn<Organization>> = [
+    {
+      key: "name",
+      header: "Name",
+      cell: (row) => <span className="font-medium text-foreground">{row.name}</span>,
+    },
+    {
+      key: "originated_id",
+      header: "Originated ID",
+      cell: (row) => (
+        <span className="font-mono text-xs text-muted-foreground">{row.originated_id}</span>
+      ),
+    },
+    {
+      key: "id",
+      header: "ID",
+      cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.id}</span>,
+    },
+  ];
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
+        <div>
+          <h1 className="font-display text-[28px] font-bold leading-tight">Organizations</h1>
+          <p className="mt-1 text-sm text-muted-foreground">
+            Institutions and resource providers registered in Custos.
+          </p>
+        </div>
+        {headerCta ? <div>{headerCta}</div> : null}
+      </div>
+
+      {isLoading ? (
+        <CardSkeleton />
+      ) : error ? (
+        <ErrorState message={error.message} onRetry={onRetry} />
+      ) : rows.length === 0 ? (
+        <EmptyState heading="No organizations yet." />
+      ) : (
+        <DataTable
+          columns={columns}
+          rows={rows}
+          rowKey={(row) => row.id}
+          pagination={{ page, pageSize, total, onPageChange }}
+        />
+      )}
+    </div>
+  );
+}
diff --git a/web/src/features/core/organizations/queries.ts b/web/src/features/core/organizations/queries.ts
new file mode 100644
index 000000000..fbe319eb2
--- /dev/null
+++ b/web/src/features/core/organizations/queries.ts
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createOrganization, listOrganizations } from "./api";
+import type { CreateOrganizationPayload } from "./schemas";
+import type { OrganizationListParams } from "./types";
+
+export const organizationKeys = {
+  all: ["organizations"] as const,
+  list: (params: OrganizationListParams = {}) =>
+    [...organizationKeys.all, "list", params] as const,
+};
+
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
+export function useOrganizations(
+  params: OrganizationListParams = {},
+  options: { enabled?: boolean } = {},
+) {
+  return useQuery({
+    queryKey: organizationKeys.list(params),
+    queryFn: () => listOrganizations(params),
+    enabled: options.enabled ?? true,
+    ...DEFAULTS,
+  });
+}
+
+export function useCreateOrganization() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: (payload: CreateOrganizationPayload) => createOrganization(payload),
+    onSuccess: () => client.invalidateQueries({ queryKey: organizationKeys.all }),
+  });
+}
diff --git a/web/src/features/core/organizations/schemas.ts b/web/src/features/core/organizations/schemas.ts
new file mode 100644
index 000000000..01bb185b6
--- /dev/null
+++ b/web/src/features/core/organizations/schemas.ts
@@ -0,0 +1,41 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { z } from "zod";
+import { zOrganization } from "@/generated/core/zod.gen";
+
+// The generated schema marks every field optional; the backend always returns
+// all three, so tighten them here where the UI reads them.
+export const organizationSchema = zOrganization.required({
+  id: true,
+  name: true,
+  originated_id: true,
+});
+export type Organization = z.infer<typeof organizationSchema>;
+
+// Portal-shaped paginated envelope. Mirrors the projects list contract.
+export const organizationListEnvelopeSchema = z.object({
+  items: z.array(organizationSchema),
+  total: z.number().int().nonnegative(),
+});
+export type OrganizationListEnvelope = z.infer<typeof organizationListEnvelopeSchema>;
+
+export const createOrganizationPayloadSchema = z.object({
+  name: z.string().min(1).max(200),
+  originated_id: z.string().optional(),
+});
+export type CreateOrganizationPayload = z.infer<typeof createOrganizationPayloadSchema>;
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/organizations/types.ts
similarity index 58%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/organizations/types.ts
index daaf9750b..238c20c44 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/organizations/types.ts
@@ -15,19 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
-
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+export type OrganizationListParams = {
+  limit?: number;
+  offset?: number;
+};
diff --git a/web/src/features/core/resources/__fixtures__/rates.json b/web/src/features/core/resources/__fixtures__/rates.json
new file mode 100644
index 000000000..833ca49ea
--- /dev/null
+++ b/web/src/features/core/resources/__fixtures__/rates.json
@@ -0,0 +1,37 @@
+[
+  {
+    "id": "rate-anvil-cpu-current",
+    "compute_allocation_resource_id": "res-anvil-cpu",
+    "rate": 0.05,
+    "start_time": "2020-01-01T00:00:00Z",
+    "end_time": "2035-01-01T00:00:00Z"
+  },
+  {
+    "id": "rate-anvil-cpu-old",
+    "compute_allocation_resource_id": "res-anvil-cpu",
+    "rate": 0.04,
+    "start_time": "2018-01-01T00:00:00Z",
+    "end_time": "2020-01-01T00:00:00Z"
+  },
+  {
+    "id": "rate-anvil-gpu-current",
+    "compute_allocation_resource_id": "res-anvil-gpu",
+    "rate": 0.5,
+    "start_time": "2021-06-01T00:00:00Z",
+    "end_time": "2035-06-01T00:00:00Z"
+  },
+  {
+    "id": "rate-anvil-gpu-old",
+    "compute_allocation_resource_id": "res-anvil-gpu",
+    "rate": 0.45,
+    "start_time": "2019-06-01T00:00:00Z",
+    "end_time": "2021-06-01T00:00:00Z"
+  },
+  {
+    "id": "rate-bridges2-cpu-current",
+    "compute_allocation_resource_id": "res-bridges2-cpu",
+    "rate": 0.06,
+    "start_time": "2022-01-01T00:00:00Z",
+    "end_time": "2033-01-01T00:00:00Z"
+  }
+]
diff --git a/web/src/features/core/resources/__fixtures__/resources.json b/web/src/features/core/resources/__fixtures__/resources.json
new file mode 100644
index 000000000..d4eeb62bb
--- /dev/null
+++ b/web/src/features/core/resources/__fixtures__/resources.json
@@ -0,0 +1,51 @@
+[
+  {
+    "id": "res-anvil-cpu",
+    "name": "Anvil CPU",
+    "resource_type": "CPU",
+    "resource_amount": 1000000,
+    "compute_cluster_id": "cluster-anvil"
+  },
+  {
+    "id": "res-anvil-gpu",
+    "name": "Anvil GPU",
+    "resource_type": "GPU",
+    "resource_amount": 50000,
+    "compute_cluster_id": "cluster-anvil"
+  },
+  {
+    "id": "res-bridges2-cpu",
+    "name": "Bridges-2 RM",
+    "resource_type": "CPU",
+    "resource_amount": 800000,
+    "compute_cluster_id": "cluster-bridges2"
+  },
+  {
+    "id": "res-bridges2-storage",
+    "name": "Bridges-2 Ocean",
+    "resource_type": "STORAGE",
+    "resource_amount": 200000,
+    "compute_cluster_id": "cluster-bridges2"
+  },
+  {
+    "id": "res-expanse-gpu",
+    "name": "Expanse GPU",
+    "resource_type": "GPU",
+    "resource_amount": 30000,
+    "compute_cluster_id": "cluster-expanse"
+  },
+  {
+    "id": "res-delta-cpu",
+    "name": "Delta CPU",
+    "resource_type": "CPU",
+    "resource_amount": 600000,
+    "compute_cluster_id": "cluster-delta"
+  },
+  {
+    "id": "res-delta-gpu",
+    "name": "Delta GPU A100",
+    "resource_type": "GPU",
+    "resource_amount": 40000,
+    "compute_cluster_id": "cluster-delta"
+  }
+]
diff --git a/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx b/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx
new file mode 100644
index 000000000..a7ad1445b
--- /dev/null
+++ b/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx
@@ -0,0 +1,82 @@
+// 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Rate } from "../schemas";
+
+// Windows are anchored far in the past/future so the ACTIVE/EXPIRED derivation
+// stays stable regardless of the wall clock at test time.
+const activeRate: Rate = {
+  id: "rate-current",
+  compute_allocation_resource_id: "res-1",
+  rate: 0.05,
+  start_time: "2000-01-01T00:00:00Z",
+  end_time: "2100-01-01T00:00:00Z",
+};
+const expiredRate: Rate = {
+  id: "rate-old",
+  compute_allocation_resource_id: "res-1",
+  rate: 0.04,
+  start_time: "2000-01-01T00:00:00Z",
+  end_time: "2001-01-01T00:00:00Z",
+};
+
+let rates: Rate[] = [];
+let effective: Rate | undefined;
+
+vi.mock("../queries", () => ({
+  useResourceRates: () => ({ data: rates, isLoading: false, error: null, refetch: vi.fn() }),
+  useEffectiveRate: () => ({ data: effective, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+import { ResourcesRatesDrawer } from "../components/ResourcesRatesDrawer";
+
+beforeEach(() => {
+  rates = [activeRate, expiredRate];
+  effective = activeRate;
+});
+
+describe("<ResourcesRatesDrawer />", () => {
+  it("derives ACTIVE and EXPIRED badges per rate window", () => {
+    render(
+      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
+    );
+    // One badge in the effective card plus one per active history row.
+    expect(screen.getAllByText("ACTIVE").length).toBeGreaterThanOrEqual(2);
+    expect(screen.getByText("EXPIRED")).toBeInTheDocument();
+  });
+
+  it("titles the drawer with the resource name and shows the effective rate", () => {
+    render(
+      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
+    );
+    expect(screen.getByText("Rates: Anvil CPU")).toBeInTheDocument();
+    expect(screen.getByText("Effective rate")).toBeInTheDocument();
+  });
+
+  it("shows the empty state when no rates are recorded", () => {
+    rates = [];
+    effective = undefined;
+    render(
+      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
+    );
+    expect(
+      screen.getByRole("heading", { name: /no rates recorded for this resource/i }),
+    ).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
new file mode 100644
index 000000000..e99c3efb9
--- /dev/null
+++ b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ComputeCluster } from "@/features/core/clusters/schemas";
+import type { ComputeAllocationResource } from "../schemas";
+
+let resources: ComputeAllocationResource[] = [];
+const clusters: ComputeCluster[] = [
+  { id: "cluster-anvil", name: "Anvil" },
+  { id: "cluster-delta", name: "Delta" },
+];
+
+vi.mock("../queries", () => ({
+  useResources: () => ({ data: resources, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+vi.mock("@/features/core/clusters/queries", () => ({
+  useClusters: () => ({ data: clusters, isLoading: false, error: null, refetch: vi.fn() }),
+}));
+
+// The rates drawer owns its own queries; stub it so this suite stays on the
+// table, its filters, and the cluster resolution.
+vi.mock("../components/ResourcesRatesDrawer", () => ({
+  ResourcesRatesDrawer: ({ resourceName }: { resourceName: string | null }) => (
+    <div>{resourceName ? `rates-drawer:${resourceName}` : null}</div>
+  ),
+}));
+
+import { ResourcesTab } from "../components/ResourcesTab";
+
+beforeEach(() => {
+  resources = [
+    {
+      id: "res-anvil-cpu",
+      name: "Anvil CPU",
+      resource_type: "CPU",
+      resource_amount: 1000,
+      compute_cluster_id: "cluster-anvil",
+    },
+    {
+      id: "res-delta-gpu",
+      name: "Delta GPU",
+      resource_type: "GPU",
+      resource_amount: 200,
+      compute_cluster_id: "cluster-delta",
+    },
+  ];
+});
+
+describe("<ResourcesTab />", () => {
+  it("resolves the cluster name as plain text, not a link", () => {
+    render(<ResourcesTab />);
+    expect(screen.getByText("Anvil CPU")).toBeInTheDocument();
+    expect(screen.getAllByText("Anvil", { exact: true }).length).toBeGreaterThan(0);
+    expect(screen.queryByRole("link", { name: "Anvil" })).not.toBeInTheDocument();
+  });
+
+  it("filters by search text", () => {
+    render(<ResourcesTab />);
+    fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "gpu" } });
+    expect(screen.queryByText("Anvil CPU")).not.toBeInTheDocument();
+    expect(screen.getByText("Delta GPU")).toBeInTheDocument();
+  });
+
+  it("filters by the selected cluster", () => {
+    render(<ResourcesTab />);
+    fireEvent.change(screen.getByLabelText("Filter by cluster"), {
+      target: { value: "cluster-delta" },
+    });
+    expect(screen.queryByText("Anvil CPU")).not.toBeInTheDocument();
+    expect(screen.getByText("Delta GPU")).toBeInTheDocument();
+  });
+
+  it("opens the rates drawer for the chosen resource", () => {
+    render(<ResourcesTab />);
+    fireEvent.click(screen.getAllByRole("button", { name: "Rates" })[0] as HTMLElement);
+    expect(screen.getByText("rates-drawer:Anvil CPU")).toBeInTheDocument();
+  });
+
+  it("shows the empty state when no resources are registered", () => {
+    resources = [];
+    render(<ResourcesTab />);
+    expect(screen.getByRole("heading", { name: /no resources registered/i })).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/resources/__tests__/api.test.ts b/web/src/features/core/resources/__tests__/api.test.ts
new file mode 100644
index 000000000..b877fce32
--- /dev/null
+++ b/web/src/features/core/resources/__tests__/api.test.ts
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { getEffectiveRate, listResourceRates, listResources } from "../api";
+
+const fetchMock = vi.fn();
+vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+function mockResponse(status: number, body: unknown): Response {
+  const text = typeof body === "string" ? body : JSON.stringify(body);
+  return new Response(text, { status, headers: { "content-type": "application/json" } });
+}
+
+afterEach(() => {
+  fetchMock.mockReset();
+});
+
+const resource = {
+  id: "res-anvil-cpu",
+  name: "Anvil CPU",
+  resource_type: "CPU",
+  resource_amount: 1000000,
+  compute_cluster_id: "cluster-anvil",
+};
+const rate = {
+  id: "rate-1",
+  compute_allocation_resource_id: "res-anvil-cpu",
+  rate: 0.05,
+  start_time: "2020-01-01T00:00:00Z",
+  end_time: "2035-01-01T00:00:00Z",
+};
+
+describe("listResources", () => {
+  it("parses a bare array of resources", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [resource]));
+    const out = await listResources();
+    expect(out[0]?.name).toBe("Anvil CPU");
+  });
+});
+
+describe("listResourceRates", () => {
+  it("parses a bare array of rates", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [rate]));
+    const out = await listResourceRates("res-anvil-cpu");
+    expect(out[0]?.rate).toBe(0.05);
+  });
+});
+
+describe("getEffectiveRate", () => {
+  it("parses a single effective rate and appends the at query when given", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, rate));
+    await getEffectiveRate("res-anvil-cpu", "2026-06-01T00:00:00Z");
+    const url = fetchMock.mock.calls[0]?.[0] as string;
+    expect(url).toContain("/rates/effective?at=");
+  });
+});
diff --git a/web/src/mocks/handlers.ts b/web/src/features/core/resources/__tests__/queries.test.ts
similarity index 59%
copy from web/src/mocks/handlers.ts
copy to web/src/features/core/resources/__tests__/queries.test.ts
index daaf9750b..8a0a8e30c 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/features/core/resources/__tests__/queries.test.ts
@@ -15,19 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { RequestHandler } from "msw";
-import { allocationsHandlers } from "./handlers/allocations";
-import { amieHandlers } from "./handlers/amie";
-import { healthzHandlers } from "./handlers/healthz";
-import { privilegesHandlers } from "./handlers/privileges";
-import { projectsHandlers } from "./handlers/projects";
-import { tracesHandlers } from "./handlers/traces";
+import { describe, expect, it } from "vitest";
+import { resourceKeys } from "../queries";
 
-export const handlers: RequestHandler[] = [
-  ...healthzHandlers,
-  ...privilegesHandlers,
-  ...projectsHandlers,
-  ...allocationsHandlers,
-  ...tracesHandlers,
-  ...amieHandlers,
-];
+describe("resourceKeys", () => {
+  it("namespaces under 'resources'", () => {
+    expect(resourceKeys.all).toEqual(["resources"]);
+  });
+
+  it("rates and effective keys carry the id", () => {
+    expect(resourceKeys.rates("res-1")).toEqual(["resources", "rates", "res-1"]);
+    expect(resourceKeys.effective("res-1")).toEqual(["resources", "effective", "res-1"]);
+  });
+});
diff --git a/web/src/features/core/resources/__tests__/schemas.test.ts b/web/src/features/core/resources/__tests__/schemas.test.ts
new file mode 100644
index 000000000..7d5035b4e
--- /dev/null
+++ b/web/src/features/core/resources/__tests__/schemas.test.ts
@@ -0,0 +1,67 @@
+// 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 { describe, expect, it } from "vitest";
+import { computeAllocationResourceSchema, isRateActive, rateSchema, type Rate } from "../schemas";
+
+describe("computeAllocationResourceSchema", () => {
+  it("accepts a fully populated resource", () => {
+    const parsed = computeAllocationResourceSchema.parse({
+      id: "res-1",
+      name: "Anvil CPU",
+      resource_type: "CPU",
+      resource_amount: 1000,
+      compute_cluster_id: "cluster-anvil",
+    });
+    expect(parsed.name).toBe("Anvil CPU");
+  });
+
+  it("rejects a resource missing its cluster id", () => {
+    const result = computeAllocationResourceSchema.safeParse({
+      id: "res-1",
+      name: "Anvil CPU",
+      resource_type: "CPU",
+      resource_amount: 1000,
+    });
+    expect(result.success).toBe(false);
+  });
+});
+
+describe("isRateActive", () => {
+  const at = new Date("2026-06-01T00:00:00Z");
+  const rate = (start: string, end: string): Rate =>
+    rateSchema.parse({
+      id: "r",
+      compute_allocation_resource_id: "res-1",
+      rate: 0.05,
+      start_time: start,
+      end_time: end,
+    });
+
+  it("is active when the reference time is within the window", () => {
+    expect(isRateActive(rate("2020-01-01T00:00:00Z", "2035-01-01T00:00:00Z"), at)).toBe(true);
+  });
+
+  it("is inactive when the window has ended", () => {
+    expect(isRateActive(rate("2018-01-01T00:00:00Z", "2020-01-01T00:00:00Z"), at)).toBe(false);
+  });
+
+  it("is inactive at the exact end (half-open interval)", () => {
+    const end = new Date("2020-01-01T00:00:00Z");
+    expect(isRateActive(rate("2018-01-01T00:00:00Z", "2020-01-01T00:00:00Z"), end)).toBe(false);
+  });
+});
diff --git a/web/src/features/core/resources/api.ts b/web/src/features/core/resources/api.ts
new file mode 100644
index 000000000..a75773281
--- /dev/null
+++ b/web/src/features/core/resources/api.ts
@@ -0,0 +1,41 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { z } from "zod";
+import { apiFetch } from "@/shared/api/client";
+import {
+  type ComputeAllocationResource,
+  type Rate,
+  computeAllocationResourceSchema,
+  rateSchema,
+} from "./schemas";
+
+export async function listResources(): Promise<ComputeAllocationResource[]> {
+  const raw = await apiFetch("/compute-allocation-resources");
+  return z.array(computeAllocationResourceSchema).parse(raw);
+}
+
+export async function listResourceRates(id: string): Promise<Rate[]> {
+  const raw = await apiFetch(`/compute-allocation-resources/${id}/rates`);
+  return z.array(rateSchema).parse(raw);
+}
+
+export async function getEffectiveRate(id: string, at?: string): Promise<Rate> {
+  const qs = at ? `?at=${encodeURIComponent(at)}` : "";
+  const raw = await apiFetch(`/compute-allocation-resources/${id}/rates/effective${qs}`);
+  return rateSchema.parse(raw);
+}
diff --git a/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx b/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx
new file mode 100644
index 000000000..5ec208e80
--- /dev/null
+++ b/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx
@@ -0,0 +1,113 @@
+// 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 { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { CardSkeleton } from "@/shared/ui/Loading";
+import { SideDrawer } from "@/shared/ui/SideDrawer";
+import { StatusBadge } from "@/shared/ui/StatusBadge";
+import { useEffectiveRate, useResourceRates } from "../queries";
+import { isRateActive, type Rate } from "../schemas";
+
+function formatDate(iso: string): string {
+  try {
+    return new Date(iso).toLocaleDateString(undefined, {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+    });
+  } catch {
+    return iso;
+  }
+}
+
+export type ResourcesRatesDrawerProps = {
+  resourceId: string | null;
+  resourceName: string | null;
+  onOpenChange: (open: boolean) => void;
+};
+
+export function ResourcesRatesDrawer({
+  resourceId,
+  resourceName,
+  onOpenChange,
+}: ResourcesRatesDrawerProps) {
+  const rates = useResourceRates(resourceId ?? undefined);
+  const effective = useEffectiveRate(resourceId ?? undefined);
+  const history = rates.data ?? [];
+
+  return (
+    <SideDrawer
+      open={Boolean(resourceId)}
+      onOpenChange={onOpenChange}
+      title={resourceName ? `Rates: ${resourceName}` : "Rates"}
+      width="lg"
+    >
+      {effective.data ? (
+        <div className="mb-6 space-y-2 rounded-lg border border-border bg-card p-4">
+          <div className="flex items-center justify-between">
+            <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              Effective rate
+            </span>
+            <StatusBadge variant="active" label="ACTIVE" />
+          </div>
+          <p className="font-display text-2xl font-bold text-foreground">{effective.data.rate}</p>
+          <p className="text-sm text-muted-foreground">
+            {formatDate(effective.data.start_time)} to {formatDate(effective.data.end_time)}
+          </p>
+        </div>
+      ) : null}
+
+      {rates.isLoading ? (
+        <CardSkeleton />
+      ) : rates.error ? (
+        <ErrorState message={(rates.error as Error).message} onRetry={() => rates.refetch()} />
+      ) : history.length === 0 ? (
+        <EmptyState heading="No rates recorded for this resource." />
+      ) : (
+        <table className="w-full text-left text-sm">
+          <thead className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+            <tr>
+              <th className="py-2 pr-4 font-medium">Rate</th>
+              <th className="py-2 pr-4 font-medium">From</th>
+              <th className="py-2 pr-4 font-medium">To</th>
+              <th className="py-2 font-medium">Status</th>
+            </tr>
+          </thead>
+          <tbody>
+            {history.map((rate: Rate) => (
+              <tr key={rate.id} className="border-t border-border/60">
+                <td className="py-2 pr-4 font-medium text-foreground">{rate.rate}</td>
+                <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.start_time)}</td>
+                <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.end_time)}</td>
+                <td className="py-2">
+                  {isRateActive(rate) ? (
+                    <StatusBadge variant="active" label="ACTIVE" />
+                  ) : (
+                    <StatusBadge variant="expired" label="EXPIRED" />
+                  )}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      )}
+    </SideDrawer>
+  );
+}
diff --git a/web/src/features/core/resources/components/ResourcesTab.tsx b/web/src/features/core/resources/components/ResourcesTab.tsx
new file mode 100644
index 000000000..266b0a16a
--- /dev/null
+++ b/web/src/features/core/resources/components/ResourcesTab.tsx
@@ -0,0 +1,138 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import * as React from "react";
+import { useClusters } from "@/features/core/clusters/queries";
+import { Button } from "@/shared/ui/button";
+import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { EmptyState } from "@/shared/ui/EmptyState";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { Input } from "@/shared/ui/input";
+import { CardSkeleton } from "@/shared/ui/Loading";
+import { useResources } from "../queries";
+import type { ComputeAllocationResource } from "../schemas";
+import { ResourcesRatesDrawer } from "./ResourcesRatesDrawer";
+
+export function ResourcesTab() {
+  const query = useResources();
+  const clustersQuery = useClusters();
+  const [search, setSearch] = React.useState("");
+  const [clusterId, setClusterId] = React.useState("");
+  const [ratesFor, setRatesFor] = React.useState<ComputeAllocationResource | null>(null);
+
+  const clusters = clustersQuery.data ?? [];
+  const clusterName = (id: string) => clusters.find((c) => c.id === id)?.name ?? id;
+
+  const rows = query.data ?? [];
+  const filtered = React.useMemo(() => {
+    const needle = search.trim().toLowerCase();
+    return rows.filter((row) => {
+      if (clusterId && row.compute_cluster_id !== clusterId) return false;
+      if (!needle) return true;
+      return `${row.name} ${row.resource_type}`.toLowerCase().includes(needle);
+    });
+  }, [rows, search, clusterId]);
+
+  const columns: Array<DataTableColumn<ComputeAllocationResource>> = [
+    {
+      key: "name",
+      header: "Resource",
+      cell: (row) => (
+        <div>
+          <div className="font-medium text-foreground">{row.name}</div>
+          <div className="text-xs text-muted-foreground">{row.resource_type}</div>
+        </div>
+      ),
+    },
+    {
+      key: "cluster",
+      header: "Cluster",
+      cell: (row) => (
+        <span className="text-sm text-foreground">{clusterName(row.compute_cluster_id)}</span>
+      ),
+    },
+    {
+      key: "resource_amount",
+      header: "Amount",
+      align: "right",
+      cell: (row) => (
+        <span className="text-sm tabular-nums text-foreground">{row.resource_amount}</span>
+      ),
+    },
+    {
+      key: "actions",
+      header: "",
+      align: "right",
+      interactive: true,
+      cell: (row) => (
+        <Button variant="outline" size="sm" onClick={() => setRatesFor(row)}>
+          Rates
+        </Button>
+      ),
+    },
+  ];
+
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center">
+        <select
+          value={clusterId}
+          onChange={(e) => setClusterId(e.target.value)}
+          aria-label="Filter by cluster"
+          className="h-9 rounded-md border bg-background px-3 text-sm sm:w-56"
+        >
+          <option value="">All clusters</option>
+          {clusters.map((cluster) => (
+            <option key={cluster.id} value={cluster.id}>
+              {cluster.name}
+            </option>
+          ))}
+        </select>
+        <Input
+          type="search"
+          placeholder="Search resources"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          aria-label="Search resources"
+          className="sm:w-72"
+        />
+      </div>
+
+      {query.isLoading ? (
+        <CardSkeleton />
+      ) : query.error ? (
+        <ErrorState message={(query.error as Error).message} onRetry={() => query.refetch()} />
+      ) : rows.length === 0 ? (
+        <EmptyState heading="No resources registered." />
+      ) : filtered.length === 0 ? (
+        <EmptyState heading="No resources match these filters." />
+      ) : (
+        <DataTable columns={columns} rows={filtered} rowKey={(row) => row.id} />
+      )}
+
+      <ResourcesRatesDrawer
+        resourceId={ratesFor?.id ?? null}
+        resourceName={ratesFor?.name ?? null}
+        onOpenChange={(open) => {
+          if (!open) setRatesFor(null);
+        }}
+      />
+    </div>
+  );
+}
diff --git a/web/src/features/core/resources/queries.ts b/web/src/features/core/resources/queries.ts
new file mode 100644
index 000000000..fab27aae7
--- /dev/null
+++ b/web/src/features/core/resources/queries.ts
@@ -0,0 +1,61 @@
+// 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 { useQuery } from "@tanstack/react-query";
+import { getEffectiveRate, listResourceRates, listResources } from "./api";
+
+export const resourceKeys = {
+  all: ["resources"] as const,
+  list: () => [...resourceKeys.all, "list"] as const,
+  rates: (id: string) => [...resourceKeys.all, "rates", id] as const,
+  effective: (id: string) => [...resourceKeys.all, "effective", id] as const,
+};
+
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
+export function useResources() {
+  return useQuery({
+    queryKey: resourceKeys.list(),
+    queryFn: listResources,
+    ...DEFAULTS,
+  });
+}
+
+export function useResourceRates(id: string | undefined) {
+  return useQuery({
+    queryKey: id ? resourceKeys.rates(id) : [...resourceKeys.all, "rates", "none"],
+    queryFn: () => listResourceRates(id as string),
+    enabled: Boolean(id),
+    ...DEFAULTS,
+  });
+}
+
+export function useEffectiveRate(id: string | undefined) {
+  return useQuery({
+    queryKey: id ? resourceKeys.effective(id) : [...resourceKeys.all, "effective", "none"],
+    queryFn: () => getEffectiveRate(id as string),
+    enabled: Boolean(id),
+    retry: false,
+    ...DEFAULTS,
+  });
+}
diff --git a/web/src/features/core/resources/schemas.ts b/web/src/features/core/resources/schemas.ts
new file mode 100644
index 000000000..ae1312adc
--- /dev/null
+++ b/web/src/features/core/resources/schemas.ts
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import type { z } from "zod";
+import {
+  zComputeAllocationResource,
+  zComputeAllocationResourceRate,
+} from "@/generated/core/zod.gen";
+
+// The generated schema marks every field optional; the backend always returns
+// these, so tighten them here where the UI reads them.
+export const computeAllocationResourceSchema = zComputeAllocationResource.required({
+  id: true,
+  name: true,
+  resource_type: true,
+  resource_amount: true,
+  compute_cluster_id: true,
+});
+export type ComputeAllocationResource = z.infer<typeof computeAllocationResourceSchema>;
+
+export const rateSchema = zComputeAllocationResourceRate.required({
+  id: true,
+  compute_allocation_resource_id: true,
+  rate: true,
+  start_time: true,
+  end_time: true,
+});
+export type Rate = z.infer<typeof rateSchema>;
+
+// A rate is active when now falls within [start_time, end_time).
+export function isRateActive(rate: Rate, at: Date = new Date()): boolean {
+  const start = new Date(rate.start_time).getTime();
+  const end = new Date(rate.end_time).getTime();
+  const now = at.getTime();
+  return now >= start && now < end;
+}
diff --git a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
index 4093fe661..9780782b9 100644
--- a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
+++ b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
@@ -21,6 +21,20 @@ import { describe, expect, it } from "vitest";
 import { z } from "zod";
 import allocationsFixture from "@/features/core/allocations/__fixtures__/allocations.json";
 import { allocationStatusSchema } from "@/features/core/allocations/schemas";
+import clusterUsersFixture from "@/features/core/clusters/__fixtures__/cluster-users.json";
+import clustersFixture from "@/features/core/clusters/__fixtures__/clusters.json";
+import {
+  computeClusterSchema,
+  computeClusterUserSchema,
+} from "@/features/core/clusters/schemas";
+import organizationsFixture from "@/features/core/organizations/__fixtures__/organizations.json";
+import { organizationSchema } from "@/features/core/organizations/schemas";
+import ratesFixture from "@/features/core/resources/__fixtures__/rates.json";
+import resourcesFixture from "@/features/core/resources/__fixtures__/resources.json";
+import {
+  computeAllocationResourceSchema,
+  rateSchema,
+} from "@/features/core/resources/schemas";
 import membersFixture from "@/features/core/projects/__fixtures__/members.json";
 import projectsFixture from "@/features/core/projects/__fixtures__/projects.json";
 import { projectMemberSchema, projectSchema } from "@/features/core/projects/schemas";
@@ -39,9 +53,34 @@ describe("MSW fixtures pass current Zod schemas", () => {
     expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
   });
 
+  it("organization fixtures validate against organizationSchema", () => {
+    const result = z.array(organizationSchema).safeParse(organizationsFixture);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
   it("allocation status fixture values validate against zAllocationStatus-backed enum", () => {
     const statuses = (allocationsFixture as Array<{ status: string }>).map((a) => a.status);
     const result = z.array(allocationStatusSchema).safeParse(statuses);
     expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
   });
+
+  it("cluster fixtures validate against computeClusterSchema", () => {
+    const result = z.array(computeClusterSchema).safeParse(clustersFixture);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("cluster user fixtures validate against computeClusterUserSchema", () => {
+    const result = z.array(computeClusterUserSchema).safeParse(clusterUsersFixture);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("resource fixtures validate against computeAllocationResourceSchema", () => {
+    const result = z.array(computeAllocationResourceSchema).safeParse(resourcesFixture);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("rate fixtures validate against rateSchema", () => {
+    const result = z.array(rateSchema).safeParse(ratesFixture);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
 });
diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts
index daaf9750b..25ba2bf00 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/mocks/handlers.ts
@@ -18,16 +18,22 @@
 import type { RequestHandler } from "msw";
 import { allocationsHandlers } from "./handlers/allocations";
 import { amieHandlers } from "./handlers/amie";
+import { clustersHandlers } from "./handlers/clusters";
 import { healthzHandlers } from "./handlers/healthz";
+import { organizationsHandlers } from "./handlers/organizations";
 import { privilegesHandlers } from "./handlers/privileges";
 import { projectsHandlers } from "./handlers/projects";
+import { resourcesHandlers } from "./handlers/resources";
 import { tracesHandlers } from "./handlers/traces";
 
 export const handlers: RequestHandler[] = [
   ...healthzHandlers,
   ...privilegesHandlers,
   ...projectsHandlers,
+  ...organizationsHandlers,
   ...allocationsHandlers,
   ...tracesHandlers,
   ...amieHandlers,
+  ...clustersHandlers,
+  ...resourcesHandlers,
 ];
diff --git a/web/src/mocks/handlers/clusters.ts b/web/src/mocks/handlers/clusters.ts
new file mode 100644
index 000000000..f1288b501
--- /dev/null
+++ b/web/src/mocks/handlers/clusters.ts
@@ -0,0 +1,45 @@
+// 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 { http, HttpResponse } from "msw";
+import clusterUsersFixture from "@/features/core/clusters/__fixtures__/cluster-users.json";
+import clustersFixture from "@/features/core/clusters/__fixtures__/clusters.json";
+import type { ComputeCluster, ComputeClusterUser } from "@/features/core/clusters/schemas";
+
+const clusters = clustersFixture as ComputeCluster[];
+const clusterUsers = clusterUsersFixture as ComputeClusterUser[];
+
+export const clustersHandlers = [
+  http.get("*/api/v1/compute-clusters", () => HttpResponse.json(clusters)),
+
+  http.get("*/api/v1/compute-clusters/:id", ({ params }) => {
+    const id = String(params.id);
+    const found = clusters.find((c) => c.id === id);
+    if (!found) return HttpResponse.json({ error: "cluster not found" }, { status: 404 });
+    return HttpResponse.json(found);
+  }),
+
+  http.get("*/api/v1/compute-clusters/:id/users", ({ params }) => {
+    const id = String(params.id);
+    return HttpResponse.json(clusterUsers.filter((u) => u.compute_cluster_id === id));
+  }),
+
+  http.get("*/api/v1/users/:id/compute-cluster-users", ({ params }) => {
+    const id = String(params.id);
+    return HttpResponse.json(clusterUsers.filter((u) => u.user_id === id));
+  }),
+];
diff --git a/web/src/mocks/handlers/organizations.ts b/web/src/mocks/handlers/organizations.ts
new file mode 100644
index 000000000..e9c016a7a
--- /dev/null
+++ b/web/src/mocks/handlers/organizations.ts
@@ -0,0 +1,55 @@
+// 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 { http, HttpResponse } from "msw";
+import type {
+  CreateOrganizationPayload,
+  Organization,
+} from "@/features/core/organizations/schemas";
+import organizationsFixture from "@/features/core/organizations/__fixtures__/organizations.json";
+
+const organizations: Organization[] = (organizationsFixture as Organization[]).map((o) => ({
+  ...o,
+}));
+
+export const organizationsHandlers = [
+  http.get("*/api/v1/organizations", ({ request }) => {
+    const url = new URL(request.url);
+    const limit = Number(url.searchParams.get("limit") ?? organizations.length);
+    const offset = Number(url.searchParams.get("offset") ?? 0);
+    const items = organizations.slice(offset, offset + limit);
+    return HttpResponse.json({ items, total: organizations.length });
+  }),
+
+  http.get("*/api/v1/organizations/:id", ({ params }) => {
+    const id = String(params.id);
+    const found = organizations.find((o) => o.id === id);
+    if (!found) return HttpResponse.json({ error: "organization not found" }, { status: 404 });
+    return HttpResponse.json(found);
+  }),
+
+  http.post("*/api/v1/organizations", async ({ request }) => {
+    const payload = (await request.json()) as CreateOrganizationPayload;
+    const organization: Organization = {
+      id: `org-${Date.now()}`,
+      name: payload.name,
+      originated_id: payload.originated_id ?? "",
+    };
+    organizations.unshift(organization);
+    return HttpResponse.json(organization, { status: 201 });
+  }),
+];
diff --git a/web/src/mocks/handlers/privileges.ts b/web/src/mocks/handlers/privileges.ts
index 31186d68a..48f3fe4fd 100644
--- a/web/src/mocks/handlers/privileges.ts
+++ b/web/src/mocks/handlers/privileges.ts
@@ -52,11 +52,26 @@ const MOCK_USER = {
   type: "CLUSTER_LOCAL",
 };
 
+// Test seam: e2e scopes privileges per persona via a non-httpOnly cookie.
+// Unset (MSW-only browsing, unit tests) falls back to full access.
+function effectivePrivileges(): Privilege[] {
+  if (typeof document === "undefined") return ALL_PRIVILEGES;
+  const match = document.cookie
+    .split("; ")
+    .find((c) => c.startsWith("custos.test-privileges="));
+  if (!match) return ALL_PRIVILEGES;
+  const list = decodeURIComponent(match.slice("custos.test-privileges=".length))
+    .split(",")
+    .map((s) => s.trim())
+    .filter(Boolean);
+  return list.length > 0 ? (list as Privilege[]) : ALL_PRIVILEGES;
+}
+
 export const privilegesHandlers = [
   http.get("*/api/v1/user/privileges", () =>
-    HttpResponse.json({ privileges: ALL_PRIVILEGES }),
+    HttpResponse.json({ privileges: effectivePrivileges() }),
   ),
   http.get("*/api/v1/me", () =>
-    HttpResponse.json({ user: MOCK_USER, privileges: ALL_PRIVILEGES }),
+    HttpResponse.json({ user: MOCK_USER, privileges: effectivePrivileges() }),
   ),
 ];
diff --git a/web/src/mocks/handlers/resources.ts b/web/src/mocks/handlers/resources.ts
new file mode 100644
index 000000000..d568f3036
--- /dev/null
+++ b/web/src/mocks/handlers/resources.ts
@@ -0,0 +1,48 @@
+// 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 { http, HttpResponse } from "msw";
+import ratesFixture from "@/features/core/resources/__fixtures__/rates.json";
+import resourcesFixture from "@/features/core/resources/__fixtures__/resources.json";
+import type { ComputeAllocationResource, Rate } from "@/features/core/resources/schemas";
+
+const resources = resourcesFixture as ComputeAllocationResource[];
+const rates = ratesFixture as Rate[];
+
+function ratesFor(resourceId: string): Rate[] {
+  return rates.filter((r) => r.compute_allocation_resource_id === resourceId);
+}
+
+export const resourcesHandlers = [
+  http.get("*/api/v1/compute-allocation-resources", () => HttpResponse.json(resources)),
+
+  http.get("*/api/v1/compute-allocation-resources/:id/rates/effective", ({ params, request }) => {
+    const id = String(params.id);
+    const at = new URL(request.url).searchParams.get("at");
+    const now = at ? new Date(at).getTime() : Date.now();
+    const active = ratesFor(id).find(
+      (r) => now >= new Date(r.start_time).getTime() && now < new Date(r.end_time).getTime(),
+    );
+    if (!active) return HttpResponse.json({ error: "no effective rate" }, { status: 404 });
+    return HttpResponse.json(active);
+  }),
+
+  http.get("*/api/v1/compute-allocation-resources/:id/rates", ({ params }) => {
+    const id = String(params.id);
+    return HttpResponse.json(ratesFor(id));
+  }),
+];
diff --git a/web/src/shared/api/__tests__/client.test.ts b/web/src/shared/api/__tests__/client.test.ts
index 7d698efb5..62a6218fa 100644
--- a/web/src/shared/api/__tests__/client.test.ts
+++ b/web/src/shared/api/__tests__/client.test.ts
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { apiFetch } from "@/shared/api/client";
+import { ApiError, apiFetch } from "@/shared/api/client";
 import { getLastTraceId, recordTraceId } from "@/shared/api/last-trace-id";
 import { http, HttpResponse } from "msw";
 import { setupServer } from "msw/node";
@@ -28,6 +28,13 @@ const baseHandlers = [
     HttpResponse.json({ status: "ok" }, { headers: { "x-trace-id": TRACE_ID } }),
   ),
   http.get("*/api/v1/example", () => HttpResponse.json({ ok: true })),
+  http.delete("*/api/v1/grant", async ({ request }) => HttpResponse.json(await request.json())),
+  http.delete("*/api/v1/users/u1/privileges/last", () =>
+    HttpResponse.json(
+      { error: "cannot remove the last holder of core:roles:manage" },
+      { status: 409 },
+    ),
+  ),
 ];
 
 const server = setupServer(...baseHandlers);
@@ -53,4 +60,23 @@ describe("apiFetch", () => {
     await apiFetch("/example");
     expect(getLastTraceId()).toBe("previous-id");
   });
+
+  it("sends a JSON body on DELETE requests", async () => {
+    const body = await apiFetch<{ reason: string }>("/grant", {
+      method: "DELETE",
+      body: { reason: "no longer needed" },
+    });
+    expect(body).toEqual({ reason: "no longer needed" });
+  });
+
+  it("derives the ApiError message from the body's error field", async () => {
+    const error = await apiFetch("/users/u1/privileges/last", { method: "DELETE" }).catch(
+      (e) => e,
+    );
+    expect(error).toBeInstanceOf(ApiError);
+    expect((error as ApiError).message).toBe(
+      "cannot remove the last holder of core:roles:manage",
+    );
+    expect((error as ApiError).status).toBe(409);
+  });
 });
diff --git a/web/src/shared/api/client.ts b/web/src/shared/api/client.ts
index 84b6af587..96287cb62 100644
--- a/web/src/shared/api/client.ts
+++ b/web/src/shared/api/client.ts
@@ -59,6 +59,16 @@ async function parseBody(response: Response): Promise<unknown> {
   }
 }
 
+// The backend reports errors as {"error": "..."}; surface that text so dialogs
+// show the guard message instead of a generic "API 409 on ...".
+function errorMessageFromBody(body: unknown): string | undefined {
+  if (body && typeof body === "object" && "error" in body) {
+    const { error } = body as { error: unknown };
+    if (typeof error === "string" && error.length > 0) return error;
+  }
+  return undefined;
+}
+
 export async function apiFetch<T = unknown>(path: string, init: ApiFetchInit = {}): Promise<T> {
   const url = buildUrl(path);
   const headers = new Headers(init.headers);
@@ -86,7 +96,7 @@ export async function apiFetch<T = unknown>(path: string, init: ApiFetchInit = {
   const parsed = await parseBody(response);
 
   if (!response.ok) {
-    throw new ApiError(response.status, url, parsed);
+    throw new ApiError(response.status, url, parsed, errorMessageFromBody(parsed));
   }
   return parsed as T;
 }
diff --git a/web/src/shared/layout/__tests__/Sidebar.test.tsx b/web/src/shared/layout/__tests__/Sidebar.test.tsx
new file mode 100644
index 000000000..71b5f2f13
--- /dev/null
+++ b/web/src/shared/layout/__tests__/Sidebar.test.tsx
@@ -0,0 +1,61 @@
+// 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 { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Privilege } from "@/features/core/identity/types";
+import { defineAbilitiesFor } from "@/shared/casl/abilities";
+import { Sidebar } from "../Sidebar";
+
+let currentPrivileges: Privilege[] = [];
+
+vi.mock("@/shared/casl/AbilityProvider", () => ({
+  useAbility: () => defineAbilitiesFor(currentPrivileges),
+}));
+
+vi.mock("next/navigation", () => ({
+  usePathname: () => "/",
+}));
+
+const NEW_ADMIN_LABELS = ["Organizations", "Resources"];
+
+const FULL_PRIVILEGES: Privilege[] = [
+  "core:organizations:read",
+  "core:clusters:read",
+];
+
+beforeEach(() => {
+  currentPrivileges = [];
+});
+
+describe("Sidebar admin entries", () => {
+  it("shows the merged admin entries for a full-privilege ability", () => {
+    currentPrivileges = FULL_PRIVILEGES;
+    render(<Sidebar />);
+    for (const label of NEW_ADMIN_LABELS) {
+      expect(screen.getByRole("link", { name: label })).toBeInTheDocument();
+    }
+  });
+
+  it("hides them when the ability lacks the mapped privileges", () => {
+    currentPrivileges = [];
+    render(<Sidebar />);
+    for (const label of NEW_ADMIN_LABELS) {
+      expect(screen.queryByRole("link", { name: label })).toBeNull();
+    }
+  });
+});
diff --git a/web/src/shared/layout/nav.ts b/web/src/shared/layout/nav.ts
index 36959294a..9934686b3 100644
--- a/web/src/shared/layout/nav.ts
+++ b/web/src/shared/layout/nav.ts
@@ -17,8 +17,10 @@
 
 import {
   Activity,
+  Building2,
   ClipboardList,
   FolderKanban,
+  HardDrive,
   Server,
   type LucideIcon,
 } from "lucide-react";
@@ -69,4 +71,18 @@ export const portalNav: NavItem[] = [
     group: "admin",
     ability: { action: "read", subject: "AMIE" },
   },
+  {
+    href: "/admin/organizations",
+    label: "Organizations",
+    icon: Building2,
+    group: "admin",
+    ability: { action: "read", subject: "Organization" },
+  },
+  {
+    href: "/admin/resources",
+    label: "Resources",
+    icon: HardDrive,
+    group: "admin",
+    ability: { action: "read", subject: "Cluster" },
+  },
 ];
diff --git a/web/tests/admin-organizations.e2e.ts b/web/tests/admin-organizations.e2e.ts
new file mode 100644
index 000000000..95f44db1e
--- /dev/null
+++ b/web/tests/admin-organizations.e2e.ts
@@ -0,0 +1,37 @@
+// 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 { expect, test } from "@playwright/test";
+import { signInAs } from "./fixtures/auth";
+
+test.describe("admin organizations", () => {
+  test("creates an organization and shows the new row", async ({ page }) => {
+    await signInAs(page, "admin");
+    await page.goto("/admin/organizations");
+
+    await expect(page.getByRole("heading", { name: /^Organizations$/ })).toBeVisible({
+      timeout: 20_000,
+    });
+
+    await page.getByRole("button", { name: "+ Create organization" }).click();
+    await expect(page.getByRole("heading", { name: /^Create organization$/ })).toBeVisible();
+    await page.getByLabel("Name").fill("Test University");
+    await page.getByRole("button", { name: "Create organization", exact: true }).click();
+
+    await expect(page.getByText("Test University")).toBeVisible({ timeout: 15_000 });
+  });
+});
diff --git a/web/tests/admin-read-only.e2e.ts b/web/tests/admin-read-only.e2e.ts
new file mode 100644
index 000000000..3d96e7956
--- /dev/null
+++ b/web/tests/admin-read-only.e2e.ts
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { expect, test } from "@playwright/test";
+import { signInAs } from "./fixtures/auth";
+
+test.describe("admin resources & clusters read-only sweep", () => {
+  test("clusters tab opens a drawer with local users", async ({ page }) => {
+    await signInAs(page, "admin");
+    await page.goto("/admin/resources");
+    await expect(page.getByRole("heading", { name: /Resources & Clusters/ })).toBeVisible({
+      timeout: 20_000,
+    });
+
+    // Clusters is the default tab.
+    await page.getByRole("button", { name: "Anvil", exact: true }).click();
+    await expect(page.getByText("Cluster: Anvil")).toBeVisible({ timeout: 15_000 });
+    await expect(page.getByText("ghopper")).toBeVisible();
+  });
+
+  test("resources tab opens a rates drawer with rate rows", async ({ page }) => {
+    await signInAs(page, "admin");
+    await page.goto("/admin/resources");
+    await expect(page.getByRole("heading", { name: /Resources & Clusters/ })).toBeVisible({
+      timeout: 20_000,
+    });
+
+    await page.getByRole("tab", { name: /^resources$/i }).click();
+    await page
+      .getByRole("row", { name: /Anvil CPU/ })
+      .getByRole("button", { name: "Rates" })
+      .click();
+
+    await expect(page.getByText("Rates: Anvil CPU")).toBeVisible({ timeout: 15_000 });
+    // History carries the current rate plus the superseded one (client-derived).
+    await expect(page.getByText("EXPIRED")).toBeVisible();
+    await expect(page.getByText("0.04")).toBeVisible();
+  });
+});
diff --git a/web/tests/admin-viewer.e2e.ts b/web/tests/admin-viewer.e2e.ts
new file mode 100644
index 000000000..e301986a3
--- /dev/null
+++ b/web/tests/admin-viewer.e2e.ts
@@ -0,0 +1,45 @@
+// 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 { expect, test } from "@playwright/test";
+import { signInAs } from "./fixtures/auth";
+
+test.describe("admin viewer persona", () => {
+  test("sees read-only surfaces with no mutation UI or dropped nav", async ({ page }) => {
+    await signInAs(page, "viewer");
+    await page.goto("/admin/organizations");
+
+    await expect(page.getByRole("heading", { name: /^Organizations$/ })).toBeVisible({
+      timeout: 20_000,
+    });
+    // Read-only viewer lacks manage Organization, so no create affordance.
+    await expect(page.getByRole("button", { name: "+ Create organization" })).toHaveCount(0);
+
+    // The dropped areas have no nav entry for anyone now.
+    await expect(page.getByRole("link", { name: "Users" })).toHaveCount(0);
+    await expect(page.getByRole("link", { name: "Roles & Privileges" })).toHaveCount(0);
+    await expect(page.getByRole("link", { name: "Clusters" })).toHaveCount(0);
+    // Read entries the viewer is entitled to remain.
+    await expect(page.getByRole("link", { name: "Organizations" })).toBeVisible();
+    await expect(page.getByRole("link", { name: "Resources" })).toBeVisible();
+
+    await page.goto("/admin/resources");
+    await expect(page.getByRole("heading", { name: /Resources & Clusters/ })).toBeVisible();
+    await expect(page.getByRole("tab", { name: /^clusters$/i })).toBeVisible();
+    await expect(page.getByRole("tab", { name: /^resources$/i })).toBeVisible();
+  });
+});
diff --git a/web/tests/allocation-members-edit.e2e.ts b/web/tests/allocation-members-edit.e2e.ts
index c6ecf0265..584bb8027 100644
--- a/web/tests/allocation-members-edit.e2e.ts
+++ b/web/tests/allocation-members-edit.e2e.ts
@@ -39,7 +39,7 @@ test.describe("allocation members tab", () => {
     const editButtons = page.getByRole("button", { name: /^edit /i });
     await editButtons.first().click();
     await expect(page.getByRole("heading", { name: /^Edit member$/i })).toBeVisible();
-    await page.getByLabel(/^role$/i).selectOption("co_pi");
+    await page.getByLabel(/^role$/i).selectOption("CO_PI");
     await page.getByRole("button", { name: /^save$/i }).click();
     await expect(page.getByRole("heading", { name: /^Edit member$/i })).toBeHidden({
       timeout: 15_000,
diff --git a/web/tests/fixtures/auth.ts b/web/tests/fixtures/auth.ts
index c0746d873..1a64424a5 100644
--- a/web/tests/fixtures/auth.ts
+++ b/web/tests/fixtures/auth.ts
@@ -23,18 +23,47 @@ import { encode } from "next-auth/jwt";
 
 export type Persona = "viewer" | "manager" | "admin";
 
+// Real privilege keys so browser abilities (PRIVILEGE_ABILITY_MAP) and the nav
+// gates resolve per persona. The MSW /user/privileges handler reads the
+// matching test-privileges cookie set below.
 const PRIVILEGES: Record<Persona, string[]> = {
-  viewer: ["hpc:read"],
-  manager: ["hpc:read", "hpc:write", "amie:read"],
+  viewer: [
+    "core:users:read",
+    "core:organizations:read",
+    "core:clusters:read",
+    "core:allocations:read",
+  ],
+  manager: [
+    "core:allocations:read",
+    "core:allocations:write",
+    "core:projects:read",
+    "core:projects:write",
+    "core:clusters:read",
+    "core:users:read",
+    "core:organizations:read",
+    "amie:packets:read",
+    "amie:replies:read",
+  ],
   admin: [
-    "amie:read",
-    "amie:write",
-    "hpc:read",
-    "hpc:write",
-    "signer:read",
-    "signer:write",
-    "privileges:grant",
-    "roles:manage",
+    "core:clusters:read",
+    "core:clusters:write",
+    "core:allocations:read",
+    "core:allocations:write",
+    "core:projects:read",
+    "core:projects:write",
+    "core:users:read",
+    "core:users:write",
+    "core:organizations:read",
+    "core:organizations:write",
+    "core:traces:read",
+    "core:privileges:grant",
+    "core:roles:manage",
+    "amie:packets:read",
+    "amie:packets:write",
+    "amie:replies:read",
+    "amie:replies:write",
+    "amie:unmapped:read",
+    "amie:unmapped:write",
   ],
 };
 
@@ -63,14 +92,25 @@ export async function signInAs(page: Page, persona: Persona = "admin") {
   });
 
   const port = process.env.PORT ?? "3217";
+  const url = `http://localhost:${port}`;
   await page.context().addCookies([
     {
       name: "custos.session-token",
       value: token,
-      url: `http://localhost:${port}`,
+      url,
       httpOnly: true,
       secure: false,
       sameSite: "Lax",
     },
+    // Non-httpOnly so the browser-context MSW /user/privileges handler can read
+    // it and scope abilities to this persona.
+    {
+      name: "custos.test-privileges",
+      value: PRIVILEGES[persona].join(","),
+      url,
+      httpOnly: false,
+      secure: false,
+      sameSite: "Lax",
+    },
   ]);
 }


(airavata-custos) 03/08: Sample data for projects, clusters, and resources

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

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

commit 80c30c745c63b7bcc118863de99584e0a1e1db67
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 02:31:58 2026 -0400

    Sample data for projects, clusters, and resources
---
 dev-ops/compose/seeds/sample_projects.sql  | 654 +++++++++++++++++++++++++++++
 dev-ops/compose/seeds/sample_resources.sql |  59 +++
 2 files changed, 713 insertions(+)

diff --git a/dev-ops/compose/seeds/sample_projects.sql b/dev-ops/compose/seeds/sample_projects.sql
new file mode 100644
index 000000000..b7c4827d8
--- /dev/null
+++ b/dev-ops/compose/seeds/sample_projects.sql
@@ -0,0 +1,654 @@
+-- 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. Licensed
+-- under the Apache License, Version 2.0.
+--
+-- OPTIONAL sample data: ~20 research projects with allocations, members,
+-- usage history, and change requests, for portal demos and manual testing.
+-- Not required for a working deployment. Apply after migrations and
+-- default_cluster.sql. Idempotent via INSERT IGNORE + deterministic IDs.
+
+USE custos;
+
+INSERT IGNORE INTO organizations (id, originated_id, name) VALUES
+  ('sample-org', 'sample-org', 'Sample Research Consortium');
+
+INSERT IGNORE INTO compute_allocation_resources (id, name, resource_type, resource_amount, compute_cluster_id) VALUES
+  ('res-cpu', 'CPU Service Units', 'CPU_HOURS', 1000000, '00000000-0000-0000-0000-000000000001'),
+  ('res-gpu', 'GPU Service Units', 'GPU_HOURS', 100000, '00000000-0000-0000-0000-000000000001');
+
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-344b252c9dd481944db0', 'sample-org', 'Elena', 'Vasquez', '', 'elena.vasquez@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-c8e6b563ac8f348d6069', 'sample-org', 'Marcus', 'Chen', '', 'marcus.chen@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-2fd62ff24cea6be8de4c', 'sample-org', 'Priya', 'Raghavan', '', 'priya.raghavan@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-bc08d052551918510023', 'sample-org', 'Tomás', 'Silva', '', 'tomas.silva@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-b800fa9f20c414f38e44', 'sample-org', 'Aisha', 'Okafor', '', 'aisha.okafor@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-3cd6d104b01ef129b379', 'sample-org', 'David', 'Kim', '', 'david.kim@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-90f9c3a872e74ceb9b54', 'sample-org', 'Sarah', 'Lindqvist', '', 'sarah.lindqvist@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-38b35e0e4b22d0d6460a', 'sample-org', 'Rajesh', 'Patel', '', 'rajesh.patel@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-26420ea0545ca72a32b3', 'sample-org', 'Mei', 'Zhang', '', 'mei.zhang@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-3b164a9395896373810e', 'sample-org', 'James', 'O''Brien', '', 'james.obrien@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-626aede3f65aeb969c74', 'sample-org', 'Fatima', 'Hassan', '', 'fatima.hassan@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-4907af7ab30bd84e6deb', 'sample-org', 'Lukas', 'Weber', '', 'lukas.weber@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-ae4c14e3cd98f58c2363', 'sample-org', 'Ana', 'Ferreira', '', 'ana.ferreira@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-343175c9b7007d1ff0f8', 'sample-org', 'Hiro', 'Tanaka', '', 'hiro.tanaka@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-f908bd32e57fd5c2896d', 'sample-org', 'Nadia', 'Petrova', '', 'nadia.petrova@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-f6343c23c6a7028f8ed7', 'sample-org', 'Carlos', 'Mendoza', '', 'carlos.mendoza@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-fa5b4cdf77ce78bd27e0', 'sample-org', 'Ingrid', 'Johansson', '', 'ingrid.johansson@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-598aba3fd25a5b74a2cf', 'sample-org', 'Kwame', 'Asante', '', 'kwame.asante@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-35b28b0dfed3e7dfcd36', 'sample-org', 'Leila', 'Nasser', '', 'leila.nasser@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-02b1cd6cafbd27dd7201', 'sample-org', 'Viktor', 'Novak', '', 'viktor.novak@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-b8e82843b10d1a15b995', 'sample-org', 'Grace', 'Adeyemi', '', 'grace.adeyemi@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-318427dda738e0504620', 'sample-org', 'Sven', 'Eriksson', '', 'sven.eriksson@sample.example.edu', 'PENDING', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-52fae2d4d9ff23760057', 'sample-org', 'Rosa', 'Delgado', '', 'rosa.delgado@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smp-8d36f8ec62cc1ab62fd5', 'sample-org', 'Arjun', 'Menon', '', 'arjun.menon@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+
+-- CHM240012: Ab Initio Screening of Transition-Metal Catalysts
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-4c8ab475ba01ce29fb67', 'CHM240012', 'Ab Initio Screening of Transition-Metal Catalysts', 'access', 'smp-344b252c9dd481944db0', 'INACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-e874cc69f25dfd4b8d9b', 'smp-4c8ab475ba01ce29fb67', 'CHM240012 startup allocation', 'INACTIVE', '00000000-0000-0000-0000-000000000001', 500000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-4c8ab475ba01ce29fb67', 'smp-344b252c9dd481944db0', 'PI'),
+  ('smp-4c8ab475ba01ce29fb67', 'smp-3cd6d104b01ef129b379', 'CO_PI'),
+  ('smp-4c8ab475ba01ce29fb67', 'smp-4907af7ab30bd84e6deb', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-03f67ac7ec854e58822e', 'smp-e874cc69f25dfd4b8d9b', 'smp-344b252c9dd481944db0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-00ecfa5a2f215d673736', 'smp-e874cc69f25dfd4b8d9b', 'smp-3cd6d104b01ef129b379', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-4fe04464197b2f2fa95c', 'smp-e874cc69f25dfd4b8d9b', 'smp-2fd62ff24cea6be8de4c', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-5e607b81210b9bed2acd', 'smp-e874cc69f25dfd4b8d9b', 'smp-38b35e0e4b22d0d6460a', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-71cf0b7ab64664364795', 'smp-e874cc69f25dfd4b8d9b', 'smp-343175c9b7007d1ff0f8', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-cb33ed122c36262d7ce3', 'smp-e874cc69f25dfd4b8d9b', 'res-cpu', 5000, 3600),
+  ('smp-7baff5fb5af1b937d1c2', 'smp-e874cc69f25dfd4b8d9b', 'res-gpu', 250, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-530ad0d71ddc54f4b616', 'smp-e874cc69f25dfd4b8d9b', 25122, 25122, '2026-02-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-chm240012-02', 'res-cpu'),
+  ('smp-105c2ba6727a17d131e0', 'smp-e874cc69f25dfd4b8d9b', 25244, 25244, '2026-04-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-chm240012-04', 'res-cpu'),
+  ('smp-d66812cbdfc4d5f9754a', 'smp-e874cc69f25dfd4b8d9b', 25366, 25366, '2026-06-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-chm240012-06', 'res-cpu'),
+  ('smp-5222a7b3a5ed5aa06b6b', 'smp-e874cc69f25dfd4b8d9b', 25259, 25259, '2026-02-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-chm240012-12', 'res-cpu'),
+  ('smp-ae21cf27ab83fab933e9', 'smp-e874cc69f25dfd4b8d9b', 25381, 25381, '2026-04-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-chm240012-14', 'res-cpu'),
+  ('smp-6826a5eafd15dcd7cbd1', 'smp-e874cc69f25dfd4b8d9b', 25503, 25503, '2026-06-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-chm240012-16', 'res-cpu'),
+  ('smp-6a4ce2db6e7755a2f0f0', 'smp-e874cc69f25dfd4b8d9b', 25396, 25396, '2026-02-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-chm240012-22', 'res-cpu'),
+  ('smp-de8cfe7a9daa94af63dd', 'smp-e874cc69f25dfd4b8d9b', 25518, 25518, '2026-04-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-chm240012-24', 'res-cpu'),
+  ('smp-bab3e2728afe00548e12', 'smp-e874cc69f25dfd4b8d9b', 25640, 25640, '2026-06-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-chm240012-26', 'res-cpu');
+INSERT IGNORE INTO compute_allocation_change_requests (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) VALUES
+  ('smp-bee39eb5dcf4d798dadd', 'smp-e874cc69f25dfd4b8d9b', 250000, 'ACTIVE', 'Supplement for CHM240012 production campaign', 'PENDING', 'smp-344b252c9dd481944db0', '', '2026-06-20 09:00:00');
+INSERT IGNORE INTO compute_allocation_change_request_events (id, compute_allocation_change_request_id, event_type, description, timestamp) VALUES
+  ('smp-bee39eb5dcf4d798dadd-e1', 'smp-bee39eb5dcf4d798dadd', 'CREATED', 'Change request submitted by PI.', '2026-06-20 09:00:00');
+
+-- PHY240034: Lattice QCD at the Physical Point
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-eedc3ed54172fa1539ee', 'PHY240034', 'Lattice QCD at the Physical Point', 'access', 'smp-c8e6b563ac8f348d6069', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-d29064eb686184d12cbe', 'smp-eedc3ed54172fa1539ee', 'PHY240034 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 250000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-eedc3ed54172fa1539ee', 'smp-c8e6b563ac8f348d6069', 'PI'),
+  ('smp-eedc3ed54172fa1539ee', 'smp-90f9c3a872e74ceb9b54', 'CO_PI'),
+  ('smp-eedc3ed54172fa1539ee', 'smp-ae4c14e3cd98f58c2363', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-dff1bea8f3c9fc390dc2', 'smp-d29064eb686184d12cbe', 'smp-c8e6b563ac8f348d6069', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7e3a683d1b228d3fd397', 'smp-d29064eb686184d12cbe', 'smp-90f9c3a872e74ceb9b54', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-781a6da2739d158a9381', 'smp-d29064eb686184d12cbe', 'smp-bc08d052551918510023', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-11ef2821527195380666', 'smp-d29064eb686184d12cbe', 'smp-26420ea0545ca72a32b3', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-8f07e38746a089ac45ce', 'smp-d29064eb686184d12cbe', 'smp-f908bd32e57fd5c2896d', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-81ce9b9915d4330c9618', 'smp-d29064eb686184d12cbe', 'res-cpu', 2500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-93a9297f42034c4f6c54', 'smp-d29064eb686184d12cbe', 12622, 12622, '2026-02-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-phy240034-02', 'res-cpu'),
+  ('smp-182f0db7f97cb88e60ca', 'smp-d29064eb686184d12cbe', 12744, 12744, '2026-04-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-phy240034-04', 'res-cpu'),
+  ('smp-de38d27ef1afee55bc46', 'smp-d29064eb686184d12cbe', 12866, 12866, '2026-06-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-phy240034-06', 'res-cpu'),
+  ('smp-50909688e38a289c775e', 'smp-d29064eb686184d12cbe', 12759, 12759, '2026-02-15 12:00:00', 'smp-bc08d052551918510023', 'job-phy240034-12', 'res-cpu'),
+  ('smp-1a7cf4b898e66519bbfa', 'smp-d29064eb686184d12cbe', 12881, 12881, '2026-04-15 12:00:00', 'smp-bc08d052551918510023', 'job-phy240034-14', 'res-cpu'),
+  ('smp-eb632ef53e053b61be18', 'smp-d29064eb686184d12cbe', 13003, 13003, '2026-06-15 12:00:00', 'smp-bc08d052551918510023', 'job-phy240034-16', 'res-cpu'),
+  ('smp-fe7ed21602138bb5d6bd', 'smp-d29064eb686184d12cbe', 12896, 12896, '2026-02-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-phy240034-22', 'res-cpu'),
+  ('smp-fcb629755d1e9143f58f', 'smp-d29064eb686184d12cbe', 13018, 13018, '2026-04-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-phy240034-24', 'res-cpu'),
+  ('smp-a528a8ce9397f2f2c14e', 'smp-d29064eb686184d12cbe', 13140, 13140, '2026-06-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-phy240034-26', 'res-cpu');
+
+-- BIO240041: Cryo-EM Reconstruction of Membrane Transporters
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-9a75381c02101891703d', 'BIO240041', 'Cryo-EM Reconstruction of Membrane Transporters', 'access', 'smp-2fd62ff24cea6be8de4c', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-4d0e44e3216d12f4d479', 'smp-9a75381c02101891703d', 'BIO240041 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 1200000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-9a75381c02101891703d', 'smp-2fd62ff24cea6be8de4c', 'PI'),
+  ('smp-9a75381c02101891703d', 'smp-38b35e0e4b22d0d6460a', 'CO_PI'),
+  ('smp-9a75381c02101891703d', 'smp-343175c9b7007d1ff0f8', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-a3ab9b4d9c40788c1903', 'smp-4d0e44e3216d12f4d479', 'smp-2fd62ff24cea6be8de4c', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-3267f51d3012259d259a', 'smp-4d0e44e3216d12f4d479', 'smp-38b35e0e4b22d0d6460a', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-629ec6cc62ffb28c951a', 'smp-4d0e44e3216d12f4d479', 'smp-b800fa9f20c414f38e44', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-df43769412be97bff936', 'smp-4d0e44e3216d12f4d479', 'smp-3b164a9395896373810e', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-ceeef3865b4d552302af', 'smp-4d0e44e3216d12f4d479', 'smp-f6343c23c6a7028f8ed7', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-3adf9e4196cfe653ed10', 'smp-4d0e44e3216d12f4d479', 'res-cpu', 12000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-f9b40c25d80a709c32e4', 'smp-4d0e44e3216d12f4d479', 60122, 60122, '2026-02-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-bio240041-02', 'res-cpu'),
+  ('smp-ee6c9eb02449247cee7e', 'smp-4d0e44e3216d12f4d479', 60244, 60244, '2026-04-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-bio240041-04', 'res-cpu'),
+  ('smp-a8db1b3ea4e59139a759', 'smp-4d0e44e3216d12f4d479', 60366, 60366, '2026-06-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-bio240041-06', 'res-cpu'),
+  ('smp-409930454eb546fe69f1', 'smp-4d0e44e3216d12f4d479', 60259, 60259, '2026-02-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-bio240041-12', 'res-cpu'),
+  ('smp-a2868ad098bf8dca568e', 'smp-4d0e44e3216d12f4d479', 60381, 60381, '2026-04-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-bio240041-14', 'res-cpu'),
+  ('smp-6dc600c77b58f77802c9', 'smp-4d0e44e3216d12f4d479', 60503, 60503, '2026-06-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-bio240041-16', 'res-cpu'),
+  ('smp-f932d2e828db398abb2c', 'smp-4d0e44e3216d12f4d479', 60396, 60396, '2026-02-15 12:00:00', 'smp-3b164a9395896373810e', 'job-bio240041-22', 'res-cpu'),
+  ('smp-dd39cc232b646d084e0b', 'smp-4d0e44e3216d12f4d479', 60518, 60518, '2026-04-15 12:00:00', 'smp-3b164a9395896373810e', 'job-bio240041-24', 'res-cpu'),
+  ('smp-c822b6cfa2e56941eaaf', 'smp-4d0e44e3216d12f4d479', 60640, 60640, '2026-06-15 12:00:00', 'smp-3b164a9395896373810e', 'job-bio240041-26', 'res-cpu');
+
+-- ATM240007: Convection-Permitting Regional Climate Downscaling
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-739a563efab75e5ab41a', 'ATM240007', 'Convection-Permitting Regional Climate Downscaling', 'access', 'smp-bc08d052551918510023', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-2551d58902d6316834f7', 'smp-739a563efab75e5ab41a', 'ATM240007 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 100000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-739a563efab75e5ab41a', 'smp-bc08d052551918510023', 'PI'),
+  ('smp-739a563efab75e5ab41a', 'smp-26420ea0545ca72a32b3', 'CO_PI'),
+  ('smp-739a563efab75e5ab41a', 'smp-f908bd32e57fd5c2896d', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-f646fe054930300ae250', 'smp-2551d58902d6316834f7', 'smp-bc08d052551918510023', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-83a967a84b8f97fcc7f8', 'smp-2551d58902d6316834f7', 'smp-26420ea0545ca72a32b3', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-f6b5af8ead74db587789', 'smp-2551d58902d6316834f7', 'smp-3cd6d104b01ef129b379', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-8605e7b86b9d576f483b', 'smp-2551d58902d6316834f7', 'smp-626aede3f65aeb969c74', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-fd74a21ed701b3fde48c', 'smp-2551d58902d6316834f7', 'smp-fa5b4cdf77ce78bd27e0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-cd993b04e4f2c02b88f8', 'smp-2551d58902d6316834f7', 'res-cpu', 1000, 3600),
+  ('smp-32db965752251c364028', 'smp-2551d58902d6316834f7', 'res-gpu', 50, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-5c988efc4a580a59b89d', 'smp-2551d58902d6316834f7', 5122, 5122, '2026-02-15 12:00:00', 'smp-bc08d052551918510023', 'job-atm240007-02', 'res-cpu'),
+  ('smp-acb54b7dbe6529188a0c', 'smp-2551d58902d6316834f7', 5244, 5244, '2026-04-15 12:00:00', 'smp-bc08d052551918510023', 'job-atm240007-04', 'res-cpu'),
+  ('smp-84b534468be86ac704e3', 'smp-2551d58902d6316834f7', 5366, 5366, '2026-06-15 12:00:00', 'smp-bc08d052551918510023', 'job-atm240007-06', 'res-cpu'),
+  ('smp-a5a772ba09c1faa5fd80', 'smp-2551d58902d6316834f7', 5259, 5259, '2026-02-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-atm240007-12', 'res-cpu'),
+  ('smp-310899e8eb00b1bbb13b', 'smp-2551d58902d6316834f7', 5381, 5381, '2026-04-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-atm240007-14', 'res-cpu'),
+  ('smp-228ad1f5a429f1b97327', 'smp-2551d58902d6316834f7', 5503, 5503, '2026-06-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-atm240007-16', 'res-cpu'),
+  ('smp-e1a05cb0fa2fed877d41', 'smp-2551d58902d6316834f7', 5396, 5396, '2026-02-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-atm240007-22', 'res-cpu'),
+  ('smp-b8bd552c0e4ccee94b45', 'smp-2551d58902d6316834f7', 5518, 5518, '2026-04-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-atm240007-24', 'res-cpu'),
+  ('smp-a656878e65a300e21644', 'smp-2551d58902d6316834f7', 5640, 5640, '2026-06-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-atm240007-26', 'res-cpu');
+
+-- MAT240019: Phase-Field Modeling of Additive Manufacturing
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-5591ed41e4391631471d', 'MAT240019', 'Phase-Field Modeling of Additive Manufacturing', 'access', 'smp-b800fa9f20c414f38e44', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-e1d2a3f5f134af253407', 'smp-5591ed41e4391631471d', 'MAT240019 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 750000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-5591ed41e4391631471d', 'smp-b800fa9f20c414f38e44', 'PI'),
+  ('smp-5591ed41e4391631471d', 'smp-3b164a9395896373810e', 'CO_PI'),
+  ('smp-5591ed41e4391631471d', 'smp-f6343c23c6a7028f8ed7', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-e3e592f0cf08e6810c4a', 'smp-e1d2a3f5f134af253407', 'smp-b800fa9f20c414f38e44', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-c1175f5f185e611386bc', 'smp-e1d2a3f5f134af253407', 'smp-3b164a9395896373810e', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-6fbefdb58caf23d4ec9a', 'smp-e1d2a3f5f134af253407', 'smp-90f9c3a872e74ceb9b54', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-846394a530902c49b71f', 'smp-e1d2a3f5f134af253407', 'smp-4907af7ab30bd84e6deb', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-3d456ef04b41df4b6b21', 'smp-e1d2a3f5f134af253407', 'smp-598aba3fd25a5b74a2cf', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-78e31b4a2a87e668ede9', 'smp-e1d2a3f5f134af253407', 'res-cpu', 7500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-94012f10b2837f8e70ac', 'smp-e1d2a3f5f134af253407', 37622, 37622, '2026-02-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-mat240019-02', 'res-cpu'),
+  ('smp-4bf63c33ab7ad750bbcb', 'smp-e1d2a3f5f134af253407', 37744, 37744, '2026-04-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-mat240019-04', 'res-cpu'),
+  ('smp-1f45c13ef3fb9504c686', 'smp-e1d2a3f5f134af253407', 37866, 37866, '2026-06-15 12:00:00', 'smp-b800fa9f20c414f38e44', 'job-mat240019-06', 'res-cpu'),
+  ('smp-d6a9533a80eb12507803', 'smp-e1d2a3f5f134af253407', 37759, 37759, '2026-02-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-mat240019-12', 'res-cpu'),
+  ('smp-35f5a1dd5e0c6768d31e', 'smp-e1d2a3f5f134af253407', 37881, 37881, '2026-04-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-mat240019-14', 'res-cpu'),
+  ('smp-4c4f22d12ad43f31a1eb', 'smp-e1d2a3f5f134af253407', 38003, 38003, '2026-06-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-mat240019-16', 'res-cpu'),
+  ('smp-c0202dbaa96535442ec2', 'smp-e1d2a3f5f134af253407', 37896, 37896, '2026-02-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mat240019-22', 'res-cpu'),
+  ('smp-7ff22c79168831d62ee2', 'smp-e1d2a3f5f134af253407', 38018, 38018, '2026-04-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mat240019-24', 'res-cpu'),
+  ('smp-4892ec0a6d21e1cdd04c', 'smp-e1d2a3f5f134af253407', 38140, 38140, '2026-06-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mat240019-26', 'res-cpu');
+INSERT IGNORE INTO compute_allocation_change_requests (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) VALUES
+  ('smp-254f9b0991e8ba676b9e', 'smp-e1d2a3f5f134af253407', 375000, 'ACTIVE', 'Supplement for MAT240019 production campaign', 'APPROVED', 'smp-b800fa9f20c414f38e44', '', '2026-06-24 09:00:00');
+INSERT IGNORE INTO compute_allocation_change_request_events (id, compute_allocation_change_request_id, event_type, description, timestamp) VALUES
+  ('smp-254f9b0991e8ba676b9e-e1', 'smp-254f9b0991e8ba676b9e', 'CREATED', 'Change request submitted by PI.', '2026-06-24 09:00:00');
+
+-- AST240023: Galaxy Formation in Cosmological Zoom Simulations
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-2acc6fcc75db53616416', 'AST240023', 'Galaxy Formation in Cosmological Zoom Simulations', 'access', 'smp-3cd6d104b01ef129b379', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-603a9403849f5bf1779d', 'smp-2acc6fcc75db53616416', 'AST240023 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 300000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-2acc6fcc75db53616416', 'smp-3cd6d104b01ef129b379', 'PI'),
+  ('smp-2acc6fcc75db53616416', 'smp-626aede3f65aeb969c74', 'CO_PI'),
+  ('smp-2acc6fcc75db53616416', 'smp-fa5b4cdf77ce78bd27e0', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-2a7e4efefba9ce923ebe', 'smp-603a9403849f5bf1779d', 'smp-3cd6d104b01ef129b379', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-2caa26d94f937425d1a4', 'smp-603a9403849f5bf1779d', 'smp-626aede3f65aeb969c74', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-c41e4253e1dc415f9d0f', 'smp-603a9403849f5bf1779d', 'smp-38b35e0e4b22d0d6460a', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-4b581a2c13cf595a4883', 'smp-603a9403849f5bf1779d', 'smp-ae4c14e3cd98f58c2363', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-f065f61b096e0c240128', 'smp-603a9403849f5bf1779d', 'smp-35b28b0dfed3e7dfcd36', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-2a8b2229da7f623bb18a', 'smp-603a9403849f5bf1779d', 'res-cpu', 3000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-6119d8a1f8d04129f3f8', 'smp-603a9403849f5bf1779d', 15122, 15122, '2026-02-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-ast240023-02', 'res-cpu'),
+  ('smp-647181201e12d96b2b62', 'smp-603a9403849f5bf1779d', 15244, 15244, '2026-04-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-ast240023-04', 'res-cpu'),
+  ('smp-28df35315256eb87cb4f', 'smp-603a9403849f5bf1779d', 15366, 15366, '2026-06-15 12:00:00', 'smp-3cd6d104b01ef129b379', 'job-ast240023-06', 'res-cpu'),
+  ('smp-f4034b96c8c2ceb41e9f', 'smp-603a9403849f5bf1779d', 15259, 15259, '2026-02-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-ast240023-12', 'res-cpu'),
+  ('smp-a340f3315559c8acea83', 'smp-603a9403849f5bf1779d', 15381, 15381, '2026-04-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-ast240023-14', 'res-cpu'),
+  ('smp-1cf9d1e335c97e2b8952', 'smp-603a9403849f5bf1779d', 15503, 15503, '2026-06-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-ast240023-16', 'res-cpu'),
+  ('smp-3466fea28ce65b64f3f3', 'smp-603a9403849f5bf1779d', 15396, 15396, '2026-02-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-ast240023-22', 'res-cpu'),
+  ('smp-9bfa80727d3c8049409b', 'smp-603a9403849f5bf1779d', 15518, 15518, '2026-04-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-ast240023-24', 'res-cpu'),
+  ('smp-70407e7274fdb84e6670', 'smp-603a9403849f5bf1779d', 15640, 15640, '2026-06-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-ast240023-26', 'res-cpu');
+
+-- CHE240055: Coarse-Grained Dynamics of Polymer Electrolytes
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-b7545fb17c505974fac7', 'CHE240055', 'Coarse-Grained Dynamics of Polymer Electrolytes', 'access', 'smp-90f9c3a872e74ceb9b54', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-50a930bb1fb569beb058', 'smp-b7545fb17c505974fac7', 'CHE240055 startup allocation', 'INACTIVE', '00000000-0000-0000-0000-000000000001', 500000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-b7545fb17c505974fac7', 'smp-90f9c3a872e74ceb9b54', 'PI'),
+  ('smp-b7545fb17c505974fac7', 'smp-4907af7ab30bd84e6deb', 'CO_PI'),
+  ('smp-b7545fb17c505974fac7', 'smp-598aba3fd25a5b74a2cf', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-df1b9ec3ffc9f799f8c7', 'smp-50a930bb1fb569beb058', 'smp-90f9c3a872e74ceb9b54', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-d9b23eb43e735256ff09', 'smp-50a930bb1fb569beb058', 'smp-4907af7ab30bd84e6deb', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-0f69ddeda7cf2638c12c', 'smp-50a930bb1fb569beb058', 'smp-26420ea0545ca72a32b3', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-4e05771035c6043e8ce7', 'smp-50a930bb1fb569beb058', 'smp-343175c9b7007d1ff0f8', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-015a0efad9c581a798c3', 'smp-50a930bb1fb569beb058', 'smp-02b1cd6cafbd27dd7201', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-585418a12468f375fb17', 'smp-50a930bb1fb569beb058', 'res-cpu', 5000, 3600),
+  ('smp-22ebd1572fb944db1fef', 'smp-50a930bb1fb569beb058', 'res-gpu', 250, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-7303d44888b9449db6a1', 'smp-50a930bb1fb569beb058', 25122, 25122, '2026-02-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-che240055-02', 'res-cpu'),
+  ('smp-6833b38aa98debeb791c', 'smp-50a930bb1fb569beb058', 25244, 25244, '2026-04-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-che240055-04', 'res-cpu'),
+  ('smp-b53af282aaef9a521e26', 'smp-50a930bb1fb569beb058', 25366, 25366, '2026-06-15 12:00:00', 'smp-90f9c3a872e74ceb9b54', 'job-che240055-06', 'res-cpu'),
+  ('smp-06af0ab1514506801fe0', 'smp-50a930bb1fb569beb058', 25259, 25259, '2026-02-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-che240055-12', 'res-cpu'),
+  ('smp-acd31c1c8e35266a6744', 'smp-50a930bb1fb569beb058', 25381, 25381, '2026-04-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-che240055-14', 'res-cpu'),
+  ('smp-65746ddfa778564f94cf', 'smp-50a930bb1fb569beb058', 25503, 25503, '2026-06-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-che240055-16', 'res-cpu'),
+  ('smp-3cea6aa6fef3a2a2c02c', 'smp-50a930bb1fb569beb058', 25396, 25396, '2026-02-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-che240055-22', 'res-cpu'),
+  ('smp-beb62c91dc7e783233e6', 'smp-50a930bb1fb569beb058', 25518, 25518, '2026-04-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-che240055-24', 'res-cpu'),
+  ('smp-6bdbddae9f82d1257645', 'smp-50a930bb1fb569beb058', 25640, 25640, '2026-06-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-che240055-26', 'res-cpu');
+
+-- GEO240011: Full-Waveform Inversion for Cascadia Subduction
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-3df742c724053f0bbfcc', 'GEO240011', 'Full-Waveform Inversion for Cascadia Subduction', 'access', 'smp-38b35e0e4b22d0d6460a', 'INACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-903e6a1acd2c67f92e00', 'smp-3df742c724053f0bbfcc', 'GEO240011 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 250000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-3df742c724053f0bbfcc', 'smp-38b35e0e4b22d0d6460a', 'PI'),
+  ('smp-3df742c724053f0bbfcc', 'smp-ae4c14e3cd98f58c2363', 'CO_PI'),
+  ('smp-3df742c724053f0bbfcc', 'smp-35b28b0dfed3e7dfcd36', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-6824f6e16aaacea2c844', 'smp-903e6a1acd2c67f92e00', 'smp-38b35e0e4b22d0d6460a', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7bab7f463ea43025f80e', 'smp-903e6a1acd2c67f92e00', 'smp-ae4c14e3cd98f58c2363', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-21fa0da14c6bed4666d1', 'smp-903e6a1acd2c67f92e00', 'smp-3b164a9395896373810e', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-c3110fe149f18a80ded9', 'smp-903e6a1acd2c67f92e00', 'smp-f908bd32e57fd5c2896d', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-d4fee66ef35b31194264', 'smp-903e6a1acd2c67f92e00', 'smp-b8e82843b10d1a15b995', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-b2d422b17c67c38949ba', 'smp-903e6a1acd2c67f92e00', 'res-cpu', 2500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-45ad6b15a89566275d44', 'smp-903e6a1acd2c67f92e00', 12622, 12622, '2026-02-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-geo240011-02', 'res-cpu'),
+  ('smp-a0098b972e16b7584386', 'smp-903e6a1acd2c67f92e00', 12744, 12744, '2026-04-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-geo240011-04', 'res-cpu'),
+  ('smp-4b6185e17858dac52571', 'smp-903e6a1acd2c67f92e00', 12866, 12866, '2026-06-15 12:00:00', 'smp-38b35e0e4b22d0d6460a', 'job-geo240011-06', 'res-cpu'),
+  ('smp-c977356bdb43d99edd22', 'smp-903e6a1acd2c67f92e00', 12759, 12759, '2026-02-15 12:00:00', 'smp-3b164a9395896373810e', 'job-geo240011-12', 'res-cpu'),
+  ('smp-af233ec0d4444a9edfa5', 'smp-903e6a1acd2c67f92e00', 12881, 12881, '2026-04-15 12:00:00', 'smp-3b164a9395896373810e', 'job-geo240011-14', 'res-cpu'),
+  ('smp-b84ddb9faa6307a5a95e', 'smp-903e6a1acd2c67f92e00', 13003, 13003, '2026-06-15 12:00:00', 'smp-3b164a9395896373810e', 'job-geo240011-16', 'res-cpu'),
+  ('smp-0e4ae5caa92f5e0d82a8', 'smp-903e6a1acd2c67f92e00', 12896, 12896, '2026-02-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-geo240011-22', 'res-cpu'),
+  ('smp-e04a55b89c5261a53754', 'smp-903e6a1acd2c67f92e00', 13018, 13018, '2026-04-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-geo240011-24', 'res-cpu'),
+  ('smp-d9a25ca35552f21d7be9', 'smp-903e6a1acd2c67f92e00', 13140, 13140, '2026-06-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-geo240011-26', 'res-cpu');
+
+-- CIS240068: Distributed Training of Sparse Mixture Models
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-46566060f3d3d2eb9420', 'CIS240068', 'Distributed Training of Sparse Mixture Models', 'access', 'smp-26420ea0545ca72a32b3', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-f6d592517cf3b690a68b', 'smp-46566060f3d3d2eb9420', 'CIS240068 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 1200000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-46566060f3d3d2eb9420', 'smp-26420ea0545ca72a32b3', 'PI'),
+  ('smp-46566060f3d3d2eb9420', 'smp-343175c9b7007d1ff0f8', 'CO_PI'),
+  ('smp-46566060f3d3d2eb9420', 'smp-02b1cd6cafbd27dd7201', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-64d81cddbe82cffe8c4c', 'smp-f6d592517cf3b690a68b', 'smp-26420ea0545ca72a32b3', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7bccab59588549eca7fe', 'smp-f6d592517cf3b690a68b', 'smp-343175c9b7007d1ff0f8', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-b692ed3def842f0502b3', 'smp-f6d592517cf3b690a68b', 'smp-626aede3f65aeb969c74', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-2e22c2973dd09d97a3d2', 'smp-f6d592517cf3b690a68b', 'smp-f6343c23c6a7028f8ed7', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7c7f4f97cb391e2c7bbc', 'smp-f6d592517cf3b690a68b', 'smp-318427dda738e0504620', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-2ee3008445252911c454', 'smp-f6d592517cf3b690a68b', 'res-cpu', 12000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-ed290c19f19b9711e6de', 'smp-f6d592517cf3b690a68b', 60122, 60122, '2026-02-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-cis240068-02', 'res-cpu'),
+  ('smp-b92b66eeb33cb669dc2d', 'smp-f6d592517cf3b690a68b', 60244, 60244, '2026-04-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-cis240068-04', 'res-cpu'),
+  ('smp-883764679b1ad666aaa2', 'smp-f6d592517cf3b690a68b', 60366, 60366, '2026-06-15 12:00:00', 'smp-26420ea0545ca72a32b3', 'job-cis240068-06', 'res-cpu'),
+  ('smp-b41963c27d6a8873a912', 'smp-f6d592517cf3b690a68b', 60259, 60259, '2026-02-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-cis240068-12', 'res-cpu'),
+  ('smp-e1d28528d1a7b2b1286c', 'smp-f6d592517cf3b690a68b', 60381, 60381, '2026-04-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-cis240068-14', 'res-cpu'),
+  ('smp-d4e85aad8a2a10fbe185', 'smp-f6d592517cf3b690a68b', 60503, 60503, '2026-06-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-cis240068-16', 'res-cpu'),
+  ('smp-30ed7b95adcdbcc8432c', 'smp-f6d592517cf3b690a68b', 60396, 60396, '2026-02-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-cis240068-22', 'res-cpu'),
+  ('smp-3bf95a6ff91f95a7bf45', 'smp-f6d592517cf3b690a68b', 60518, 60518, '2026-04-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-cis240068-24', 'res-cpu'),
+  ('smp-c47aec92d29b9aa71a2c', 'smp-f6d592517cf3b690a68b', 60640, 60640, '2026-06-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-cis240068-26', 'res-cpu');
+INSERT IGNORE INTO compute_allocation_change_requests (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) VALUES
+  ('smp-a362284a709cb8db8365', 'smp-f6d592517cf3b690a68b', 600000, 'ACTIVE', 'Supplement for CIS240068 production campaign', 'REJECTED', 'smp-26420ea0545ca72a32b3', '', '2026-06-20 09:00:00');
+INSERT IGNORE INTO compute_allocation_change_request_events (id, compute_allocation_change_request_id, event_type, description, timestamp) VALUES
+  ('smp-a362284a709cb8db8365-e1', 'smp-a362284a709cb8db8365', 'CREATED', 'Change request submitted by PI.', '2026-06-20 09:00:00');
+
+-- MCB240032: Molecular Dynamics of Intrinsically Disordered Proteins
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-2ef2fd65182345f961cf', 'MCB240032', 'Molecular Dynamics of Intrinsically Disordered Proteins', 'access', 'smp-3b164a9395896373810e', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-c9853aa7a5047707793f', 'smp-2ef2fd65182345f961cf', 'MCB240032 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 100000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-2ef2fd65182345f961cf', 'smp-3b164a9395896373810e', 'PI'),
+  ('smp-2ef2fd65182345f961cf', 'smp-f908bd32e57fd5c2896d', 'CO_PI'),
+  ('smp-2ef2fd65182345f961cf', 'smp-b8e82843b10d1a15b995', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-9476c4f1e5bf7533ef75', 'smp-c9853aa7a5047707793f', 'smp-3b164a9395896373810e', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-6f19adb01050b92b28ad', 'smp-c9853aa7a5047707793f', 'smp-f908bd32e57fd5c2896d', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-f6693a2b1c7120ffe29c', 'smp-c9853aa7a5047707793f', 'smp-4907af7ab30bd84e6deb', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-caa25cae214c2173de67', 'smp-c9853aa7a5047707793f', 'smp-fa5b4cdf77ce78bd27e0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-07bffefcd0482952db55', 'smp-c9853aa7a5047707793f', 'smp-52fae2d4d9ff23760057', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-04145f1728c174284d80', 'smp-c9853aa7a5047707793f', 'res-cpu', 1000, 3600),
+  ('smp-86cca597b1b4dfe7f75f', 'smp-c9853aa7a5047707793f', 'res-gpu', 50, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-0545c6e6175b54feda98', 'smp-c9853aa7a5047707793f', 5122, 5122, '2026-02-15 12:00:00', 'smp-3b164a9395896373810e', 'job-mcb240032-02', 'res-cpu'),
+  ('smp-da639b4db3786bbb4839', 'smp-c9853aa7a5047707793f', 5244, 5244, '2026-04-15 12:00:00', 'smp-3b164a9395896373810e', 'job-mcb240032-04', 'res-cpu'),
+  ('smp-2ff12ffb0c267e76a30a', 'smp-c9853aa7a5047707793f', 5366, 5366, '2026-06-15 12:00:00', 'smp-3b164a9395896373810e', 'job-mcb240032-06', 'res-cpu'),
+  ('smp-b6b494f6fea4c318d2d6', 'smp-c9853aa7a5047707793f', 5259, 5259, '2026-02-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mcb240032-12', 'res-cpu'),
+  ('smp-7db21ec71651cc4242c8', 'smp-c9853aa7a5047707793f', 5381, 5381, '2026-04-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mcb240032-14', 'res-cpu'),
+  ('smp-a494340a98c6f8a8d1f6', 'smp-c9853aa7a5047707793f', 5503, 5503, '2026-06-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-mcb240032-16', 'res-cpu'),
+  ('smp-9660252e76ac549b0a47', 'smp-c9853aa7a5047707793f', 5396, 5396, '2026-02-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-mcb240032-22', 'res-cpu'),
+  ('smp-878859e4c47f6b6cc517', 'smp-c9853aa7a5047707793f', 5518, 5518, '2026-04-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-mcb240032-24', 'res-cpu'),
+  ('smp-552d8c8f929c5fd05719', 'smp-c9853aa7a5047707793f', 5640, 5640, '2026-06-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-mcb240032-26', 'res-cpu');
+
+-- ENG240046: Wall-Resolved LES of Turbine Film Cooling
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-0948a283a07e0b73d2f7', 'ENG240046', 'Wall-Resolved LES of Turbine Film Cooling', 'access', 'smp-626aede3f65aeb969c74', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-c4071741a17cbea31f46', 'smp-0948a283a07e0b73d2f7', 'ENG240046 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 750000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-0948a283a07e0b73d2f7', 'smp-626aede3f65aeb969c74', 'PI'),
+  ('smp-0948a283a07e0b73d2f7', 'smp-f6343c23c6a7028f8ed7', 'CO_PI'),
+  ('smp-0948a283a07e0b73d2f7', 'smp-318427dda738e0504620', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-ceeab8b8d03eb169950a', 'smp-c4071741a17cbea31f46', 'smp-626aede3f65aeb969c74', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-d3f4c3b41e0e54c34bed', 'smp-c4071741a17cbea31f46', 'smp-f6343c23c6a7028f8ed7', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-9d0325840ec68ace6a7d', 'smp-c4071741a17cbea31f46', 'smp-ae4c14e3cd98f58c2363', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-95c353026d3979df0010', 'smp-c4071741a17cbea31f46', 'smp-598aba3fd25a5b74a2cf', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-3f7b090718cb5d3bffda', 'smp-c4071741a17cbea31f46', 'smp-8d36f8ec62cc1ab62fd5', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-290e16e3bc2f7ef0e0ff', 'smp-c4071741a17cbea31f46', 'res-cpu', 7500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-1b8f689a0cffe1d52ca8', 'smp-c4071741a17cbea31f46', 37622, 37622, '2026-02-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-eng240046-02', 'res-cpu'),
+  ('smp-9ed2a39afce2ac00f3e5', 'smp-c4071741a17cbea31f46', 37744, 37744, '2026-04-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-eng240046-04', 'res-cpu'),
+  ('smp-cfecf6fd8b56831fabed', 'smp-c4071741a17cbea31f46', 37866, 37866, '2026-06-15 12:00:00', 'smp-626aede3f65aeb969c74', 'job-eng240046-06', 'res-cpu'),
+  ('smp-8ce1615d93d0f31f13fa', 'smp-c4071741a17cbea31f46', 37759, 37759, '2026-02-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-eng240046-12', 'res-cpu'),
+  ('smp-ab60b36df0db3dc6c392', 'smp-c4071741a17cbea31f46', 37881, 37881, '2026-04-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-eng240046-14', 'res-cpu'),
+  ('smp-e9c9bfbc8282a2058866', 'smp-c4071741a17cbea31f46', 38003, 38003, '2026-06-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-eng240046-16', 'res-cpu'),
+  ('smp-4177f7b4cfc4945eafe2', 'smp-c4071741a17cbea31f46', 37896, 37896, '2026-02-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eng240046-22', 'res-cpu'),
+  ('smp-aa9d56523363c1d988ea', 'smp-c4071741a17cbea31f46', 38018, 38018, '2026-04-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eng240046-24', 'res-cpu'),
+  ('smp-91b516fb1a3cd25627bb', 'smp-c4071741a17cbea31f46', 38140, 38140, '2026-06-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eng240046-26', 'res-cpu');
+
+-- OCE240015: Mesoscale Eddy Tracking in Global Ocean Reanalysis
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-c642dcb393db75aa5526', 'OCE240015', 'Mesoscale Eddy Tracking in Global Ocean Reanalysis', 'access', 'smp-4907af7ab30bd84e6deb', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-de5487db358f6fb7d6fe', 'smp-c642dcb393db75aa5526', 'OCE240015 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 300000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-c642dcb393db75aa5526', 'smp-4907af7ab30bd84e6deb', 'PI'),
+  ('smp-c642dcb393db75aa5526', 'smp-fa5b4cdf77ce78bd27e0', 'CO_PI'),
+  ('smp-c642dcb393db75aa5526', 'smp-52fae2d4d9ff23760057', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-adc667d00a365ef25633', 'smp-de5487db358f6fb7d6fe', 'smp-4907af7ab30bd84e6deb', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-48470b978fb2146882a6', 'smp-de5487db358f6fb7d6fe', 'smp-fa5b4cdf77ce78bd27e0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-036435c793e419eb68e5', 'smp-de5487db358f6fb7d6fe', 'smp-343175c9b7007d1ff0f8', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-cab86841b6858594eb99', 'smp-de5487db358f6fb7d6fe', 'smp-35b28b0dfed3e7dfcd36', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-6b247b1e68a043d0386b', 'smp-de5487db358f6fb7d6fe', 'smp-344b252c9dd481944db0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-352960efaeab23d059b5', 'smp-de5487db358f6fb7d6fe', 'res-cpu', 3000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-5f737417545f4ef958b9', 'smp-de5487db358f6fb7d6fe', 15122, 15122, '2026-02-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-oce240015-02', 'res-cpu'),
+  ('smp-47166fc6b0dd678f1bd5', 'smp-de5487db358f6fb7d6fe', 15244, 15244, '2026-04-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-oce240015-04', 'res-cpu'),
+  ('smp-23f3f687943779bef143', 'smp-de5487db358f6fb7d6fe', 15366, 15366, '2026-06-15 12:00:00', 'smp-4907af7ab30bd84e6deb', 'job-oce240015-06', 'res-cpu'),
+  ('smp-764478c5e1a63403d465', 'smp-de5487db358f6fb7d6fe', 15259, 15259, '2026-02-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-oce240015-12', 'res-cpu'),
+  ('smp-53be17fc6ec1b4bd2f26', 'smp-de5487db358f6fb7d6fe', 15381, 15381, '2026-04-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-oce240015-14', 'res-cpu'),
+  ('smp-6baa75f9201df61b025f', 'smp-de5487db358f6fb7d6fe', 15503, 15503, '2026-06-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-oce240015-16', 'res-cpu'),
+  ('smp-ba1a17e1891bf497e4a3', 'smp-de5487db358f6fb7d6fe', 15396, 15396, '2026-02-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-oce240015-22', 'res-cpu'),
+  ('smp-fb89291e07eabaf74b00', 'smp-de5487db358f6fb7d6fe', 15518, 15518, '2026-04-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-oce240015-24', 'res-cpu'),
+  ('smp-a107d2c5d41c9a8015c0', 'smp-de5487db358f6fb7d6fe', 15640, 15640, '2026-06-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-oce240015-26', 'res-cpu');
+
+-- DMS240027: High-Order Solvers for Kinetic Plasma Equations
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-22445a61084f5764042b', 'DMS240027', 'High-Order Solvers for Kinetic Plasma Equations', 'access', 'smp-ae4c14e3cd98f58c2363', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-ce03e036de1da6863393', 'smp-22445a61084f5764042b', 'DMS240027 startup allocation', 'INACTIVE', '00000000-0000-0000-0000-000000000001', 500000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-22445a61084f5764042b', 'smp-ae4c14e3cd98f58c2363', 'PI'),
+  ('smp-22445a61084f5764042b', 'smp-598aba3fd25a5b74a2cf', 'CO_PI'),
+  ('smp-22445a61084f5764042b', 'smp-8d36f8ec62cc1ab62fd5', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-5f8a62442cc1be68f10d', 'smp-ce03e036de1da6863393', 'smp-ae4c14e3cd98f58c2363', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7d6d016ed837e198c526', 'smp-ce03e036de1da6863393', 'smp-598aba3fd25a5b74a2cf', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-43c41d94073b21d4db5f', 'smp-ce03e036de1da6863393', 'smp-f908bd32e57fd5c2896d', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-f5fa7ced0d73482111ba', 'smp-ce03e036de1da6863393', 'smp-02b1cd6cafbd27dd7201', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-2b3ebce9395cb23a040e', 'smp-ce03e036de1da6863393', 'smp-c8e6b563ac8f348d6069', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-01b687d9274b62c9f6b5', 'smp-ce03e036de1da6863393', 'res-cpu', 5000, 3600),
+  ('smp-f9c7a3aa26187e49d26e', 'smp-ce03e036de1da6863393', 'res-gpu', 250, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-dba38111b0c14e71fd7f', 'smp-ce03e036de1da6863393', 25122, 25122, '2026-02-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-dms240027-02', 'res-cpu'),
+  ('smp-2a90ead06577735119e1', 'smp-ce03e036de1da6863393', 25244, 25244, '2026-04-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-dms240027-04', 'res-cpu'),
+  ('smp-7072c544a0eec959aef0', 'smp-ce03e036de1da6863393', 25366, 25366, '2026-06-15 12:00:00', 'smp-ae4c14e3cd98f58c2363', 'job-dms240027-06', 'res-cpu'),
+  ('smp-a414051ec8de4520b9b6', 'smp-ce03e036de1da6863393', 25259, 25259, '2026-02-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-dms240027-12', 'res-cpu'),
+  ('smp-b614b9f0d21a56d80103', 'smp-ce03e036de1da6863393', 25381, 25381, '2026-04-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-dms240027-14', 'res-cpu'),
+  ('smp-c4278139fad25c9719e3', 'smp-ce03e036de1da6863393', 25503, 25503, '2026-06-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-dms240027-16', 'res-cpu'),
+  ('smp-20b22d60cdb397c05c42', 'smp-ce03e036de1da6863393', 25396, 25396, '2026-02-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-dms240027-22', 'res-cpu'),
+  ('smp-8147ad5d8acf9a477192', 'smp-ce03e036de1da6863393', 25518, 25518, '2026-04-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-dms240027-24', 'res-cpu'),
+  ('smp-c462c852980fe4eaaef5', 'smp-ce03e036de1da6863393', 25640, 25640, '2026-06-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-dms240027-26', 'res-cpu');
+INSERT IGNORE INTO compute_allocation_change_requests (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) VALUES
+  ('smp-cba9d22751366aa10b94', 'smp-ce03e036de1da6863393', 250000, 'ACTIVE', 'Supplement for DMS240027 production campaign', 'PENDING', 'smp-ae4c14e3cd98f58c2363', '', '2026-06-24 09:00:00');
+INSERT IGNORE INTO compute_allocation_change_request_events (id, compute_allocation_change_request_id, event_type, description, timestamp) VALUES
+  ('smp-cba9d22751366aa10b94-e1', 'smp-cba9d22751366aa10b94', 'CREATED', 'Change request submitted by PI.', '2026-06-24 09:00:00');
+
+-- EAR240038: Mantle Convection with Realistic Rheology
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-6f71b0a457dc2d9383c7', 'EAR240038', 'Mantle Convection with Realistic Rheology', 'access', 'smp-343175c9b7007d1ff0f8', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-cac362e9bc8a5b73dc56', 'smp-6f71b0a457dc2d9383c7', 'EAR240038 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 250000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-6f71b0a457dc2d9383c7', 'smp-343175c9b7007d1ff0f8', 'PI'),
+  ('smp-6f71b0a457dc2d9383c7', 'smp-35b28b0dfed3e7dfcd36', 'CO_PI'),
+  ('smp-6f71b0a457dc2d9383c7', 'smp-344b252c9dd481944db0', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-7433b8a0532af1994f82', 'smp-cac362e9bc8a5b73dc56', 'smp-343175c9b7007d1ff0f8', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-8ab28e66c8069430b34a', 'smp-cac362e9bc8a5b73dc56', 'smp-35b28b0dfed3e7dfcd36', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-7ed90896509e0411de77', 'smp-cac362e9bc8a5b73dc56', 'smp-f6343c23c6a7028f8ed7', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-ee268f40f73c25a878df', 'smp-cac362e9bc8a5b73dc56', 'smp-b8e82843b10d1a15b995', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-5e3817eadd4645ed02d4', 'smp-cac362e9bc8a5b73dc56', 'smp-2fd62ff24cea6be8de4c', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-81f50c2c9a64f4e65068', 'smp-cac362e9bc8a5b73dc56', 'res-cpu', 2500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-c88c10bdb0c05befa0f2', 'smp-cac362e9bc8a5b73dc56', 12622, 12622, '2026-02-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-ear240038-02', 'res-cpu'),
+  ('smp-324ff31c36ac2b276a59', 'smp-cac362e9bc8a5b73dc56', 12744, 12744, '2026-04-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-ear240038-04', 'res-cpu'),
+  ('smp-dc60c880beda5edcc869', 'smp-cac362e9bc8a5b73dc56', 12866, 12866, '2026-06-15 12:00:00', 'smp-343175c9b7007d1ff0f8', 'job-ear240038-06', 'res-cpu'),
+  ('smp-ac4f17b1f4d9a2653164', 'smp-cac362e9bc8a5b73dc56', 12759, 12759, '2026-02-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-ear240038-12', 'res-cpu'),
+  ('smp-18f43de9e2b2ce99dc97', 'smp-cac362e9bc8a5b73dc56', 12881, 12881, '2026-04-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-ear240038-14', 'res-cpu'),
+  ('smp-e4ea2a77728d3faa087a', 'smp-cac362e9bc8a5b73dc56', 13003, 13003, '2026-06-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-ear240038-16', 'res-cpu'),
+  ('smp-a1cd91aae6e29ba33065', 'smp-cac362e9bc8a5b73dc56', 12896, 12896, '2026-02-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-ear240038-22', 'res-cpu'),
+  ('smp-35a3fb4f232de26e49a6', 'smp-cac362e9bc8a5b73dc56', 13018, 13018, '2026-04-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-ear240038-24', 'res-cpu'),
+  ('smp-ad766f924c56521625d2', 'smp-cac362e9bc8a5b73dc56', 13140, 13140, '2026-06-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-ear240038-26', 'res-cpu');
+
+-- NEU240052: Whole-Brain Spiking Network Simulation
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-f5e84bbacec87c3deb6d', 'NEU240052', 'Whole-Brain Spiking Network Simulation', 'access', 'smp-f908bd32e57fd5c2896d', 'INACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-b1145214bc21940084fa', 'smp-f5e84bbacec87c3deb6d', 'NEU240052 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 1200000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-f5e84bbacec87c3deb6d', 'smp-f908bd32e57fd5c2896d', 'PI'),
+  ('smp-f5e84bbacec87c3deb6d', 'smp-02b1cd6cafbd27dd7201', 'CO_PI'),
+  ('smp-f5e84bbacec87c3deb6d', 'smp-c8e6b563ac8f348d6069', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-ef7b3cf471a18f724bfe', 'smp-b1145214bc21940084fa', 'smp-f908bd32e57fd5c2896d', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-dd291d9acb1a3cf3a34d', 'smp-b1145214bc21940084fa', 'smp-02b1cd6cafbd27dd7201', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-8172646ef19cf2aba13d', 'smp-b1145214bc21940084fa', 'smp-fa5b4cdf77ce78bd27e0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-62e2175da6c8cd7bc527', 'smp-b1145214bc21940084fa', 'smp-318427dda738e0504620', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-5ee1e3afbdb762fc82b9', 'smp-b1145214bc21940084fa', 'smp-bc08d052551918510023', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-272f3c504c041203dbb6', 'smp-b1145214bc21940084fa', 'res-cpu', 12000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-bbce515125ea9a07b292', 'smp-b1145214bc21940084fa', 60122, 60122, '2026-02-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-neu240052-02', 'res-cpu'),
+  ('smp-22f88a7c4c6b3d6fe05d', 'smp-b1145214bc21940084fa', 60244, 60244, '2026-04-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-neu240052-04', 'res-cpu'),
+  ('smp-01fffd2e411603a60bbd', 'smp-b1145214bc21940084fa', 60366, 60366, '2026-06-15 12:00:00', 'smp-f908bd32e57fd5c2896d', 'job-neu240052-06', 'res-cpu'),
+  ('smp-fe4ae389920eee9d080b', 'smp-b1145214bc21940084fa', 60259, 60259, '2026-02-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-neu240052-12', 'res-cpu'),
+  ('smp-c9efd8c32a16059fb7e6', 'smp-b1145214bc21940084fa', 60381, 60381, '2026-04-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-neu240052-14', 'res-cpu'),
+  ('smp-99f3926b6c00e9c5d335', 'smp-b1145214bc21940084fa', 60503, 60503, '2026-06-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-neu240052-16', 'res-cpu'),
+  ('smp-8b8ef509006b1f3c9baa', 'smp-b1145214bc21940084fa', 60396, 60396, '2026-02-15 12:00:00', 'smp-318427dda738e0504620', 'job-neu240052-22', 'res-cpu'),
+  ('smp-ec7a532ca3a2d62e3556', 'smp-b1145214bc21940084fa', 60518, 60518, '2026-04-15 12:00:00', 'smp-318427dda738e0504620', 'job-neu240052-24', 'res-cpu'),
+  ('smp-7bb7b64c3a488df7b353', 'smp-b1145214bc21940084fa', 60640, 60640, '2026-06-15 12:00:00', 'smp-318427dda738e0504620', 'job-neu240052-26', 'res-cpu');
+
+-- ECO240009: Agent-Based Modeling of Land-Use Change
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-e483b73b4458eae0fa14', 'ECO240009', 'Agent-Based Modeling of Land-Use Change', 'access', 'smp-f6343c23c6a7028f8ed7', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-7512b54990a4efa50cf6', 'smp-e483b73b4458eae0fa14', 'ECO240009 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 100000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-e483b73b4458eae0fa14', 'smp-f6343c23c6a7028f8ed7', 'PI'),
+  ('smp-e483b73b4458eae0fa14', 'smp-b8e82843b10d1a15b995', 'CO_PI'),
+  ('smp-e483b73b4458eae0fa14', 'smp-2fd62ff24cea6be8de4c', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-9d0742abbe4931843e57', 'smp-7512b54990a4efa50cf6', 'smp-f6343c23c6a7028f8ed7', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-849058dc7fcecbf7b6b5', 'smp-7512b54990a4efa50cf6', 'smp-b8e82843b10d1a15b995', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-a934e4f3f489392b9a05', 'smp-7512b54990a4efa50cf6', 'smp-598aba3fd25a5b74a2cf', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-432ed414e8e53550f302', 'smp-7512b54990a4efa50cf6', 'smp-52fae2d4d9ff23760057', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-5cbd30c58d82e19d150a', 'smp-7512b54990a4efa50cf6', 'smp-b800fa9f20c414f38e44', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-5a5aec09c83d141ffac9', 'smp-7512b54990a4efa50cf6', 'res-cpu', 1000, 3600),
+  ('smp-a96c3041058a5feb48be', 'smp-7512b54990a4efa50cf6', 'res-gpu', 50, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-03881086768fe03766f4', 'smp-7512b54990a4efa50cf6', 5122, 5122, '2026-02-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-eco240009-02', 'res-cpu'),
+  ('smp-6a8c06b1577be4f478a6', 'smp-7512b54990a4efa50cf6', 5244, 5244, '2026-04-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-eco240009-04', 'res-cpu'),
+  ('smp-a47aeaa1650e33338ea2', 'smp-7512b54990a4efa50cf6', 5366, 5366, '2026-06-15 12:00:00', 'smp-f6343c23c6a7028f8ed7', 'job-eco240009-06', 'res-cpu'),
+  ('smp-3bb282631adbeef4f91c', 'smp-7512b54990a4efa50cf6', 5259, 5259, '2026-02-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eco240009-12', 'res-cpu'),
+  ('smp-0b7d4354f5fa6d91b12a', 'smp-7512b54990a4efa50cf6', 5381, 5381, '2026-04-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eco240009-14', 'res-cpu'),
+  ('smp-2fd5abb579af4df3c888', 'smp-7512b54990a4efa50cf6', 5503, 5503, '2026-06-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-eco240009-16', 'res-cpu'),
+  ('smp-cb736460f484f94be590', 'smp-7512b54990a4efa50cf6', 5396, 5396, '2026-02-15 12:00:00', 'smp-52fae2d4d9ff23760057', 'job-eco240009-22', 'res-cpu'),
+  ('smp-1fcffefc2bf002f5599c', 'smp-7512b54990a4efa50cf6', 5518, 5518, '2026-04-15 12:00:00', 'smp-52fae2d4d9ff23760057', 'job-eco240009-24', 'res-cpu'),
+  ('smp-72ae86ab1c594bf42f50', 'smp-7512b54990a4efa50cf6', 5640, 5640, '2026-06-15 12:00:00', 'smp-52fae2d4d9ff23760057', 'job-eco240009-26', 'res-cpu');
+
+-- MED240061: Virtual Screening Against Antibiotic Resistance Targets
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-980231459f6b23c93e99', 'MED240061', 'Virtual Screening Against Antibiotic Resistance Targets', 'access', 'smp-fa5b4cdf77ce78bd27e0', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-bdb96ba1e6a5d37513db', 'smp-980231459f6b23c93e99', 'MED240061 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 750000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-980231459f6b23c93e99', 'smp-fa5b4cdf77ce78bd27e0', 'PI'),
+  ('smp-980231459f6b23c93e99', 'smp-318427dda738e0504620', 'CO_PI'),
+  ('smp-980231459f6b23c93e99', 'smp-bc08d052551918510023', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-7ffab5fb09b6212c8741', 'smp-bdb96ba1e6a5d37513db', 'smp-fa5b4cdf77ce78bd27e0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-99a0dad83547931b15ad', 'smp-bdb96ba1e6a5d37513db', 'smp-318427dda738e0504620', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-fd315fada5b30c7a1b15', 'smp-bdb96ba1e6a5d37513db', 'smp-35b28b0dfed3e7dfcd36', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-b485267a18604bb920b4', 'smp-bdb96ba1e6a5d37513db', 'smp-8d36f8ec62cc1ab62fd5', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-c28380a7fcc0a214c012', 'smp-bdb96ba1e6a5d37513db', 'smp-3cd6d104b01ef129b379', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-7d2a9327eed6552b9597', 'smp-bdb96ba1e6a5d37513db', 'res-cpu', 7500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-fa6d5c9f7535e88b9fe3', 'smp-bdb96ba1e6a5d37513db', 37622, 37622, '2026-02-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-med240061-02', 'res-cpu'),
+  ('smp-7c000be8f80e3ca352db', 'smp-bdb96ba1e6a5d37513db', 37744, 37744, '2026-04-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-med240061-04', 'res-cpu'),
+  ('smp-fb4aaee12a343fe01586', 'smp-bdb96ba1e6a5d37513db', 37866, 37866, '2026-06-15 12:00:00', 'smp-fa5b4cdf77ce78bd27e0', 'job-med240061-06', 'res-cpu'),
+  ('smp-32193d634be270c82d67', 'smp-bdb96ba1e6a5d37513db', 37759, 37759, '2026-02-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-med240061-12', 'res-cpu'),
+  ('smp-31a909ee65bae384a14e', 'smp-bdb96ba1e6a5d37513db', 37881, 37881, '2026-04-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-med240061-14', 'res-cpu'),
+  ('smp-13e67e5444e2ec360cbd', 'smp-bdb96ba1e6a5d37513db', 38003, 38003, '2026-06-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-med240061-16', 'res-cpu'),
+  ('smp-c7929bce2db2faeecc55', 'smp-bdb96ba1e6a5d37513db', 37896, 37896, '2026-02-15 12:00:00', 'smp-8d36f8ec62cc1ab62fd5', 'job-med240061-22', 'res-cpu'),
+  ('smp-b03fe4d7ef1395d89bde', 'smp-bdb96ba1e6a5d37513db', 38018, 38018, '2026-04-15 12:00:00', 'smp-8d36f8ec62cc1ab62fd5', 'job-med240061-24', 'res-cpu'),
+  ('smp-ecb89e0a260d7720850c', 'smp-bdb96ba1e6a5d37513db', 38140, 38140, '2026-06-15 12:00:00', 'smp-8d36f8ec62cc1ab62fd5', 'job-med240061-26', 'res-cpu');
+INSERT IGNORE INTO compute_allocation_change_requests (id, compute_allocation_id, requested_su_amount, requested_status, reason, change_status, requester_id, approver_id, timestamp) VALUES
+  ('smp-6ce228100e9a0587de58', 'smp-bdb96ba1e6a5d37513db', 375000, 'ACTIVE', 'Supplement for MED240061 production campaign', 'PENDING', 'smp-fa5b4cdf77ce78bd27e0', '', '2026-06-20 09:00:00');
+INSERT IGNORE INTO compute_allocation_change_request_events (id, compute_allocation_change_request_id, event_type, description, timestamp) VALUES
+  ('smp-6ce228100e9a0587de58-e1', 'smp-6ce228100e9a0587de58', 'CREATED', 'Change request submitted by PI.', '2026-06-20 09:00:00');
+
+-- AER240044: Hypersonic Boundary-Layer Transition Prediction
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-ac4de8764ba7aa98e886', 'AER240044', 'Hypersonic Boundary-Layer Transition Prediction', 'access', 'smp-598aba3fd25a5b74a2cf', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-fd5e6684bf9182850ebd', 'smp-ac4de8764ba7aa98e886', 'AER240044 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 300000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-ac4de8764ba7aa98e886', 'smp-598aba3fd25a5b74a2cf', 'PI'),
+  ('smp-ac4de8764ba7aa98e886', 'smp-52fae2d4d9ff23760057', 'CO_PI'),
+  ('smp-ac4de8764ba7aa98e886', 'smp-b800fa9f20c414f38e44', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-9ac2e29e9bca669bedac', 'smp-fd5e6684bf9182850ebd', 'smp-598aba3fd25a5b74a2cf', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-276a2a13e89431d65dd1', 'smp-fd5e6684bf9182850ebd', 'smp-52fae2d4d9ff23760057', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-9f80e1cb60b7fed5519d', 'smp-fd5e6684bf9182850ebd', 'smp-02b1cd6cafbd27dd7201', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-23ee3cd03d7dd5895364', 'smp-fd5e6684bf9182850ebd', 'smp-344b252c9dd481944db0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-5f60915d151ee7c64632', 'smp-fd5e6684bf9182850ebd', 'smp-90f9c3a872e74ceb9b54', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-785ef1f0e1c1269098dd', 'smp-fd5e6684bf9182850ebd', 'res-cpu', 3000, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-2840160fb65ec4f4d7ce', 'smp-fd5e6684bf9182850ebd', 15122, 15122, '2026-02-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-aer240044-02', 'res-cpu'),
+  ('smp-894388502b8d4c479e50', 'smp-fd5e6684bf9182850ebd', 15244, 15244, '2026-04-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-aer240044-04', 'res-cpu'),
+  ('smp-fec37bbc32f5d233a7f6', 'smp-fd5e6684bf9182850ebd', 15366, 15366, '2026-06-15 12:00:00', 'smp-598aba3fd25a5b74a2cf', 'job-aer240044-06', 'res-cpu'),
+  ('smp-02f68a751de4c2ce6483', 'smp-fd5e6684bf9182850ebd', 15259, 15259, '2026-02-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-aer240044-12', 'res-cpu'),
+  ('smp-775794c0f0e4e295f8d1', 'smp-fd5e6684bf9182850ebd', 15381, 15381, '2026-04-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-aer240044-14', 'res-cpu'),
+  ('smp-bbef762e52a9d5327f23', 'smp-fd5e6684bf9182850ebd', 15503, 15503, '2026-06-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-aer240044-16', 'res-cpu'),
+  ('smp-0cdc260bcd08b694ecef', 'smp-fd5e6684bf9182850ebd', 15396, 15396, '2026-02-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-aer240044-22', 'res-cpu'),
+  ('smp-594643d1848f1c5317a0', 'smp-fd5e6684bf9182850ebd', 15518, 15518, '2026-04-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-aer240044-24', 'res-cpu'),
+  ('smp-1b6c4033a2c532111c26', 'smp-fd5e6684bf9182850ebd', 15640, 15640, '2026-06-15 12:00:00', 'smp-344b252c9dd481944db0', 'job-aer240044-26', 'res-cpu');
+
+-- QCN240017: Tensor-Network Simulation of Quantum Circuits
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-0b70f6fc04a8a1f03514', 'QCN240017', 'Tensor-Network Simulation of Quantum Circuits', 'access', 'smp-35b28b0dfed3e7dfcd36', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-81d26521f4dc52bb030b', 'smp-0b70f6fc04a8a1f03514', 'QCN240017 startup allocation', 'INACTIVE', '00000000-0000-0000-0000-000000000001', 500000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-0b70f6fc04a8a1f03514', 'smp-35b28b0dfed3e7dfcd36', 'PI'),
+  ('smp-0b70f6fc04a8a1f03514', 'smp-8d36f8ec62cc1ab62fd5', 'CO_PI'),
+  ('smp-0b70f6fc04a8a1f03514', 'smp-3cd6d104b01ef129b379', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-10a5eafb6445f979c71d', 'smp-81d26521f4dc52bb030b', 'smp-35b28b0dfed3e7dfcd36', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-2065f6b4a0c81045f9a6', 'smp-81d26521f4dc52bb030b', 'smp-8d36f8ec62cc1ab62fd5', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-77b9d327bcbd501ea39e', 'smp-81d26521f4dc52bb030b', 'smp-b8e82843b10d1a15b995', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-9957caef1d18c43f12b3', 'smp-81d26521f4dc52bb030b', 'smp-c8e6b563ac8f348d6069', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-e7da29a49c0b1e06670a', 'smp-81d26521f4dc52bb030b', 'smp-38b35e0e4b22d0d6460a', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-f39914bda842d3029073', 'smp-81d26521f4dc52bb030b', 'res-cpu', 5000, 3600),
+  ('smp-48fe1aeed1dee260a1cd', 'smp-81d26521f4dc52bb030b', 'res-gpu', 250, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-8aba1e6527fb6978d840', 'smp-81d26521f4dc52bb030b', 25122, 25122, '2026-02-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-qcn240017-02', 'res-cpu'),
+  ('smp-6a627f8a8045fc5d0914', 'smp-81d26521f4dc52bb030b', 25244, 25244, '2026-04-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-qcn240017-04', 'res-cpu'),
+  ('smp-e011e4b777f76f1850da', 'smp-81d26521f4dc52bb030b', 25366, 25366, '2026-06-15 12:00:00', 'smp-35b28b0dfed3e7dfcd36', 'job-qcn240017-06', 'res-cpu'),
+  ('smp-e16f0134dc556cd3a7c1', 'smp-81d26521f4dc52bb030b', 25259, 25259, '2026-02-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-qcn240017-12', 'res-cpu'),
+  ('smp-8bea4370c2aedb5041f0', 'smp-81d26521f4dc52bb030b', 25381, 25381, '2026-04-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-qcn240017-14', 'res-cpu'),
+  ('smp-6f5f0597c0fcc780cf39', 'smp-81d26521f4dc52bb030b', 25503, 25503, '2026-06-15 12:00:00', 'smp-b8e82843b10d1a15b995', 'job-qcn240017-16', 'res-cpu'),
+  ('smp-5699a40460fb16a2423e', 'smp-81d26521f4dc52bb030b', 25396, 25396, '2026-02-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-qcn240017-22', 'res-cpu'),
+  ('smp-9ef9920430d5a62d1381', 'smp-81d26521f4dc52bb030b', 25518, 25518, '2026-04-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-qcn240017-24', 'res-cpu'),
+  ('smp-3a66778317d3640f3405', 'smp-81d26521f4dc52bb030b', 25640, 25640, '2026-06-15 12:00:00', 'smp-c8e6b563ac8f348d6069', 'job-qcn240017-26', 'res-cpu');
+
+-- SES240029: Urban Microclimate Simulation for Heat Resilience
+INSERT IGNORE INTO projects (id, originated_id, title, origination, project_pi_id, status) VALUES
+  ('smp-30a2eabc73a65a860667', 'SES240029', 'Urban Microclimate Simulation for Heat Resilience', 'access', 'smp-02b1cd6cafbd27dd7201', 'ACTIVE');
+INSERT IGNORE INTO compute_allocations (id, project_id, name, status, compute_cluster_id, initial_su_amount, start_time, end_time) VALUES
+  ('smp-0384acb90162ba62c422', 'smp-30a2eabc73a65a860667', 'SES240029 startup allocation', 'ACTIVE', '00000000-0000-0000-0000-000000000001', 250000, '2026-01-01 00:00:00', '2026-12-31 00:00:00');
+INSERT IGNORE INTO project_memberships (project_id, user_id, role) VALUES
+  ('smp-30a2eabc73a65a860667', 'smp-02b1cd6cafbd27dd7201', 'PI'),
+  ('smp-30a2eabc73a65a860667', 'smp-344b252c9dd481944db0', 'CO_PI'),
+  ('smp-30a2eabc73a65a860667', 'smp-90f9c3a872e74ceb9b54', 'ALLOCATION_MANAGER');
+INSERT IGNORE INTO compute_allocation_memberships (id, compute_allocation_id, user_id, start_time, end_time, membership_status) VALUES
+  ('smp-08f7c147f068caa62854', 'smp-0384acb90162ba62c422', 'smp-02b1cd6cafbd27dd7201', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-acee8d6c86e4d40da1e0', 'smp-0384acb90162ba62c422', 'smp-344b252c9dd481944db0', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'INACTIVE'),
+  ('smp-ee0408bdf7f2155c00ac', 'smp-0384acb90162ba62c422', 'smp-318427dda738e0504620', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-a5b622ae224e8db0569e', 'smp-0384acb90162ba62c422', 'smp-2fd62ff24cea6be8de4c', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE'),
+  ('smp-40a595e58ab783c22456', 'smp-0384acb90162ba62c422', 'smp-26420ea0545ca72a32b3', '2026-01-01 00:00:00', '2026-12-31 00:00:00', 'ACTIVE');
+INSERT IGNORE INTO compute_allocation_resource_mappings (id, compute_allocation_id, compute_allocation_resource_id, resource_amount, resource_time) VALUES
+  ('smp-71ee2172bc07537ec447', 'smp-0384acb90162ba62c422', 'res-cpu', 2500, 3600);
+INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_raw_amount, used_su_amount, calculated_time, user_id, job_id, compute_allocation_resource_id) VALUES
+  ('smp-8973a187de40708db0da', 'smp-0384acb90162ba62c422', 12622, 12622, '2026-02-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-ses240029-02', 'res-cpu'),
+  ('smp-33251ff628cd501dc185', 'smp-0384acb90162ba62c422', 12744, 12744, '2026-04-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-ses240029-04', 'res-cpu'),
+  ('smp-34541172ae248bfde92a', 'smp-0384acb90162ba62c422', 12866, 12866, '2026-06-15 12:00:00', 'smp-02b1cd6cafbd27dd7201', 'job-ses240029-06', 'res-cpu'),
+  ('smp-520674936ddbc4a3ca07', 'smp-0384acb90162ba62c422', 12759, 12759, '2026-02-15 12:00:00', 'smp-318427dda738e0504620', 'job-ses240029-12', 'res-cpu'),
+  ('smp-be63b53db20750905ba4', 'smp-0384acb90162ba62c422', 12881, 12881, '2026-04-15 12:00:00', 'smp-318427dda738e0504620', 'job-ses240029-14', 'res-cpu'),
+  ('smp-5f848d4163e3d823d89f', 'smp-0384acb90162ba62c422', 13003, 13003, '2026-06-15 12:00:00', 'smp-318427dda738e0504620', 'job-ses240029-16', 'res-cpu'),
+  ('smp-87de20f2ff9c078f1bad', 'smp-0384acb90162ba62c422', 12896, 12896, '2026-02-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-22', 'res-cpu'),
+  ('smp-63be3f2e1dc38e8cc659', 'smp-0384acb90162ba62c422', 13018, 13018, '2026-04-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-24', 'res-cpu'),
+  ('smp-c90ca83a563957d78e84', 'smp-0384acb90162ba62c422', 13140, 13140, '2026-06-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-26', 'res-cpu');
diff --git a/dev-ops/compose/seeds/sample_resources.sql b/dev-ops/compose/seeds/sample_resources.sql
new file mode 100644
index 000000000..89835a529
--- /dev/null
+++ b/dev-ops/compose/seeds/sample_resources.sql
@@ -0,0 +1,59 @@
+-- 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. Licensed
+-- under the Apache License, Version 2.0.
+--
+-- OPTIONAL sample data: compute clusters, allocation resources, and rate
+-- histories for portal demos and manual testing of the resources and rates
+-- screens. Self-contained (defines its own clusters and resources) and safe
+-- to apply on its own after migrations. Rate windows are chosen so every
+-- status renders: expired, superseded (overlapped by a later start), active,
+-- scheduled, and one resource with no rates at all.
+-- Idempotent via INSERT IGNORE + deterministic IDs.
+
+USE custos;
+
+INSERT IGNORE INTO compute_clusters (id, name) VALUES
+  ('smpr-cluster-a', 'ClusterA'),
+  ('smpr-cluster-b', 'ClusterB');
+
+INSERT IGNORE INTO compute_allocation_resources (id, name, resource_type, resource_amount, compute_cluster_id) VALUES
+  ('smpr-res-a-cpu', 'ClusterA CPU Core Hours', 'CPU_HOURS', 2000000, 'smpr-cluster-a'),
+  ('smpr-res-a-storage', 'ClusterA Project Storage', 'STORAGE_TB', 500, 'smpr-cluster-a'),
+  ('smpr-res-b-gpu', 'ClusterB GPU Hours', 'GPU_HOURS', 150000, 'smpr-cluster-b'),
+  ('smpr-res-b-mem', 'ClusterB Large Memory Hours', 'CPU_HOURS', 300000, 'smpr-cluster-b');
+
+-- ClusterA CPU: full lifecycle. The 2025 window is superseded from
+-- 2026-05-01 by the later-starting 1.25 rate (latest start wins); a 2027
+-- change is already scheduled.
+INSERT IGNORE INTO compute_allocation_resource_rates (id, compute_allocation_resource_id, rate, start_time, end_time) VALUES
+  ('smpr-rate-acpu-2024', 'smpr-res-a-cpu', 1.00, '2024-01-01 00:00:00', '2025-01-01 00:00:00'),
+  ('smpr-rate-acpu-2025', 'smpr-res-a-cpu', 1.10, '2025-01-01 00:00:00', '2026-12-31 00:00:00'),
+  ('smpr-rate-acpu-cur',  'smpr-res-a-cpu', 1.25, '2026-05-01 00:00:00', '2027-05-01 00:00:00'),
+  ('smpr-rate-acpu-next', 'smpr-res-a-cpu', 1.40, '2027-05-01 00:00:00', '2028-05-01 00:00:00');
+
+-- ClusterB GPU: one active rate and one scheduled increase.
+INSERT IGNORE INTO compute_allocation_resource_rates (id, compute_allocation_resource_id, rate, start_time, end_time) VALUES
+  ('smpr-rate-bgpu-cur',  'smpr-res-b-gpu', 8.00, '2026-01-01 00:00:00', '2027-01-01 00:00:00'),
+  ('smpr-rate-bgpu-next', 'smpr-res-b-gpu', 9.50, '2027-01-01 00:00:00', '2028-01-01 00:00:00');
+
+-- ClusterB Large Memory: a single expired rate, so the resource currently
+-- has no effective rate (the gap case).
+INSERT IGNORE INTO compute_allocation_resource_rates (id, compute_allocation_resource_id, rate, start_time, end_time) VALUES
+  ('smpr-rate-bmem-old', 'smpr-res-b-mem', 2.00, '2024-06-01 00:00:00', '2025-06-01 00:00:00');
+
+-- ClusterA Project Storage intentionally has no rates (empty-state case).
+
+-- Two sample users with provisioned cluster accounts so the cluster local
+-- users view has rows. ClusterB intentionally keeps only one account.
+INSERT IGNORE INTO organizations (id, originated_id, name) VALUES
+  ('sample-org', 'sample-org', 'Sample Research Consortium');
+
+INSERT IGNORE INTO users (id, organization_id, first_name, last_name, middle_name, email, status, type) VALUES
+  ('smpr-user-nvasquez', 'sample-org', 'Nadia', 'Vasquez', '', 'nadia.vasquez@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL'),
+  ('smpr-user-tokada', 'sample-org', 'Takeshi', 'Okada', '', 'takeshi.okada@sample.example.edu', 'ACTIVE', 'CLUSTER_LOCAL');
+
+INSERT IGNORE INTO compute_cluster_users (id, compute_cluster_id, user_id, local_username) VALUES
+  ('smpr-ccu-a-1', 'smpr-cluster-a', 'smpr-user-nvasquez', 'nvasquez'),
+  ('smpr-ccu-a-2', 'smpr-cluster-a', 'smpr-user-tokada', 'tokada'),
+  ('smpr-ccu-b-1', 'smpr-cluster-b', 'smpr-user-nvasquez', 'nvasquez');
\ No newline at end of file


(airavata-custos) 05/08: Show allocation and usage aggregates on the resources tab

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

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

commit 40e1eda9d3e88763abecf2312332bddd442d3f9c
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 03:58:08 2026 -0400

    Show allocation and usage aggregates on the resources tab
---
 .../core/clusters/__fixtures__/cluster-users.json  |  8 +--
 .../core/clusters/__fixtures__/clusters.json       |  4 +-
 .../core/clusters/__tests__/ClustersTab.test.tsx   |  6 +--
 .../core/resources/__fixtures__/rates.json         |  4 +-
 .../core/resources/__fixtures__/resources.json     | 18 +++----
 .../core/resources/__tests__/ResourcesTab.test.tsx | 23 +++++++--
 .../features/core/resources/__tests__/api.test.ts  | 21 +++++---
 web/src/features/core/resources/api.ts             | 15 ++----
 .../core/resources/components/ResourcesTab.tsx     | 57 ++++++++++++++++++----
 web/src/features/core/resources/queries.ts         |  6 +--
 web/src/features/core/resources/schemas.ts         | 14 ++++++
 web/src/mocks/handlers/resources.ts                | 23 +++++++++
 web/tests/admin-read-only.e2e.ts                   |  4 +-
 13 files changed, 148 insertions(+), 55 deletions(-)

diff --git a/web/src/features/core/clusters/__fixtures__/cluster-users.json b/web/src/features/core/clusters/__fixtures__/cluster-users.json
index 16338d9e6..792f688f8 100644
--- a/web/src/features/core/clusters/__fixtures__/cluster-users.json
+++ b/web/src/features/core/clusters/__fixtures__/cluster-users.json
@@ -18,14 +18,14 @@
     "local_username": "dritchie"
   },
   {
-    "id": "ccu-bridges2-ada",
-    "compute_cluster_id": "cluster-bridges2",
+    "id": "ccu-c-ada",
+    "compute_cluster_id": "cluster-c",
     "user_id": "user-ada-lovelace",
     "local_username": "ada"
   },
   {
-    "id": "ccu-expanse-katherine",
-    "compute_cluster_id": "cluster-expanse",
+    "id": "ccu-d-katherine",
+    "compute_cluster_id": "cluster-d",
     "user_id": "user-katherine-johnson",
     "local_username": "kjohnson"
   },
diff --git a/web/src/features/core/clusters/__fixtures__/clusters.json b/web/src/features/core/clusters/__fixtures__/clusters.json
index b6e873f04..dc1433077 100644
--- a/web/src/features/core/clusters/__fixtures__/clusters.json
+++ b/web/src/features/core/clusters/__fixtures__/clusters.json
@@ -1,6 +1,6 @@
 [
   { "id": "cluster-a", "name": "ClusterA" },
-  { "id": "cluster-bridges2", "name": "Bridges-2" },
-  { "id": "cluster-expanse", "name": "Expanse" },
+  { "id": "cluster-c", "name": "ClusterC" },
+  { "id": "cluster-d", "name": "ClusterD" },
   { "id": "cluster-b", "name": "ClusterB" }
 ]
diff --git a/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
index bb1d55e91..e584b348c 100644
--- a/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
+++ b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
@@ -32,7 +32,7 @@ import { ClustersTab } from "../components/ClustersTab";
 beforeEach(() => {
   clusters = [
     { id: "cluster-a", name: "ClusterA" },
-    { id: "cluster-bridges2", name: "Bridges-2" },
+    { id: "cluster-c", name: "ClusterC" },
   ];
   users = [
     {
@@ -47,9 +47,9 @@ beforeEach(() => {
 describe("<ClustersTab />", () => {
   it("filters clusters client-side by name", () => {
     render(<ClustersTab />);
-    fireEvent.change(screen.getByLabelText("Search clusters"), { target: { value: "bridges" } });
+    fireEvent.change(screen.getByLabelText("Search clusters"), { target: { value: "clusterc" } });
     expect(screen.queryByText("ClusterA")).not.toBeInTheDocument();
-    expect(screen.getByText("Bridges-2")).toBeInTheDocument();
+    expect(screen.getByText("ClusterC")).toBeInTheDocument();
   });
 
   it("opens a drawer with local users, rendering the user id as plain text", () => {
diff --git a/web/src/features/core/resources/__fixtures__/rates.json b/web/src/features/core/resources/__fixtures__/rates.json
index 4c676c730..ba3fd618a 100644
--- a/web/src/features/core/resources/__fixtures__/rates.json
+++ b/web/src/features/core/resources/__fixtures__/rates.json
@@ -28,8 +28,8 @@
     "end_time": "2021-06-01T00:00:00Z"
   },
   {
-    "id": "rate-bridges2-cpu-current",
-    "compute_allocation_resource_id": "res-bridges2-cpu",
+    "id": "rate-c-cpu-current",
+    "compute_allocation_resource_id": "res-c-cpu",
     "rate": 0.06,
     "start_time": "2022-01-01T00:00:00Z",
     "end_time": "2033-01-01T00:00:00Z"
diff --git a/web/src/features/core/resources/__fixtures__/resources.json b/web/src/features/core/resources/__fixtures__/resources.json
index 664f304a3..05997005c 100644
--- a/web/src/features/core/resources/__fixtures__/resources.json
+++ b/web/src/features/core/resources/__fixtures__/resources.json
@@ -14,25 +14,25 @@
     "compute_cluster_id": "cluster-a"
   },
   {
-    "id": "res-bridges2-cpu",
-    "name": "Bridges-2 RM",
+    "id": "res-c-cpu",
+    "name": "ClusterC RM",
     "resource_type": "CPU",
     "resource_amount": 800000,
-    "compute_cluster_id": "cluster-bridges2"
+    "compute_cluster_id": "cluster-c"
   },
   {
-    "id": "res-bridges2-storage",
-    "name": "Bridges-2 Ocean",
+    "id": "res-c-storage",
+    "name": "ClusterC Ocean",
     "resource_type": "STORAGE",
     "resource_amount": 200000,
-    "compute_cluster_id": "cluster-bridges2"
+    "compute_cluster_id": "cluster-c"
   },
   {
-    "id": "res-expanse-gpu",
-    "name": "Expanse GPU",
+    "id": "res-d-gpu",
+    "name": "ClusterD GPU",
     "resource_type": "GPU",
     "resource_amount": 30000,
-    "compute_cluster_id": "cluster-expanse"
+    "compute_cluster_id": "cluster-d"
   },
   {
     "id": "res-b-cpu",
diff --git a/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
index 218062101..8bcb51c64 100644
--- a/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
+++ b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
@@ -18,16 +18,16 @@
 import { fireEvent, render, screen } from "@testing-library/react";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import type { ComputeCluster } from "@/features/core/clusters/schemas";
-import type { ComputeAllocationResource } from "../schemas";
+import type { ResourceSummary } from "../schemas";
 
-let resources: ComputeAllocationResource[] = [];
+let resources: ResourceSummary[] = [];
 const clusters: ComputeCluster[] = [
   { id: "cluster-a", name: "ClusterA" },
   { id: "cluster-b", name: "ClusterB" },
 ];
 
 vi.mock("../queries", () => ({
-  useResources: () => ({ data: resources, isLoading: false, error: null, refetch: vi.fn() }),
+  useResourceSummaries: () => ({ data: resources, isLoading: false, error: null, refetch: vi.fn() }),
 }));
 
 vi.mock("@/features/core/clusters/queries", () => ({
@@ -52,6 +52,10 @@ beforeEach(() => {
       resource_type: "CPU",
       resource_amount: 1000,
       compute_cluster_id: "cluster-a",
+      allocation_count: 3,
+      total_allocated: 50000,
+      total_used_su: 12500,
+      rate_count: 2,
     },
     {
       id: "res-b-gpu",
@@ -59,6 +63,10 @@ beforeEach(() => {
       resource_type: "GPU",
       resource_amount: 200,
       compute_cluster_id: "cluster-b",
+      allocation_count: 0,
+      total_allocated: 0,
+      total_used_su: 0,
+      rate_count: 0,
     },
   ];
 });
@@ -89,10 +97,17 @@ describe("<ResourcesTab />", () => {
 
   it("opens the rates drawer for the chosen resource", () => {
     render(<ResourcesTab />);
-    fireEvent.click(screen.getAllByRole("button", { name: "Rates" })[0] as HTMLElement);
+    fireEvent.click(screen.getByRole("button", { name: "Rates (2)" }));
     expect(screen.getByText("rates-drawer:ClusterA CPU")).toBeInTheDocument();
   });
 
+  it("renders aggregate columns with a usage bar", () => {
+    render(<ResourcesTab />);
+    expect(screen.getByText("50,000")).toBeInTheDocument();
+    expect(screen.getByText("12,500")).toBeInTheDocument();
+    expect(screen.getAllByText("25.0%").length).toBeGreaterThan(0);
+  });
+
   it("shows the empty state when no resources are registered", () => {
     resources = [];
     render(<ResourcesTab />);
diff --git a/web/src/features/core/resources/__tests__/api.test.ts b/web/src/features/core/resources/__tests__/api.test.ts
index 0f6c4f7e9..fab678e12 100644
--- a/web/src/features/core/resources/__tests__/api.test.ts
+++ b/web/src/features/core/resources/__tests__/api.test.ts
@@ -16,7 +16,7 @@
 // under the License.
 
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { getEffectiveRate, listResourceRates, listResources } from "../api";
+import { getEffectiveRate, listResourceRates, listResourceSummaries } from "../api";
 
 const fetchMock = vi.fn();
 vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
@@ -30,12 +30,16 @@ afterEach(() => {
   fetchMock.mockReset();
 });
 
-const resource = {
+const summary = {
   id: "res-a-cpu",
   name: "ClusterA CPU",
   resource_type: "CPU",
   resource_amount: 1000000,
   compute_cluster_id: "cluster-a",
+  allocation_count: 2,
+  total_allocated: 50000,
+  total_used_su: 12500,
+  rate_count: 3,
 };
 const rate = {
   id: "rate-1",
@@ -45,16 +49,19 @@ const rate = {
   end_time: "2035-01-01T00:00:00Z",
 };
 
-describe("listResources", () => {
-  it("parses a bare array of resources", async () => {
-    fetchMock.mockResolvedValueOnce(mockResponse(200, [resource]));
-    const out = await listResources();
+describe("listResourceSummaries", () => {
+  it("parses a bare array of summary rows", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [summary]));
+    const out = await listResourceSummaries();
     expect(out[0]?.name).toBe("ClusterA CPU");
+    expect(out[0]?.rate_count).toBe(3);
+    const url = fetchMock.mock.calls[0]?.[0] as string;
+    expect(url).toContain("/compute-allocation-resources/summary");
   });
 
   it("returns an empty array when the backend sends null", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, "null"));
-    await expect(listResources()).resolves.toEqual([]);
+    await expect(listResourceSummaries()).resolves.toEqual([]);
   });
 });
 
diff --git a/web/src/features/core/resources/api.ts b/web/src/features/core/resources/api.ts
index 9d5c75510..f623d1da7 100644
--- a/web/src/features/core/resources/api.ts
+++ b/web/src/features/core/resources/api.ts
@@ -17,20 +17,15 @@
 
 import { z } from "zod";
 import { apiFetch } from "@/shared/api/client";
-import {
-  type ComputeAllocationResource,
-  type Rate,
-  computeAllocationResourceSchema,
-  rateSchema,
-} from "./schemas";
+import { type Rate, type ResourceSummary, rateSchema, resourceSummarySchema } from "./schemas";
 
 // The backend serializes empty lists as JSON null (Go nil slice).
-const resourceListSchema = z.array(computeAllocationResourceSchema).nullable();
+const summaryListSchema = z.array(resourceSummarySchema).nullable();
 const rateListSchema = z.array(rateSchema).nullable();
 
-export async function listResources(): Promise<ComputeAllocationResource[]> {
-  const raw = await apiFetch("/compute-allocation-resources");
-  return resourceListSchema.parse(raw) ?? [];
+export async function listResourceSummaries(): Promise<ResourceSummary[]> {
+  const raw = await apiFetch("/compute-allocation-resources/summary");
+  return summaryListSchema.parse(raw) ?? [];
 }
 
 export async function listResourceRates(id: string): Promise<Rate[]> {
diff --git a/web/src/features/core/resources/components/ResourcesTab.tsx b/web/src/features/core/resources/components/ResourcesTab.tsx
index 266b0a16a..e24905040 100644
--- a/web/src/features/core/resources/components/ResourcesTab.tsx
+++ b/web/src/features/core/resources/components/ResourcesTab.tsx
@@ -25,16 +25,17 @@ import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
 import { Input } from "@/shared/ui/input";
 import { CardSkeleton } from "@/shared/ui/Loading";
-import { useResources } from "../queries";
-import type { ComputeAllocationResource } from "../schemas";
+import { UsageBar } from "@/shared/ui/UsageBar";
+import { useResourceSummaries } from "../queries";
+import type { ResourceSummary } from "../schemas";
 import { ResourcesRatesDrawer } from "./ResourcesRatesDrawer";
 
 export function ResourcesTab() {
-  const query = useResources();
+  const query = useResourceSummaries();
   const clustersQuery = useClusters();
   const [search, setSearch] = React.useState("");
   const [clusterId, setClusterId] = React.useState("");
-  const [ratesFor, setRatesFor] = React.useState<ComputeAllocationResource | null>(null);
+  const [ratesFor, setRatesFor] = React.useState<ResourceSummary | null>(null);
 
   const clusters = clustersQuery.data ?? [];
   const clusterName = (id: string) => clusters.find((c) => c.id === id)?.name ?? id;
@@ -49,7 +50,7 @@ export function ResourcesTab() {
     });
   }, [rows, search, clusterId]);
 
-  const columns: Array<DataTableColumn<ComputeAllocationResource>> = [
+  const columns: Array<DataTableColumn<ResourceSummary>> = [
     {
       key: "name",
       header: "Resource",
@@ -68,13 +69,51 @@ export function ResourcesTab() {
       ),
     },
     {
-      key: "resource_amount",
-      header: "Amount",
+      key: "allocation_count",
+      header: "Allocations",
       align: "right",
       cell: (row) => (
-        <span className="text-sm tabular-nums text-foreground">{row.resource_amount}</span>
+        <span className="text-sm tabular-nums text-foreground">{row.allocation_count}</span>
       ),
     },
+    {
+      key: "total_allocated",
+      header: "Allocated",
+      align: "right",
+      cell: (row) => (
+        <span className="text-sm tabular-nums text-foreground">
+          {row.total_allocated.toLocaleString()}
+        </span>
+      ),
+    },
+    {
+      key: "total_used_su",
+      header: "Used SU",
+      align: "right",
+      cell: (row) => (
+        <span className="text-sm tabular-nums text-foreground">
+          {row.total_used_su.toLocaleString()}
+        </span>
+      ),
+    },
+    {
+      key: "usage",
+      header: "Used %",
+      cell: (row) => {
+        const pct = (row.total_used_su / Math.max(1, row.total_allocated)) * 100;
+        return (
+          <div className="w-32" title={`${pct.toFixed(1)}% used`}>
+            <UsageBar
+              value={row.total_used_su}
+              max={Math.max(1, row.total_allocated)}
+              label={`${pct.toFixed(1)}%`}
+              ariaLabel={`${row.name} usage ${pct.toFixed(1)} percent`}
+              size="sm"
+            />
+          </div>
+        );
+      },
+    },
     {
       key: "actions",
       header: "",
@@ -82,7 +121,7 @@ export function ResourcesTab() {
       interactive: true,
       cell: (row) => (
         <Button variant="outline" size="sm" onClick={() => setRatesFor(row)}>
-          Rates
+          Rates ({row.rate_count})
         </Button>
       ),
     },
diff --git a/web/src/features/core/resources/queries.ts b/web/src/features/core/resources/queries.ts
index 607008450..bd8ada1fe 100644
--- a/web/src/features/core/resources/queries.ts
+++ b/web/src/features/core/resources/queries.ts
@@ -18,7 +18,7 @@
 "use client";
 
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { createResourceRate, getEffectiveRate, listResourceRates, listResources } from "./api";
+import { createResourceRate, getEffectiveRate, listResourceRates, listResourceSummaries } from "./api";
 
 export const resourceKeys = {
   all: ["resources"] as const,
@@ -33,10 +33,10 @@ const DEFAULTS = {
   refetchOnWindowFocus: false,
 } as const;
 
-export function useResources() {
+export function useResourceSummaries() {
   return useQuery({
     queryKey: resourceKeys.list(),
-    queryFn: listResources,
+    queryFn: listResourceSummaries,
     ...DEFAULTS,
   });
 }
diff --git a/web/src/features/core/resources/schemas.ts b/web/src/features/core/resources/schemas.ts
index 12f288feb..007ae588a 100644
--- a/web/src/features/core/resources/schemas.ts
+++ b/web/src/features/core/resources/schemas.ts
@@ -19,6 +19,7 @@ import { z } from "zod";
 import {
   zComputeAllocationResource,
   zComputeAllocationResourceRate,
+  zComputeAllocationResourceSummary,
 } from "@/generated/core/zod.gen";
 
 // The generated schema marks every field optional; the backend always returns
@@ -32,6 +33,19 @@ export const computeAllocationResourceSchema = zComputeAllocationResource.requir
 });
 export type ComputeAllocationResource = z.infer<typeof computeAllocationResourceSchema>;
 
+export const resourceSummarySchema = zComputeAllocationResourceSummary.required({
+  id: true,
+  name: true,
+  resource_type: true,
+  resource_amount: true,
+  compute_cluster_id: true,
+  allocation_count: true,
+  total_allocated: true,
+  total_used_su: true,
+  rate_count: true,
+});
+export type ResourceSummary = z.infer<typeof resourceSummarySchema>;
+
 export const rateSchema = zComputeAllocationResourceRate.required({
   id: true,
   compute_allocation_resource_id: true,
diff --git a/web/src/mocks/handlers/resources.ts b/web/src/mocks/handlers/resources.ts
index 3df4bdc6d..2bba51067 100644
--- a/web/src/mocks/handlers/resources.ts
+++ b/web/src/mocks/handlers/resources.ts
@@ -27,9 +27,32 @@ function ratesFor(resourceId: string): Rate[] {
   return rates.filter((r) => r.compute_allocation_resource_id === resourceId);
 }
 
+// Allocation aggregates per resource; rate_count derives from live rate state.
+const ALLOCATION_AGGREGATES: Record<
+  string,
+  { allocation_count: number; total_allocated: number; total_used_su: number }
+> = {
+  "res-a-cpu": { allocation_count: 3, total_allocated: 500000, total_used_su: 312400 },
+  "res-a-gpu": { allocation_count: 2, total_allocated: 40000, total_used_su: 45100 },
+};
+
 export const resourcesHandlers = [
   http.get("*/api/v1/compute-allocation-resources", () => HttpResponse.json(resources)),
 
+  http.get("*/api/v1/compute-allocation-resources/summary", () =>
+    HttpResponse.json(
+      resources.map((r) => ({
+        ...r,
+        ...(ALLOCATION_AGGREGATES[r.id] ?? {
+          allocation_count: 0,
+          total_allocated: 0,
+          total_used_su: 0,
+        }),
+        rate_count: ratesFor(r.id).length,
+      })),
+    ),
+  ),
+
   http.get("*/api/v1/compute-allocation-resources/:id/rates/effective", ({ params, request }) => {
     const id = String(params.id);
     const at = new URL(request.url).searchParams.get("at");
diff --git a/web/tests/admin-read-only.e2e.ts b/web/tests/admin-read-only.e2e.ts
index 1ecedf282..6e06709e3 100644
--- a/web/tests/admin-read-only.e2e.ts
+++ b/web/tests/admin-read-only.e2e.ts
@@ -42,7 +42,7 @@ test.describe("admin resources & clusters read-only sweep", () => {
     await page.getByRole("tab", { name: /^resources$/i }).click();
     await page
       .getByRole("row", { name: /ClusterA CPU/ })
-      .getByRole("button", { name: "Rates" })
+      .getByRole("button", { name: /^Rates \(\d+\)$/ })
       .click();
 
     await expect(page.getByText("Rates: ClusterA CPU")).toBeVisible({ timeout: 15_000 });
@@ -61,7 +61,7 @@ test.describe("admin resources & clusters read-only sweep", () => {
     await page.getByRole("tab", { name: /^resources$/i }).click();
     await page
       .getByRole("row", { name: /ClusterA GPU/ })
-      .getByRole("button", { name: "Rates" })
+      .getByRole("button", { name: /^Rates \(\d+\)$/ })
       .click();
     await expect(page.getByText("Rates: ClusterA GPU")).toBeVisible({ timeout: 15_000 });
 


(airavata-custos) 08/08: Add user profile settings page

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

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

commit 89cecac75a4dd1bd8418be2bb1cc9ad0ab8c2ba2
Author: lahiruj <la...@gmail.com>
AuthorDate: Thu Jul 9 02:56:31 2026 -0400

    Add user profile settings page
---
 .../api.ts => app/(portal)/settings/page.tsx}      |  21 +--
 .../core/identity/__fixtures__/settings.json       |  96 +++++++++++
 .../core/identity/__tests__/privileges.test.ts     |  86 ++++++++++
 .../core/identity/__tests__/schemas.test.ts        |  15 ++
 web/src/features/core/identity/api.ts              |  81 +++++++--
 .../core/identity/components/AccessCard.tsx        | 183 +++++++++++++++++++++
 .../core/identity/components/AppearanceCard.tsx    |  71 ++++++++
 .../core/identity/components/IdentitiesCard.tsx    |  74 +++++++++
 .../core/identity/components/ProfileCard.tsx       | 169 +++++++++++++++++++
 .../core/identity/components/SettingsPage.tsx      |  59 +++++++
 .../components/__tests__/AccessCard.test.tsx       |  98 +++++++++++
 .../__tests__/AppearanceCard.test.tsx}             |  43 +++--
 .../components/__tests__/IdentitiesCard.test.tsx   |  47 ++++++
 .../components/__tests__/ProfileCard.test.tsx      |  84 ++++++++++
 web/src/features/core/identity/privileges.ts       | 124 ++++++++++++++
 web/src/features/core/identity/queries.ts          |  73 +++++++-
 web/src/features/core/identity/schemas.ts          |  56 ++++++-
 web/src/generated/core/index.ts                    |   4 +-
 web/src/generated/core/sdk.gen.ts                  |  15 +-
 web/src/generated/core/types.gen.ts                |  47 ++++++
 web/src/generated/core/zod.gen.ts                  |  22 +++
 .../mocks/__tests__/fixtures-vs-schemas.test.ts    |  42 +++++
 web/src/mocks/handlers.ts                          |   2 +
 web/src/mocks/handlers/identity.ts                 |  53 ++++++
 web/src/mocks/handlers/privileges.ts               |  16 +-
 web/src/shared/layout/UserPill.tsx                 |  21 ++-
 web/tests/settings.e2e.ts                          |  50 ++++++
 27 files changed, 1578 insertions(+), 74 deletions(-)

diff --git a/web/src/features/core/identity/api.ts b/web/src/app/(portal)/settings/page.tsx
similarity index 53%
copy from web/src/features/core/identity/api.ts
copy to web/src/app/(portal)/settings/page.tsx
index 2e36430cc..2a75ce9f7 100644
--- a/web/src/features/core/identity/api.ts
+++ b/web/src/app/(portal)/settings/page.tsx
@@ -15,23 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { apiFetch } from "@/shared/api/client";
-import { auth } from "@/shared/auth/auth";
-import { privilegesResponseSchema } from "./schemas";
-import type { CurrentUser, Privilege } from "./types";
+import { SettingsPage } from "@/features/core/identity/components/SettingsPage";
 
-export async function getCurrentUser(): Promise<CurrentUser | null> {
-  const session = await auth();
-  if (!session?.user) return null;
-  return {
-    id: session.user.id ?? session.user.email ?? "",
-    email: session.user.email ?? "",
-    name: session.user.name ?? session.user.email ?? "",
-    privileges: session.privileges ?? [],
-  };
-}
-
-export async function getPrivileges(): Promise<Privilege[]> {
-  const raw = await apiFetch("/user/privileges");
-  return privilegesResponseSchema.parse(raw);
+export default function Settings() {
+  return <SettingsPage />;
 }
diff --git a/web/src/features/core/identity/__fixtures__/settings.json b/web/src/features/core/identity/__fixtures__/settings.json
new file mode 100644
index 000000000..342241dc5
--- /dev/null
+++ b/web/src/features/core/identity/__fixtures__/settings.json
@@ -0,0 +1,96 @@
+{
+  "user": {
+    "id": "msw-user",
+    "organization_id": "msw-org",
+    "first_name": "Elena",
+    "middle_name": "",
+    "last_name": "Vasquez",
+    "email": "elena.vasquez@sample.example.edu",
+    "status": "ACTIVE",
+    "type": "CLUSTER_LOCAL"
+  },
+  "identities": [
+    {
+      "id": "ident-1",
+      "user_id": "msw-user",
+      "source": "cilogon",
+      "external_id": "elena.vasquez@access-ci.org",
+      "email": "elena.vasquez@access-ci.org",
+      "oidc_sub": "http://cilogon.org/serverA/users/12345",
+      "created_at": "2026-03-12T09:00:00Z"
+    },
+    {
+      "id": "ident-2",
+      "user_id": "msw-user",
+      "source": "comanage",
+      "external_id": "CO-4821",
+      "email": "elena.vasquez@sample.example.edu",
+      "oidc_sub": "",
+      "created_at": "2026-03-14T09:00:00Z"
+    }
+  ],
+  "roles": [
+    {
+      "user_id": "msw-user",
+      "role_id": "role-admin",
+      "granted_by": "portal-admin",
+      "granted_at": "2026-03-12T09:00:00Z",
+      "reason": "Initial administrator"
+    },
+    {
+      "user_id": "msw-user",
+      "role_id": "role-amie",
+      "granted_by": "portal-admin",
+      "granted_at": "2026-04-02T09:00:00Z",
+      "reason": "Operate AMIE exchange"
+    }
+  ],
+  "roleDetails": {
+    "role-admin": {
+      "role": {
+        "id": "role-admin",
+        "name": "Administrator",
+        "description": "Full management of users, projects, allocations, and clusters.",
+        "is_system": true,
+        "created_at": "2026-01-01T00:00:00Z"
+      },
+      "privileges": [
+        "core:users:read",
+        "core:users:write",
+        "core:projects:read",
+        "core:projects:write",
+        "core:allocations:read",
+        "core:allocations:write",
+        "core:clusters:read",
+        "core:clusters:write",
+        "core:roles:manage"
+      ]
+    },
+    "role-amie": {
+      "role": {
+        "id": "role-amie",
+        "name": "AMIE Operator",
+        "description": "Operate the AMIE packet exchange: inspect and reply to packets.",
+        "is_system": false,
+        "created_at": "2026-02-01T00:00:00Z"
+      },
+      "privileges": [
+        "amie:packets:read",
+        "amie:packets:write",
+        "amie:replies:read",
+        "amie:replies:write",
+        "amie:unmapped:read"
+      ]
+    }
+  },
+  "direct": [
+    {
+      "id": "grant-1",
+      "user_id": "msw-user",
+      "privilege": "core:traces:read",
+      "granted_by": "portal-admin",
+      "granted_at": "2026-05-01T09:00:00Z",
+      "reason": "Audit review access"
+    }
+  ]
+}
diff --git a/web/src/features/core/identity/__tests__/privileges.test.ts b/web/src/features/core/identity/__tests__/privileges.test.ts
new file mode 100644
index 000000000..b13d158fe
--- /dev/null
+++ b/web/src/features/core/identity/__tests__/privileges.test.ts
@@ -0,0 +1,86 @@
+// 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 { describe, expect, it } from "vitest";
+import { buildPrivilegeGroups, domainLabel, resourceLabel } from "../privileges";
+import type { MyAccess } from "../queries";
+
+const access: MyAccess = {
+  roles: [
+    {
+      role: { id: "role-admin", name: "Administrator", is_system: true },
+      privileges: ["core:users:read", "core:users:write", "core:roles:manage"],
+      grant: { role_id: "role-admin" },
+    },
+    {
+      role: { id: "role-amie", name: "AMIE Operator" },
+      privileges: ["amie:packets:read", "amie:packets:write"],
+      grant: { role_id: "role-amie" },
+    },
+  ],
+  direct: [{ privilege: "core:traces:read" }],
+  privileges: [
+    "core:users:read",
+    "core:users:write",
+    "core:roles:manage",
+    "core:traces:read",
+    "amie:packets:read",
+    "amie:packets:write",
+    "future:widgets:read",
+  ],
+};
+
+function must<T>(value: T | undefined): T {
+  if (value === undefined) throw new Error("expected a value");
+  return value;
+}
+
+describe("buildPrivilegeGroups", () => {
+  const groups = buildPrivilegeGroups(access);
+  const core = must(groups.find((g) => g.domain === "core"));
+  const users = must(core.rows.find((r) => r.resource === "users"));
+
+  it("groups by domain with title-cased labels, unknown domains generic", () => {
+    expect(domainLabel("core")).toBe("Core");
+    expect(domainLabel("amie")).toBe("AMIE");
+    expect(domainLabel("future")).toBe("Future");
+    expect(resourceLabel("packets")).toBe("Packets");
+    expect(groups.map((g) => g.label).sort()).toEqual(["AMIE", "Core", "Future"]);
+  });
+
+  it("one row per resource with one chip per action", () => {
+    expect(users.actions.map((a) => a.action)).toEqual(["read", "write"]);
+    expect(users.rawKeys).toEqual(["core:users:read", "core:users:write"]);
+  });
+
+  it("attributes provenance: role name for role-derived, Direct grant otherwise", () => {
+    expect(users.provenance).toBe("role");
+    expect(users.roleId).toBe("role-admin");
+    expect(users.provenanceLabel).toBe("Administrator");
+
+    const traces = must(core.rows.find((r) => r.resource === "traces"));
+    expect(traces.provenance).toBe("direct");
+    expect(traces.provenanceLabel).toBe("Direct grant");
+  });
+
+  it("renders unknown-domain keys generically without a hardcoded list", () => {
+    const future = must(groups.find((g) => g.domain === "future"));
+    const row = must(future.rows.at(0));
+    expect(row.resourceLabel).toBe("Widgets");
+    expect(row.provenanceLabel).toBe("Direct grant");
+  });
+});
diff --git a/web/src/features/core/identity/__tests__/schemas.test.ts b/web/src/features/core/identity/__tests__/schemas.test.ts
index 39a9c4e33..d96abc1e7 100644
--- a/web/src/features/core/identity/__tests__/schemas.test.ts
+++ b/web/src/features/core/identity/__tests__/schemas.test.ts
@@ -20,6 +20,7 @@ import {
   callerPrivilegesSchema,
   privilegeKeySchema,
   privilegesResponseSchema,
+  userRolesResponseSchema,
 } from "../schemas";
 
 describe("identity schemas", () => {
@@ -46,4 +47,18 @@ describe("identity schemas", () => {
     ).toEqual(["core:projects:read"]);
     expect(privilegesResponseSchema.parse({})).toEqual([]);
   });
+
+  it("tolerates null granted_by and reason on role grants (backend nullable *string)", () => {
+    const parsed = userRolesResponseSchema.parse([
+      {
+        user_id: "u-1",
+        role_id: "r-1",
+        granted_at: "2026-07-01T00:00:00.000Z",
+        granted_by: null,
+        reason: null,
+      },
+    ]);
+    expect(parsed).toHaveLength(1);
+    expect(parsed[0]?.role_id).toBe("r-1");
+  });
 });
diff --git a/web/src/features/core/identity/api.ts b/web/src/features/core/identity/api.ts
index 2e36430cc..13933f09f 100644
--- a/web/src/features/core/identity/api.ts
+++ b/web/src/features/core/identity/api.ts
@@ -16,22 +16,75 @@
 // under the License.
 
 import { apiFetch } from "@/shared/api/client";
-import { auth } from "@/shared/auth/auth";
-import { privilegesResponseSchema } from "./schemas";
-import type { CurrentUser, Privilege } from "./types";
-
-export async function getCurrentUser(): Promise<CurrentUser | null> {
-  const session = await auth();
-  if (!session?.user) return null;
-  return {
-    id: session.user.id ?? session.user.email ?? "",
-    email: session.user.email ?? "",
-    name: session.user.name ?? session.user.email ?? "",
-    privileges: session.privileges ?? [],
-  };
-}
+import {
+  meResponseSchema,
+  privilegesResponseSchema,
+  roleDetailResponseSchema,
+  userIdentitiesResponseSchema,
+  userPrivilegesResponseSchema,
+  userRolesResponseSchema,
+  userSchema,
+} from "./schemas";
+import type {
+  RoleWithPrivileges,
+  UserIdentity,
+  UserNameUpdate,
+  UserPrivilege,
+  UserProfile,
+} from "./schemas";
+import type { Privilege } from "./types";
 
 export async function getPrivileges(): Promise<Privilege[]> {
   const raw = await apiFetch("/user/privileges");
   return privilegesResponseSchema.parse(raw);
 }
+
+export type Me = { user: UserProfile; privileges: Privilege[] };
+
+export async function getMe(): Promise<Me> {
+  const parsed = meResponseSchema.parse(await apiFetch("/me"));
+  return { user: parsed.user, privileges: parsed.privileges ?? [] };
+}
+
+export async function getMyIdentities(userId: string): Promise<UserIdentity[]> {
+  return userIdentitiesResponseSchema.parse(
+    await apiFetch(`/users/${userId}/user-identities`),
+  );
+}
+
+// No user-scoped roles-with-privileges endpoint exists; compose it from the
+// caller's role grants and each role's privilege detail.
+export async function getMyRolesWithPrivileges(
+  userId: string,
+): Promise<RoleWithPrivileges[]> {
+  const grants = userRolesResponseSchema.parse(
+    await apiFetch(`/users/${userId}/roles`),
+  );
+  const details = await Promise.all(
+    grants.map(async (grant) => {
+      const detail = roleDetailResponseSchema.parse(
+        await apiFetch(`/roles/${grant.role_id}`),
+      );
+      if (!detail.role) return null;
+      return { role: detail.role, privileges: detail.privileges, grant };
+    }),
+  );
+  return details.filter((d): d is RoleWithPrivileges => d !== null);
+}
+
+export async function getMyDirectPrivileges(
+  userId: string,
+): Promise<UserPrivilege[]> {
+  return userPrivilegesResponseSchema.parse(
+    await apiFetch(`/users/${userId}/privileges`),
+  );
+}
+
+export async function updateMyName(
+  userId: string,
+  name: UserNameUpdate,
+): Promise<UserProfile> {
+  return userSchema.parse(
+    await apiFetch(`/users/${userId}`, { method: "PUT", body: name }),
+  );
+}
diff --git a/web/src/features/core/identity/components/AccessCard.tsx b/web/src/features/core/identity/components/AccessCard.tsx
new file mode 100644
index 000000000..a9134c86f
--- /dev/null
+++ b/web/src/features/core/identity/components/AccessCard.tsx
@@ -0,0 +1,183 @@
+// 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 { useState } from "react";
+import { cn } from "@/lib/utils";
+import { Badge } from "@/shared/ui/badge";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
+import { buildPrivilegeGroups } from "../privileges";
+import type { PrivilegeAction } from "../privileges";
+import type { MyAccess } from "../queries";
+import type { RoleWithPrivileges, UserRole } from "../schemas";
+
+function formatDate(iso?: string): string | null {
+  if (!iso) return null;
+  const date = new Date(iso);
+  if (Number.isNaN(date.getTime())) return null;
+  return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+function grantedLine(grant: UserRole): string | null {
+  const when = formatDate(grant.granted_at);
+  const by = grant.granted_by;
+  if (when && by) return `Granted ${when} · by ${by}`;
+  if (when) return `Granted ${when}`;
+  if (by) return `Granted by ${by}`;
+  return null;
+}
+
+const columnHeading = "mb-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase";
+
+function ActionChip({ action }: { action: PrivilegeAction }) {
+  const read = action.action === "read";
+  return (
+    <span
+      className={cn(
+        "rounded-[5px] px-2 py-0.5 text-[11px] font-semibold",
+        read
+          ? "bg-muted text-muted-foreground"
+          : "bg-[color:var(--brand-tint)] text-[color:var(--accent-foreground)]",
+      )}
+    >
+      {action.label}
+    </span>
+  );
+}
+
+export function AccessCard({ access }: { access: MyAccess }) {
+  const [activeRole, setActiveRole] = useState<string | null>(null);
+  const groups = buildPrivilegeGroups(access);
+
+  return (
+    <Card className="gap-0 py-0">
+      <CardHeader className="px-6 pt-5 pb-4">
+        <CardTitle>Roles &amp; effective privileges</CardTitle>
+        <CardDescription>
+          Roles group privileges. Hover a role to see exactly what it grants.
+        </CardDescription>
+      </CardHeader>
+
+      <div className="grid grid-cols-1 border-t border-border md:grid-cols-2">
+        <section className="px-6 py-5 md:border-r md:border-border">
+          <h4 className={columnHeading}>Roles</h4>
+          <RolesColumn
+            roles={access.roles}
+            activeRole={activeRole}
+            onHover={setActiveRole}
+          />
+        </section>
+
+        <section className="border-t border-border px-6 py-5 md:border-t-0">
+          <h4 className={columnHeading}>Effective privileges</h4>
+          {groups.length === 0 ? (
+            <p className="text-sm text-muted-foreground">No privileges granted.</p>
+          ) : (
+            <div className="space-y-4">
+              {groups.map((group) => (
+                <div key={group.domain}>
+                  <div className="mb-1.5 text-xs font-semibold text-foreground">{group.label}</div>
+                  {group.rows.map((row) => {
+                    const highlighted =
+                      activeRole !== null &&
+                      row.provenance === "role" &&
+                      row.roleId === activeRole;
+                    return (
+                      <div
+                        key={row.resource}
+                        title={row.rawKeys.join(" · ")}
+                        className={cn(
+                          "flex items-center justify-between gap-3 rounded-md px-2 py-1 transition-colors",
+                          highlighted && "bg-[color:var(--brand-tint)]",
+                        )}
+                      >
+                        <span className="min-w-[110px] font-medium">{row.resourceLabel}</span>
+                        <span className="flex flex-1 gap-1.5">
+                          {row.actions.map((action) => (
+                            <ActionChip key={action.action} action={action} />
+                          ))}
+                        </span>
+                        <span
+                          className={cn(
+                            "text-[11px] whitespace-nowrap",
+                            row.provenance === "direct"
+                              ? "font-semibold text-[color:var(--accent-foreground)]"
+                              : "text-muted-foreground",
+                          )}
+                        >
+                          {row.provenanceLabel}
+                        </span>
+                      </div>
+                    );
+                  })}
+                </div>
+              ))}
+            </div>
+          )}
+        </section>
+      </div>
+    </Card>
+  );
+}
+
+function RolesColumn({
+  roles,
+  activeRole,
+  onHover,
+}: {
+  roles: RoleWithPrivileges[];
+  activeRole: string | null;
+  onHover: (roleId: string | null) => void;
+}) {
+  if (roles.length === 0) {
+    return <p className="text-sm text-muted-foreground">No roles assigned.</p>;
+  }
+  return (
+    <div className="space-y-2.5">
+      {roles.map((rwp) => {
+        const roleId = rwp.role.id ?? "";
+        const granted = grantedLine(rwp.grant);
+        return (
+          <div
+            key={roleId}
+            data-role-id={roleId}
+            onMouseEnter={() => onHover(roleId)}
+            onMouseLeave={() => onHover(null)}
+            className={cn(
+              "rounded-lg border border-border p-3 transition-colors",
+              activeRole === roleId && "border-brand bg-[color:var(--brand-tint)]",
+            )}
+          >
+            <div className="mb-0.5 flex items-center gap-2">
+              <span className="font-semibold">{rwp.role.name}</span>
+              {rwp.role.is_system ? (
+                <Badge className="bg-[color:var(--brand-tint)] text-[color:var(--accent-foreground)]">
+                  SYSTEM
+                </Badge>
+              ) : null}
+            </div>
+            {rwp.role.description ? (
+              <div className="text-[13px] text-muted-foreground">{rwp.role.description}</div>
+            ) : null}
+            {granted ? <div className="mt-1.5 text-xs text-muted-foreground">{granted}</div> : null}
+          </div>
+        );
+      })}
+    </div>
+  );
+}
diff --git a/web/src/features/core/identity/components/AppearanceCard.tsx b/web/src/features/core/identity/components/AppearanceCard.tsx
new file mode 100644
index 000000000..5b80eb302
--- /dev/null
+++ b/web/src/features/core/identity/components/AppearanceCard.tsx
@@ -0,0 +1,71 @@
+// 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 { useEffect, useState } from "react";
+import { Monitor, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { cn } from "@/lib/utils";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
+
+const CHOICES = [
+  { value: "light", label: "Light", Icon: Sun },
+  { value: "dark", label: "Dark", Icon: Moon },
+  { value: "system", label: "System", Icon: Monitor },
+] as const;
+
+export function AppearanceCard() {
+  const { theme, setTheme } = useTheme();
+  // Theme resolves client-side only; avoid a hydration mismatch on first paint.
+  const [mounted, setMounted] = useState(false);
+  useEffect(() => setMounted(true), []);
+  const current = mounted ? theme : undefined;
+
+  return (
+    <Card className="gap-0 py-0">
+      <CardHeader className="px-6 pt-5 pb-4">
+        <CardTitle>Appearance</CardTitle>
+        <CardDescription>Theme preference is stored per device.</CardDescription>
+      </CardHeader>
+      <CardContent className="px-6 pb-6">
+        <fieldset className="inline-flex gap-0.5 rounded-lg bg-muted p-0.5" aria-label="Theme">
+          {CHOICES.map(({ value, label, Icon }) => {
+            const active = current === value;
+            return (
+              <button
+                key={value}
+                type="button"
+                aria-pressed={active}
+                onClick={() => setTheme(value)}
+                className={cn(
+                  "flex items-center gap-1.5 rounded-md px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
+                  active
+                    ? "bg-card text-foreground shadow-sm"
+                    : "text-muted-foreground hover:text-foreground",
+                )}
+              >
+                <Icon className="h-4 w-4" />
+                {label}
+              </button>
+            );
+          })}
+        </fieldset>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/features/core/identity/components/IdentitiesCard.tsx b/web/src/features/core/identity/components/IdentitiesCard.tsx
new file mode 100644
index 000000000..f560c1b49
--- /dev/null
+++ b/web/src/features/core/identity/components/IdentitiesCard.tsx
@@ -0,0 +1,74 @@
+// 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 { Link2 } from "lucide-react";
+import { Badge } from "@/shared/ui/badge";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
+import type { UserIdentity } from "../schemas";
+
+function formatDate(iso?: string): string | null {
+  if (!iso) return null;
+  const date = new Date(iso);
+  if (Number.isNaN(date.getTime())) return null;
+  return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+export function IdentitiesCard({ identities }: { identities: UserIdentity[] }) {
+  return (
+    <Card className="gap-0 py-0">
+      <CardHeader className="px-6 pt-5 pb-4">
+        <CardTitle>External identities</CardTitle>
+        <CardDescription>
+          Accounts linked to your Custos user. Managed by your identity provider.
+        </CardDescription>
+      </CardHeader>
+      {identities.length === 0 ? (
+        <p className="border-t border-border px-6 py-6 text-sm text-muted-foreground">
+          No identities linked to this account.
+        </p>
+      ) : (
+        <ul className="divide-y divide-border border-t border-border">
+          {identities.map((identity) => {
+            const linked = formatDate(identity.created_at);
+            return (
+              <li
+                key={identity.id ?? `${identity.source}-${identity.external_id}`}
+                className="flex flex-wrap items-center gap-3 px-6 py-3.5"
+              >
+                <Link2 className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+                <span className="min-w-[88px] font-semibold">{identity.source ?? "—"}</span>
+                <span className="flex-1 font-mono text-[13px]">
+                  {identity.external_id || identity.email || "—"}
+                </span>
+                {identity.oidc_sub ? (
+                  <Badge variant="secondary">OIDC</Badge>
+                ) : (
+                  <Badge variant="secondary">Registry</Badge>
+                )}
+                {linked ? (
+                  <span className="ml-auto text-xs text-muted-foreground">Linked {linked}</span>
+                ) : null}
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </Card>
+  );
+}
diff --git a/web/src/features/core/identity/components/ProfileCard.tsx b/web/src/features/core/identity/components/ProfileCard.tsx
new file mode 100644
index 000000000..7c1305cae
--- /dev/null
+++ b/web/src/features/core/identity/components/ProfileCard.tsx
@@ -0,0 +1,169 @@
+// 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 { useState } from "react";
+import { Lock, Pencil } from "lucide-react";
+import { Avatar, AvatarFallback } from "@/shared/ui/avatar";
+import { Button } from "@/shared/ui/button";
+import { Card } from "@/shared/ui/card";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
+import { StatusBadge } from "@/shared/ui/StatusBadge";
+import { useUpdateMyName } from "../queries";
+import type { UserProfile } from "../schemas";
+
+function displayName(user: UserProfile): string {
+  const full = [user.first_name, user.middle_name, user.last_name]
+    .filter(Boolean)
+    .join(" ")
+    .trim();
+  return full || user.email || "Unknown user";
+}
+
+function initials(user: UserProfile): string {
+  const source = displayName(user);
+  const parts = source.split(/\s+/).filter(Boolean);
+  const first = parts.at(0) ?? source;
+  const last = parts.at(-1) ?? "";
+  const chars = parts.length >= 2 ? `${first.charAt(0)}${last.charAt(0)}` : source.slice(0, 2);
+  return chars.toUpperCase();
+}
+
+function formatDate(iso?: string): string | null {
+  if (!iso) return null;
+  const date = new Date(iso);
+  if (Number.isNaN(date.getTime())) return null;
+  return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <div>
+      <div className="mb-0.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
+        {label}
+      </div>
+      <div className="font-medium">{children}</div>
+    </div>
+  );
+}
+
+export function ProfileCard({ user }: { user: UserProfile }) {
+  const [editing, setEditing] = useState(false);
+  const [first, setFirst] = useState(user.first_name ?? "");
+  const [middle, setMiddle] = useState(user.middle_name ?? "");
+  const [last, setLast] = useState(user.last_name ?? "");
+  const update = useUpdateMyName(user.id);
+
+  function openEdit() {
+    setFirst(user.first_name ?? "");
+    setMiddle(user.middle_name ?? "");
+    setLast(user.last_name ?? "");
+    setEditing(true);
+  }
+
+  function onSave() {
+    update.mutate(
+      { first_name: first, middle_name: middle, last_name: last },
+      { onSuccess: () => setEditing(false) },
+    );
+  }
+
+  const memberSince = formatDate(undefined);
+
+  return (
+    <Card className="gap-0 divide-y divide-border py-0">
+      <div className="flex items-center gap-4 px-6 py-5">
+        <Avatar className="h-14 w-14 border-2 border-brand text-lg">
+          <AvatarFallback>{initials(user)}</AvatarFallback>
+        </Avatar>
+        <div className="min-w-0">
+          <div className="truncate text-base font-semibold">{displayName(user)}</div>
+          <div className="truncate text-sm text-muted-foreground">{user.email}</div>
+        </div>
+        <div className="ml-auto flex items-center gap-3">
+          {user.status === "ACTIVE" ? <StatusBadge variant="active" /> : null}
+          <Button variant="outline" size="sm" onClick={openEdit} disabled={editing}>
+            <Pencil className="h-3.5 w-3.5" />
+            Edit name
+          </Button>
+        </div>
+      </div>
+
+      {editing ? (
+        <div className="px-6 py-5">
+          <div className="grid gap-3 sm:grid-cols-3">
+            <div>
+              <Label htmlFor="settings-first-name">First name</Label>
+              <Input
+                id="settings-first-name"
+                className="mt-1"
+                value={first}
+                onChange={(e) => setFirst(e.target.value)}
+              />
+            </div>
+            <div>
+              <Label htmlFor="settings-middle-name">Middle name</Label>
+              <Input
+                id="settings-middle-name"
+                className="mt-1"
+                placeholder="Optional"
+                value={middle}
+                onChange={(e) => setMiddle(e.target.value)}
+              />
+            </div>
+            <div>
+              <Label htmlFor="settings-last-name">Last name</Label>
+              <Input
+                id="settings-last-name"
+                className="mt-1"
+                value={last}
+                onChange={(e) => setLast(e.target.value)}
+              />
+            </div>
+          </div>
+          <div className="mt-4 flex items-center gap-2">
+            <Button variant="brand" size="sm" onClick={onSave} disabled={update.isPending}>
+              {update.isPending ? "Saving…" : "Save"}
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setEditing(false)}
+              disabled={update.isPending}
+            >
+              Cancel
+            </Button>
+            <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
+              <Lock className="h-3 w-3" />
+              Email and linked identities are managed by your identity provider.
+            </span>
+          </div>
+        </div>
+      ) : null}
+
+      <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 px-6 py-5">
+        <Field label="Username">
+          <span className="font-mono text-[13px]">{user.email?.split("@")[0] ?? "—"}</span>
+        </Field>
+        <Field label="Organization">{user.organization_id ?? "—"}</Field>
+        <Field label="Member since">{memberSince ?? "—"}</Field>
+      </div>
+    </Card>
+  );
+}
diff --git a/web/src/features/core/identity/components/SettingsPage.tsx b/web/src/features/core/identity/components/SettingsPage.tsx
new file mode 100644
index 000000000..3126cd14a
--- /dev/null
+++ b/web/src/features/core/identity/components/SettingsPage.tsx
@@ -0,0 +1,59 @@
+// 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 { CenteredSpinner } from "@/shared/ui/Loading";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { useMe, useMyAccess, useMyIdentities } from "../queries";
+import { AccessCard } from "./AccessCard";
+import { AppearanceCard } from "./AppearanceCard";
+import { IdentitiesCard } from "./IdentitiesCard";
+import { ProfileCard } from "./ProfileCard";
+
+export function SettingsPage() {
+  const me = useMe();
+  // The backend user id from /me is authoritative for sub-resource reads;
+  // session.user.id may be an email fallback.
+  const userId = me.data?.user.id;
+  const identities = useMyIdentities(userId);
+  const access = useMyAccess(userId, me.data?.privileges ?? []);
+
+  return (
+    <div className="mx-auto w-full max-w-[880px] px-6 py-8">
+      <header className="mb-7">
+        <h1 className="font-heading text-2xl font-bold tracking-tight">Settings</h1>
+        <p className="text-muted-foreground">
+          Your profile, linked identities, access, and appearance.
+        </p>
+      </header>
+
+      {me.isPending ? (
+        <CenteredSpinner label="Loading your settings" />
+      ) : me.isError || !me.data ? (
+        <ErrorState message="Could not load your profile." onRetry={() => me.refetch()} />
+      ) : (
+        <div className="space-y-6">
+          <ProfileCard user={me.data.user} />
+          {identities.data ? <IdentitiesCard identities={identities.data} /> : null}
+          {access.data ? <AccessCard access={access.data} /> : null}
+          <AppearanceCard />
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx b/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx
new file mode 100644
index 000000000..18271e171
--- /dev/null
+++ b/web/src/features/core/identity/components/__tests__/AccessCard.test.tsx
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AccessCard } from "../AccessCard";
+import type { MyAccess } from "../../queries";
+
+const access: MyAccess = {
+  roles: [
+    {
+      role: {
+        id: "role-admin",
+        name: "Administrator",
+        description: "Full management.",
+        is_system: true,
+      },
+      privileges: ["core:users:read", "core:users:write"],
+      grant: { role_id: "role-admin", granted_by: "portal-admin", granted_at: "2026-03-12T09:00:00Z" },
+    },
+    {
+      role: { id: "role-amie", name: "AMIE Operator" },
+      privileges: ["amie:packets:read", "amie:packets:write"],
+      grant: { role_id: "role-amie" },
+    },
+  ],
+  direct: [{ privilege: "core:traces:read" }],
+  privileges: [
+    "core:users:read",
+    "core:users:write",
+    "core:traces:read",
+    "amie:packets:read",
+    "amie:packets:write",
+  ],
+};
+
+function rowFor(resource: string): HTMLElement {
+  return screen.getByText(resource).closest("div[title]") as HTMLElement;
+}
+
+function roleCard(id: string): HTMLElement {
+  return document.querySelector(`[data-role-id="${id}"]`) as HTMLElement;
+}
+
+describe("AccessCard", () => {
+  it("renders both columns with equal-weight headings and domain groups", () => {
+    render(<AccessCard access={access} />);
+    expect(screen.getByRole("heading", { name: "Roles" })).toBeInTheDocument();
+    expect(screen.getByRole("heading", { name: "Effective privileges" })).toBeInTheDocument();
+    expect(roleCard("role-admin")).toHaveTextContent("Administrator");
+    expect(screen.getByText("SYSTEM")).toBeInTheDocument();
+    // Domain group labels (Core, AMIE) plus resource rows.
+    expect(screen.getByText("Core")).toBeInTheDocument();
+    expect(screen.getByText("AMIE")).toBeInTheDocument();
+    expect(screen.getByText("Users")).toBeInTheDocument();
+    expect(screen.getByText("Packets")).toBeInTheDocument();
+  });
+
+  it("shows one chip per action and direct-grant provenance", () => {
+    render(<AccessCard access={access} />);
+    const users = rowFor("Users");
+    expect(users).toHaveTextContent("Read");
+    expect(users).toHaveTextContent("Write");
+    expect(users).toHaveAttribute("title", "core:users:read · core:users:write");
+    expect(rowFor("Traces")).toHaveTextContent("Direct grant");
+  });
+
+  it("highlights a role's privileges on hover", () => {
+    render(<AccessCard access={access} />);
+    const users = rowFor("Users");
+    const packets = rowFor("Packets");
+    expect(users.className).not.toContain("brand-tint");
+
+    fireEvent.mouseEnter(roleCard("role-admin"));
+    expect(users.className).toContain("brand-tint");
+    // Only the hovered role's rows highlight, not another role's.
+    expect(packets.className).not.toContain("brand-tint");
+  });
+
+  it("renders empty states when the caller has no roles", () => {
+    render(<AccessCard access={{ roles: [], direct: [], privileges: [] }} />);
+    expect(screen.getByText("No roles assigned.")).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/identity/api.ts b/web/src/features/core/identity/components/__tests__/AppearanceCard.test.tsx
similarity index 50%
copy from web/src/features/core/identity/api.ts
copy to web/src/features/core/identity/components/__tests__/AppearanceCard.test.tsx
index 2e36430cc..0d8feabe3 100644
--- a/web/src/features/core/identity/api.ts
+++ b/web/src/features/core/identity/components/__tests__/AppearanceCard.test.tsx
@@ -15,23 +15,30 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { apiFetch } from "@/shared/api/client";
-import { auth } from "@/shared/auth/auth";
-import { privilegesResponseSchema } from "./schemas";
-import type { CurrentUser, Privilege } from "./types";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
 
-export async function getCurrentUser(): Promise<CurrentUser | null> {
-  const session = await auth();
-  if (!session?.user) return null;
-  return {
-    id: session.user.id ?? session.user.email ?? "",
-    email: session.user.email ?? "",
-    name: session.user.name ?? session.user.email ?? "",
-    privileges: session.privileges ?? [],
-  };
-}
+const setTheme = vi.fn();
+vi.mock("next-themes", () => ({
+  useTheme: () => ({ theme: "light", setTheme }),
+}));
 
-export async function getPrivileges(): Promise<Privilege[]> {
-  const raw = await apiFetch("/user/privileges");
-  return privilegesResponseSchema.parse(raw);
-}
+import { AppearanceCard } from "../AppearanceCard";
+
+describe("AppearanceCard", () => {
+  it("marks the active theme as pressed and calls setTheme on click", () => {
+    render(<AppearanceCard />);
+
+    expect(screen.getByRole("button", { name: /light/i })).toHaveAttribute(
+      "aria-pressed",
+      "true",
+    );
+    expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute(
+      "aria-pressed",
+      "false",
+    );
+
+    fireEvent.click(screen.getByRole("button", { name: /dark/i }));
+    expect(setTheme).toHaveBeenCalledWith("dark");
+  });
+});
diff --git a/web/src/features/core/identity/components/__tests__/IdentitiesCard.test.tsx b/web/src/features/core/identity/components/__tests__/IdentitiesCard.test.tsx
new file mode 100644
index 000000000..f1e67caaf
--- /dev/null
+++ b/web/src/features/core/identity/components/__tests__/IdentitiesCard.test.tsx
@@ -0,0 +1,47 @@
+// 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 { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { IdentitiesCard } from "../IdentitiesCard";
+import type { UserIdentity } from "../../schemas";
+
+describe("IdentitiesCard", () => {
+  it("renders an empty state when no identities are linked", () => {
+    render(<IdentitiesCard identities={[]} />);
+    expect(screen.getByText("No identities linked to this account.")).toBeInTheDocument();
+  });
+
+  it("renders a row per identity with source, external id, and type badge", () => {
+    const identities: UserIdentity[] = [
+      {
+        id: "i1",
+        source: "cilogon",
+        external_id: "elena@access-ci.org",
+        oidc_sub: "http://cilogon.org/serverA/users/1",
+        created_at: "2026-03-12T09:00:00Z",
+      },
+      { id: "i2", source: "comanage", external_id: "CO-4821", oidc_sub: "" },
+    ];
+    render(<IdentitiesCard identities={identities} />);
+    expect(screen.getByText("cilogon")).toBeInTheDocument();
+    expect(screen.getByText("elena@access-ci.org")).toBeInTheDocument();
+    expect(screen.getByText("OIDC")).toBeInTheDocument();
+    expect(screen.getByText("comanage")).toBeInTheDocument();
+    expect(screen.getByText("Registry")).toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/identity/components/__tests__/ProfileCard.test.tsx b/web/src/features/core/identity/components/__tests__/ProfileCard.test.tsx
new file mode 100644
index 000000000..4e63fa0f5
--- /dev/null
+++ b/web/src/features/core/identity/components/__tests__/ProfileCard.test.tsx
@@ -0,0 +1,84 @@
+// 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { ProfileCard } from "../ProfileCard";
+import type { UserProfile } from "../../schemas";
+
+const user: UserProfile = {
+  id: "u1",
+  email: "elena.vasquez@sample.example.edu",
+  first_name: "Elena",
+  middle_name: "",
+  last_name: "Vasquez",
+  organization_id: "org-1",
+  status: "ACTIVE",
+  type: "CLUSTER_LOCAL",
+};
+
+let putBody: unknown;
+const server = setupServer(
+  http.put("*/api/v1/users/:id", async ({ request }) => {
+    putBody = await request.json();
+    return HttpResponse.json({ ...user, first_name: "Elena", last_name: "Vasquez-Ng" });
+  }),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+afterEach(() => server.resetHandlers());
+
+function renderCard() {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <ProfileCard user={user} />
+    </QueryClientProvider>,
+  );
+}
+
+describe("ProfileCard", () => {
+  it("shows name, email, ACTIVE badge, and read-only fields", () => {
+    renderCard();
+    expect(screen.getByText("Elena Vasquez")).toBeInTheDocument();
+    expect(screen.getByText("elena.vasquez@sample.example.edu")).toBeInTheDocument();
+    expect(screen.getByText("Active")).toBeInTheDocument();
+    expect(screen.getByText("elena.vasquez")).toBeInTheDocument();
+  });
+
+  it("expands the inline editor and PUTs the name fields on save", async () => {
+    renderCard();
+    fireEvent.click(screen.getByRole("button", { name: /edit name/i }));
+
+    fireEvent.change(screen.getByLabelText(/last name/i), {
+      target: { value: "Vasquez-Ng" },
+    });
+    fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
+
+    await waitFor(() =>
+      expect(putBody).toEqual({
+        first_name: "Elena",
+        middle_name: "",
+        last_name: "Vasquez-Ng",
+      }),
+    );
+  });
+});
diff --git a/web/src/features/core/identity/privileges.ts b/web/src/features/core/identity/privileges.ts
new file mode 100644
index 000000000..a319cb733
--- /dev/null
+++ b/web/src/features/core/identity/privileges.ts
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import type { MyAccess } from "./queries";
+
+// Title-case a slug segment: "unmapped" → "Unmapped", "amie" → "Amie" is
+// then upper-cased for known acronym domains below.
+function titleCase(slug: string): string {
+  return slug
+    .split(/[-_]/)
+    .filter(Boolean)
+    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+    .join(" ");
+}
+
+const DOMAIN_LABELS: Record<string, string> = {
+  core: "Core",
+  amie: "AMIE",
+};
+
+export function domainLabel(domain: string): string {
+  return DOMAIN_LABELS[domain] ?? titleCase(domain);
+}
+
+export function resourceLabel(resource: string): string {
+  return titleCase(resource);
+}
+
+export type ProvenanceKind = "role" | "direct";
+
+export type PrivilegeAction = {
+  action: string;
+  label: string;
+  rawKey: string;
+};
+
+export type PrivilegeRow = {
+  resource: string;
+  resourceLabel: string;
+  actions: PrivilegeAction[];
+  rawKeys: string[];
+  // Where this row's grant comes from: a role (with its id + name) or a direct grant.
+  provenance: ProvenanceKind;
+  roleId?: string;
+  provenanceLabel: string;
+};
+
+export type PrivilegeGroup = {
+  domain: string;
+  label: string;
+  rows: PrivilegeRow[];
+};
+
+type Source = { roleId?: string; kind: ProvenanceKind; label: string };
+
+// Build the domain → resource → actions view from the caller's access payload.
+// Every effective key is placed; its provenance is the first role that grants
+// it, else the direct grant, else (defensively) "Direct grant".
+export function buildPrivilegeGroups(access: MyAccess): PrivilegeGroup[] {
+  const sourceByKey = new Map<string, Source>();
+  for (const rwp of access.roles) {
+    const roleId = rwp.role.id ?? "";
+    const label = rwp.role.name ?? "Role";
+    for (const key of rwp.privileges) {
+      if (!sourceByKey.has(key)) sourceByKey.set(key, { roleId, kind: "role", label });
+    }
+  }
+  for (const grant of access.direct) {
+    const key = grant.privilege;
+    if (key && !sourceByKey.has(key)) {
+      sourceByKey.set(key, { kind: "direct", label: "Direct grant" });
+    }
+  }
+
+  const groups = new Map<string, Map<string, PrivilegeRow>>();
+  for (const key of access.privileges) {
+    const [domain, resource, action] = key.split(":");
+    if (!domain || !resource || !action) continue;
+    const source = sourceByKey.get(key) ?? { kind: "direct" as const, label: "Direct grant" };
+
+    let byResource = groups.get(domain);
+    if (!byResource) {
+      byResource = new Map();
+      groups.set(domain, byResource);
+    }
+    let row = byResource.get(resource);
+    if (!row) {
+      row = {
+        resource,
+        resourceLabel: resourceLabel(resource),
+        actions: [],
+        rawKeys: [],
+        provenance: source.kind,
+        roleId: source.roleId,
+        provenanceLabel: source.label,
+      };
+      byResource.set(resource, row);
+    }
+    if (!row.actions.some((a) => a.action === action)) {
+      row.actions.push({ action, label: titleCase(action), rawKey: key });
+    }
+    row.rawKeys.push(key);
+  }
+
+  return [...groups.entries()].map(([domain, byResource]) => ({
+    domain,
+    label: domainLabel(domain),
+    rows: [...byResource.values()],
+  }));
+}
diff --git a/web/src/features/core/identity/queries.ts b/web/src/features/core/identity/queries.ts
index 4f85fbe91..de3d5d39b 100644
--- a/web/src/features/core/identity/queries.ts
+++ b/web/src/features/core/identity/queries.ts
@@ -17,18 +17,35 @@
 
 "use client";
 
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useSession } from "next-auth/react";
 import { apiFetch } from "@/shared/api/client";
+import {
+  getMe,
+  getMyDirectPrivileges,
+  getMyIdentities,
+  getMyRolesWithPrivileges,
+  updateMyName,
+} from "./api";
 import { privilegesResponseSchema } from "./schemas";
+import type { RoleWithPrivileges, UserNameUpdate, UserPrivilege } from "./schemas";
 import type { CurrentUser, Privilege } from "./types";
 
 export const identityKeys = {
   all: ["identity"] as const,
   current: () => [...identityKeys.all, "current"] as const,
   privileges: () => [...identityKeys.all, "privileges"] as const,
+  me: () => [...identityKeys.all, "me"] as const,
+  identities: (userId: string) => [...identityKeys.all, "identities", userId] as const,
+  access: (userId: string) => [...identityKeys.all, "access", userId] as const,
 };
 
+const DEFAULTS = {
+  staleTime: 30_000,
+  gcTime: 300_000,
+  refetchOnWindowFocus: false,
+} as const;
+
 export function useCurrentUser() {
   const { data: session, status } = useSession();
   const user: CurrentUser | null = session?.user
@@ -51,8 +68,56 @@ export function usePrivileges() {
       return privilegesResponseSchema.parse(raw);
     },
     enabled: status === "authenticated",
-    staleTime: 30_000,
-    gcTime: 300_000,
-    refetchOnWindowFocus: false,
+    ...DEFAULTS,
+  });
+}
+
+export function useMe() {
+  const { status } = useSession();
+  return useQuery({
+    queryKey: identityKeys.me(),
+    queryFn: getMe,
+    enabled: status === "authenticated",
+    ...DEFAULTS,
+  });
+}
+
+export function useMyIdentities(userId: string | undefined) {
+  return useQuery({
+    queryKey: userId ? identityKeys.identities(userId) : [...identityKeys.all, "identities", "none"],
+    queryFn: () => getMyIdentities(userId as string),
+    enabled: Boolean(userId),
+    ...DEFAULTS,
+  });
+}
+
+export type MyAccess = {
+  roles: RoleWithPrivileges[];
+  direct: UserPrivilege[];
+  privileges: Privilege[];
+};
+
+// The access card needs roles, direct grants, and the effective union together;
+// compose them from the caller-scoped endpoints (no single backend endpoint).
+export function useMyAccess(userId: string | undefined, effective: Privilege[]) {
+  return useQuery({
+    queryKey: userId ? identityKeys.access(userId) : [...identityKeys.all, "access", "none"],
+    queryFn: async (): Promise<MyAccess> => {
+      const [roles, direct] = await Promise.all([
+        getMyRolesWithPrivileges(userId as string),
+        getMyDirectPrivileges(userId as string),
+      ]);
+      return { roles, direct, privileges: effective };
+    },
+    enabled: Boolean(userId),
+    ...DEFAULTS,
+  });
+}
+
+export function useUpdateMyName(userId: string | undefined) {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: (name: UserNameUpdate) => updateMyName(userId as string, name),
+    onSuccess: () => client.invalidateQueries({ queryKey: identityKeys.me() }),
   });
 }
diff --git a/web/src/features/core/identity/schemas.ts b/web/src/features/core/identity/schemas.ts
index 56ede3503..05cbf9b5b 100644
--- a/web/src/features/core/identity/schemas.ts
+++ b/web/src/features/core/identity/schemas.ts
@@ -18,6 +18,8 @@
 import { z } from "zod";
 import {
   zRole,
+  zUser,
+  zUserIdentity,
   zUserPrivilege,
   zUserRole,
 } from "@/generated/core/zod.gen";
@@ -25,13 +27,23 @@ import {
 // Any non-empty string. Connector registries extend the catalog at runtime,
 // so the generated core-only enum can't be a tight bound.
 export const privilegeKeySchema = z.string().min(1);
-export const userPrivilegeSchema = zUserPrivilege;
-export const userRoleSchema = zUserRole;
+// granted_by and reason are nullable (*string) on the backend, so they arrive
+// as JSON null; the generated schema only allows undefined, so widen them.
+const nullableGrantMeta = {
+  granted_by: z.string().nullish(),
+  reason: z.string().nullish(),
+};
+export const userPrivilegeSchema = zUserPrivilege.extend(nullableGrantMeta);
+export const userRoleSchema = zUserRole.extend(nullableGrantMeta);
 export const roleSchema = zRole;
+export const userSchema = zUser;
+export const userIdentitySchema = zUserIdentity;
 
 export const roleWithPrivilegesSchema = z.object({
   role: zRole,
   privileges: z.array(privilegeKeySchema),
+  // The caller's grant for this role (granted_at, granted_by, reason).
+  grant: userRoleSchema,
 });
 
 export const callerPrivilegesSchema = z.object({
@@ -42,4 +54,44 @@ export const privilegesResponseSchema = callerPrivilegesSchema.transform(
   (value) => value.privileges ?? [],
 );
 
+// GET /me — profile plus effective privilege keys.
+export const meResponseSchema = z.object({
+  user: zUser,
+  privileges: z.array(privilegeKeySchema).optional(),
+});
+
+// Backend may send null instead of [] for empty lists; tolerate it.
+export const userIdentitiesResponseSchema = z
+  .array(zUserIdentity)
+  .nullish()
+  .transform((value) => value ?? []);
+
+export const userRolesResponseSchema = z
+  .array(userRoleSchema)
+  .nullish()
+  .transform((value) => value ?? []);
+
+export const userPrivilegesResponseSchema = z
+  .array(userPrivilegeSchema)
+  .nullish()
+  .transform((value) => value ?? []);
+
+// GET /roles/{id} — a role and the privilege keys it carries.
+export const roleDetailResponseSchema = z.object({
+  role: zRole.optional(),
+  privileges: z.array(privilegeKeySchema).nullish().transform((v) => v ?? []),
+});
+
+export const userNameUpdateSchema = z.object({
+  first_name: z.string().optional(),
+  middle_name: z.string().optional(),
+  last_name: z.string().optional(),
+});
+
 export type PrivilegeKey = z.infer<typeof privilegeKeySchema>;
+export type UserProfile = z.infer<typeof zUser>;
+export type UserIdentity = z.infer<typeof zUserIdentity>;
+export type UserRole = z.infer<typeof userRoleSchema>;
+export type UserPrivilege = z.infer<typeof userPrivilegeSchema>;
+export type RoleWithPrivileges = z.infer<typeof roleWithPrivilegesSchema>;
+export type UserNameUpdate = z.infer<typeof userNameUpdateSchema>;
diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index c28645ac0..609051d12 100644
--- a/web/src/generated/core/index.ts
+++ b/web/src/generated/core/index.ts
@@ -1,4 +1,4 @@
 // This file is auto-generated by @hey-api/openapi-ts
 
-export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
-export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocationResourceSummary,  [...]
+export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
+export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocationResourceSummary,  [...]
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index bd4bd21f9..5c476e63f 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -2,7 +2,7 @@
 
 import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client';
 import { client } from './client.gen';
-import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
+import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
     /**
@@ -971,6 +971,19 @@ export const getUsersById = <ThrowOnError extends boolean = false>(options: Opti
     ...options
 });
 
+/**
+ * Update a user's name
+ */
+export const putUsersById = <ThrowOnError extends boolean = false>(options: Options<PutUsersByIdData, ThrowOnError>): RequestResult<PutUsersByIdResponses, PutUsersByIdErrors, ThrowOnError> => (options.client ?? client).put<PutUsersByIdResponses, PutUsersByIdErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/users/{id}',
+    ...options,
+    headers: {
+        'Content-Type': 'application/json',
+        ...options.headers
+    }
+});
+
 /**
  * List change requests submitted by a user
  */
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index 9d106a927..8ac6d5f35 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -514,6 +514,12 @@ export type UpdateRoleRequest = {
     name?: string;
 };
 
+export type UserNameUpdateRequest = {
+    first_name?: string;
+    last_name?: string;
+    middle_name?: string;
+};
+
 export type GetAuditEventsData = {
     body?: never;
     path?: never;
@@ -3606,6 +3612,47 @@ export type GetUsersByIdResponses = {
 
 export type GetUsersByIdResponse = GetUsersByIdResponses[keyof GetUsersByIdResponses];
 
+export type PutUsersByIdData = {
+    /**
+     * Name fields
+     */
+    body: UserNameUpdateRequest;
+    path: {
+        /**
+         * User ID
+         */
+        id: string;
+    };
+    query?: never;
+    url: '/users/{id}';
+};
+
+export type PutUsersByIdErrors = {
+    /**
+     * Bad Request
+     */
+    400: {
+        error?: string;
+    };
+    /**
+     * Not Found
+     */
+    404: {
+        error?: string;
+    };
+};
+
+export type PutUsersByIdError = PutUsersByIdErrors[keyof PutUsersByIdErrors];
+
+export type PutUsersByIdResponses = {
+    /**
+     * OK
+     */
+    200: User;
+};
+
+export type PutUsersByIdResponse = PutUsersByIdResponses[keyof PutUsersByIdResponses];
+
 export type GetUsersByIdChangeRequestsData = {
     body?: never;
     path: {
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index c69932055..898f0518c 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -483,6 +483,14 @@ export const zUpdateRoleRequest = z.object({
 
 export type updateRoleRequestZodType = z.infer<typeof zUpdateRoleRequest>;
 
+export const zUserNameUpdateRequest = z.object({
+    first_name: z.string().optional(),
+    last_name: z.string().optional(),
+    middle_name: z.string().optional()
+});
+
+export type userNameUpdateRequestZodType = z.infer<typeof zUserNameUpdateRequest>;
+
 export const zGetAuditEventsQuery = z.object({
     trace_id: z.string(),
     span_id: z.string().optional()
@@ -1358,6 +1366,20 @@ export const zGetUsersByIdPath = z.object({
  */
 export const zGetUsersByIdResponse = zUser;
 
+/**
+ * Name fields
+ */
+export const zPutUsersByIdBody = zUserNameUpdateRequest;
+
+export const zPutUsersByIdPath = z.object({
+    id: z.string()
+});
+
+/**
+ * OK
+ */
+export const zPutUsersByIdResponse = zUser;
+
 export const zGetUsersByIdChangeRequestsPath = z.object({
     id: z.string()
 });
diff --git a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
index 394d7493f..5af637d6a 100644
--- a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
+++ b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
@@ -46,6 +46,14 @@ import {
 import membersFixture from "@/features/core/projects/__fixtures__/members.json";
 import projectsFixture from "@/features/core/projects/__fixtures__/projects.json";
 import { projectMemberSchema, projectSchema } from "@/features/core/projects/schemas";
+import settingsFixture from "@/features/core/identity/__fixtures__/settings.json";
+import {
+  roleDetailResponseSchema,
+  userIdentitySchema,
+  userPrivilegeSchema,
+  userRoleSchema,
+  userSchema,
+} from "@/features/core/identity/schemas";
 
 describe("MSW fixtures pass current Zod schemas", () => {
   it("project fixtures validate against projectSchema (zProjectStatus-backed)", () => {
@@ -111,4 +119,38 @@ describe("MSW fixtures pass current Zod schemas", () => {
     const result = z.array(rateSchema).safeParse(ratesFixture);
     expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
   });
+
+  it("settings /me user validates against userSchema", () => {
+    const result = userSchema.safeParse(settingsFixture.user);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("settings identity fixtures validate against userIdentitySchema", () => {
+    const result = z.array(userIdentitySchema).safeParse(settingsFixture.identities);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("settings role grants validate against userRoleSchema", () => {
+    const result = z.array(userRoleSchema).safeParse(settingsFixture.roles);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("settings role details validate against roleDetailResponseSchema", () => {
+    const result = z
+      .array(roleDetailResponseSchema)
+      .safeParse(Object.values(settingsFixture.roleDetails));
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("settings direct grants validate against userPrivilegeSchema", () => {
+    const result = z.array(userPrivilegeSchema).safeParse(settingsFixture.direct);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("settings role grant ids resolve via roleDetails (internally consistent)", () => {
+    const detailIds = Object.keys(settingsFixture.roleDetails);
+    for (const grant of settingsFixture.roles) {
+      expect(detailIds).toContain(grant.role_id);
+    }
+  });
 });
diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts
index 25ba2bf00..b5f3df526 100644
--- a/web/src/mocks/handlers.ts
+++ b/web/src/mocks/handlers.ts
@@ -20,6 +20,7 @@ import { allocationsHandlers } from "./handlers/allocations";
 import { amieHandlers } from "./handlers/amie";
 import { clustersHandlers } from "./handlers/clusters";
 import { healthzHandlers } from "./handlers/healthz";
+import { identityHandlers } from "./handlers/identity";
 import { organizationsHandlers } from "./handlers/organizations";
 import { privilegesHandlers } from "./handlers/privileges";
 import { projectsHandlers } from "./handlers/projects";
@@ -29,6 +30,7 @@ import { tracesHandlers } from "./handlers/traces";
 export const handlers: RequestHandler[] = [
   ...healthzHandlers,
   ...privilegesHandlers,
+  ...identityHandlers,
   ...projectsHandlers,
   ...organizationsHandlers,
   ...allocationsHandlers,
diff --git a/web/src/mocks/handlers/identity.ts b/web/src/mocks/handlers/identity.ts
new file mode 100644
index 000000000..0fd9d2c73
--- /dev/null
+++ b/web/src/mocks/handlers/identity.ts
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { http, HttpResponse } from "msw";
+import fixture from "@/features/core/identity/__fixtures__/settings.json";
+import { effectivePrivileges } from "./privileges";
+
+type RoleDetail = (typeof fixture.roleDetails)[keyof typeof fixture.roleDetails];
+
+// Per-run mutable copy so PUT /users/{id} name edits are visible on the next
+// /me read within a session.
+let user = { ...fixture.user };
+
+export const identityHandlers = [
+  http.get("*/api/v1/me", () =>
+    HttpResponse.json({ user, privileges: effectivePrivileges() }),
+  ),
+  http.get("*/api/v1/users/:id/user-identities", () =>
+    HttpResponse.json(fixture.identities),
+  ),
+  http.get("*/api/v1/users/:id/roles", () => HttpResponse.json(fixture.roles)),
+  http.get("*/api/v1/roles/:roleId", ({ params }) => {
+    const detail = (fixture.roleDetails as Record<string, RoleDetail | undefined>)[
+      String(params.roleId)
+    ];
+    if (!detail) return HttpResponse.json({ error: "not found" }, { status: 404 });
+    return HttpResponse.json(detail);
+  }),
+  http.get("*/api/v1/users/:id/privileges", () => HttpResponse.json(fixture.direct)),
+  http.put("*/api/v1/users/:id", async ({ request }) => {
+    const body = (await request.json()) as {
+      first_name?: string;
+      middle_name?: string;
+      last_name?: string;
+    };
+    user = { ...user, ...body };
+    return HttpResponse.json(user);
+  }),
+];
diff --git a/web/src/mocks/handlers/privileges.ts b/web/src/mocks/handlers/privileges.ts
index 48f3fe4fd..5d2fbe236 100644
--- a/web/src/mocks/handlers/privileges.ts
+++ b/web/src/mocks/handlers/privileges.ts
@@ -42,16 +42,6 @@ const ALL_PRIVILEGES: Privilege[] = [
   "amie:unmapped:write",
 ];
 
-const MOCK_USER = {
-  id: "msw-user",
-  organization_id: "msw-org",
-  first_name: "MSW",
-  last_name: "User",
-  email: "msw@custos.local",
-  status: "ACTIVE",
-  type: "CLUSTER_LOCAL",
-};
-
 // Test seam: e2e scopes privileges per persona via a non-httpOnly cookie.
 // Unset (MSW-only browsing, unit tests) falls back to full access.
 function effectivePrivileges(): Privilege[] {
@@ -67,11 +57,11 @@ function effectivePrivileges(): Privilege[] {
   return list.length > 0 ? (list as Privilege[]) : ALL_PRIVILEGES;
 }
 
+// Exported so the identity settings handlers share the same effective set.
+export { effectivePrivileges };
+
 export const privilegesHandlers = [
   http.get("*/api/v1/user/privileges", () =>
     HttpResponse.json({ privileges: effectivePrivileges() }),
   ),
-  http.get("*/api/v1/me", () =>
-    HttpResponse.json({ user: MOCK_USER, privileges: effectivePrivileges() }),
-  ),
 ];
diff --git a/web/src/shared/layout/UserPill.tsx b/web/src/shared/layout/UserPill.tsx
index 2940e551b..005cc0cbf 100644
--- a/web/src/shared/layout/UserPill.tsx
+++ b/web/src/shared/layout/UserPill.tsx
@@ -22,9 +22,11 @@ import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
+  DropdownMenuSeparator,
   DropdownMenuTrigger,
 } from "@/shared/ui/dropdown-menu";
-import { LogOut } from "lucide-react";
+import { LogOut, Settings } from "lucide-react";
+import Link from "next/link";
 import { useSession } from "next-auth/react";
 import { useSignOut } from "@/shared/auth/useSignOut";
 
@@ -58,8 +60,23 @@ export function UserPill() {
             </button>
           )}
         />
-        <DropdownMenuContent align="end" className="w-40">
+        <DropdownMenuContent align="end" className="w-64">
+          <div className="px-2 py-1.5">
+            <div className="truncate text-sm font-semibold text-foreground">{name}</div>
+            <div className="truncate text-xs text-muted-foreground">{email}</div>
+          </div>
+          <DropdownMenuSeparator />
           <DropdownMenuItem
+            render={(props) => (
+              <Link {...props} href="/settings">
+                <Settings className="mr-2 h-4 w-4" />
+                Settings
+              </Link>
+            )}
+          />
+          <DropdownMenuSeparator />
+          <DropdownMenuItem
+            variant="destructive"
             disabled={isPending}
             onClick={() => void signOut({ callbackUrl: "/sign-in" })}
           >
diff --git a/web/tests/settings.e2e.ts b/web/tests/settings.e2e.ts
new file mode 100644
index 000000000..f667a43ed
--- /dev/null
+++ b/web/tests/settings.e2e.ts
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { expect, test } from "@playwright/test";
+import { signInAs } from "./fixtures/auth";
+
+test.describe("settings", () => {
+  test("account menu opens the settings page and theme persists across reload", async ({
+    page,
+  }) => {
+    await signInAs(page, "admin");
+    await page.goto("/");
+
+    // Open the account menu and navigate to Settings.
+    await page.getByRole("button", { name: /account menu/i }).click();
+    await page.getByRole("menuitem", { name: /settings/i }).click();
+    await expect(page).toHaveURL(/\/settings$/);
+    await expect(page.getByRole("heading", { name: /^Settings$/ })).toBeVisible();
+
+    // The identity cards render against the seeded MSW fixtures.
+    await expect(page.getByRole("heading", { name: "Roles" })).toBeVisible();
+    await expect(page.getByRole("heading", { name: "Effective privileges" })).toBeVisible();
+
+    // Switch to the dark theme and confirm it applies.
+    await page.getByRole("button", { name: /^dark$/i }).click();
+    await expect(page.locator("html")).toHaveClass(/dark/);
+
+    // Reload and confirm the choice persisted (next-themes localStorage).
+    await page.reload();
+    await expect(page.getByRole("button", { name: /^dark$/i })).toHaveAttribute(
+      "aria-pressed",
+      "true",
+    );
+    await expect(page.locator("html")).toHaveClass(/dark/);
+  });
+});


(airavata-custos) 02/08: Clusters, resources, and resource rate management UI components

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

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

commit 918722dbcf09c073191b2c174d4aa9b5da819bcc
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 02:30:19 2026 -0400

    Clusters, resources, and resource rate management UI components
---
 .../core/clusters/__fixtures__/cluster-users.json  |  16 +-
 .../core/clusters/__fixtures__/clusters.json       |   4 +-
 .../core/clusters/__tests__/ClustersTab.test.tsx   |  10 +-
 .../features/core/clusters/__tests__/api.test.ts   |  24 ++-
 .../core/clusters/__tests__/queries.test.ts        |   4 +-
 .../core/clusters/__tests__/schemas.test.ts        |   8 +-
 web/src/features/core/clusters/api.ts              |   8 +-
 .../core/resources/__fixtures__/rates.json         |  16 +-
 .../core/resources/__fixtures__/resources.json     |  24 +--
 .../__tests__/ResourcesRatesDrawer.test.tsx        | 190 +++++++++++++++----
 .../core/resources/__tests__/ResourcesTab.test.tsx |  34 ++--
 .../features/core/resources/__tests__/api.test.ts  |  24 ++-
 .../core/resources/__tests__/queries.test.tsx      |  57 ++++++
 .../core/resources/__tests__/schemas.test.ts       |   8 +-
 web/src/features/core/resources/api.ts             |  23 ++-
 .../resources/components/ResourcesRatesDrawer.tsx  | 207 +++++++++++++++++++--
 web/src/features/core/resources/queries.ts         |  15 +-
 web/src/features/core/resources/schemas.ts         |  31 ++-
 web/src/mocks/handlers/resources.ts                |  13 ++
 web/tests/admin-read-only.e2e.ts                   |  33 +++-
 web/tests/admin-viewer.e2e.ts                      |   9 +
 21 files changed, 620 insertions(+), 138 deletions(-)

diff --git a/web/src/features/core/clusters/__fixtures__/cluster-users.json b/web/src/features/core/clusters/__fixtures__/cluster-users.json
index 88ac10a6e..16338d9e6 100644
--- a/web/src/features/core/clusters/__fixtures__/cluster-users.json
+++ b/web/src/features/core/clusters/__fixtures__/cluster-users.json
@@ -1,19 +1,19 @@
 [
   {
-    "id": "ccu-anvil-ada",
-    "compute_cluster_id": "cluster-anvil",
+    "id": "ccu-a-ada",
+    "compute_cluster_id": "cluster-a",
     "user_id": "user-ada-lovelace",
     "local_username": "alovelace"
   },
   {
-    "id": "ccu-anvil-grace",
-    "compute_cluster_id": "cluster-anvil",
+    "id": "ccu-a-grace",
+    "compute_cluster_id": "cluster-a",
     "user_id": "user-grace-hopper",
     "local_username": "ghopper"
   },
   {
-    "id": "ccu-anvil-dennis",
-    "compute_cluster_id": "cluster-anvil",
+    "id": "ccu-a-dennis",
+    "compute_cluster_id": "cluster-a",
     "user_id": "user-dennis-ritchie",
     "local_username": "dritchie"
   },
@@ -30,8 +30,8 @@
     "local_username": "kjohnson"
   },
   {
-    "id": "ccu-delta-donald",
-    "compute_cluster_id": "cluster-delta",
+    "id": "ccu-b-donald",
+    "compute_cluster_id": "cluster-b",
     "user_id": "user-donald-knuth",
     "local_username": "dknuth"
   }
diff --git a/web/src/features/core/clusters/__fixtures__/clusters.json b/web/src/features/core/clusters/__fixtures__/clusters.json
index 6715d7894..b6e873f04 100644
--- a/web/src/features/core/clusters/__fixtures__/clusters.json
+++ b/web/src/features/core/clusters/__fixtures__/clusters.json
@@ -1,6 +1,6 @@
 [
-  { "id": "cluster-anvil", "name": "Anvil" },
+  { "id": "cluster-a", "name": "ClusterA" },
   { "id": "cluster-bridges2", "name": "Bridges-2" },
   { "id": "cluster-expanse", "name": "Expanse" },
-  { "id": "cluster-delta", "name": "Delta" }
+  { "id": "cluster-b", "name": "ClusterB" }
 ]
diff --git a/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
index eb83300f1..bb1d55e91 100644
--- a/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
+++ b/web/src/features/core/clusters/__tests__/ClustersTab.test.tsx
@@ -31,13 +31,13 @@ import { ClustersTab } from "../components/ClustersTab";
 
 beforeEach(() => {
   clusters = [
-    { id: "cluster-anvil", name: "Anvil" },
+    { id: "cluster-a", name: "ClusterA" },
     { id: "cluster-bridges2", name: "Bridges-2" },
   ];
   users = [
     {
       id: "ccu-1",
-      compute_cluster_id: "cluster-anvil",
+      compute_cluster_id: "cluster-a",
       user_id: "user-ada-lovelace",
       local_username: "alovelace",
     },
@@ -48,15 +48,15 @@ describe("<ClustersTab />", () => {
   it("filters clusters client-side by name", () => {
     render(<ClustersTab />);
     fireEvent.change(screen.getByLabelText("Search clusters"), { target: { value: "bridges" } });
-    expect(screen.queryByText("Anvil")).not.toBeInTheDocument();
+    expect(screen.queryByText("ClusterA")).not.toBeInTheDocument();
     expect(screen.getByText("Bridges-2")).toBeInTheDocument();
   });
 
   it("opens a drawer with local users, rendering the user id as plain text", () => {
     render(<ClustersTab />);
-    fireEvent.click(screen.getByRole("button", { name: "Anvil" }));
+    fireEvent.click(screen.getByRole("button", { name: "ClusterA" }));
 
-    expect(screen.getByText("Cluster: Anvil")).toBeInTheDocument();
+    expect(screen.getByText("Cluster: ClusterA")).toBeInTheDocument();
     expect(screen.getByText("alovelace")).toBeInTheDocument();
     expect(screen.getByText("user-ada-lovelace")).toBeInTheDocument();
     expect(screen.queryByRole("link", { name: "user-ada-lovelace" })).not.toBeInTheDocument();
diff --git a/web/src/features/core/clusters/__tests__/api.test.ts b/web/src/features/core/clusters/__tests__/api.test.ts
index 7e0069b9d..5e1bf4951 100644
--- a/web/src/features/core/clusters/__tests__/api.test.ts
+++ b/web/src/features/core/clusters/__tests__/api.test.ts
@@ -30,10 +30,10 @@ afterEach(() => {
   fetchMock.mockReset();
 });
 
-const cluster = { id: "cluster-anvil", name: "Anvil" };
+const cluster = { id: "cluster-a", name: "ClusterA" };
 const clusterUser = {
   id: "ccu-1",
-  compute_cluster_id: "cluster-anvil",
+  compute_cluster_id: "cluster-a",
   user_id: "user-ada-lovelace",
   local_username: "alovelace",
 };
@@ -43,22 +43,34 @@ describe("listClusters", () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, [cluster]));
     const out = await listClusters();
     expect(out).toHaveLength(1);
-    expect(out[0]?.name).toBe("Anvil");
+    expect(out[0]?.name).toBe("ClusterA");
+  });
+
+  it("returns an empty array when the backend sends null", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, "null"));
+    await expect(listClusters()).resolves.toEqual([]);
+  });
+});
+
+describe("listClusterUsers null body", () => {
+  it("returns an empty array when the backend sends null", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, "null"));
+    await expect(listClusterUsers("cluster-a")).resolves.toEqual([]);
   });
 });
 
 describe("getCluster", () => {
   it("parses a single cluster", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, cluster));
-    const out = await getCluster("cluster-anvil");
-    expect(out.id).toBe("cluster-anvil");
+    const out = await getCluster("cluster-a");
+    expect(out.id).toBe("cluster-a");
   });
 });
 
 describe("listClusterUsers", () => {
   it("parses a bare array of cluster users", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, [clusterUser]));
-    const out = await listClusterUsers("cluster-anvil");
+    const out = await listClusterUsers("cluster-a");
     expect(out[0]?.local_username).toBe("alovelace");
   });
 });
diff --git a/web/src/features/core/clusters/__tests__/queries.test.ts b/web/src/features/core/clusters/__tests__/queries.test.ts
index e602a7fef..7c9caeb6c 100644
--- a/web/src/features/core/clusters/__tests__/queries.test.ts
+++ b/web/src/features/core/clusters/__tests__/queries.test.ts
@@ -24,7 +24,7 @@ describe("clusterKeys", () => {
   });
 
   it("detail and users keys carry the id", () => {
-    expect(clusterKeys.detail("cluster-anvil")).toEqual(["clusters", "detail", "cluster-anvil"]);
-    expect(clusterKeys.users("cluster-anvil")).toEqual(["clusters", "users", "cluster-anvil"]);
+    expect(clusterKeys.detail("cluster-a")).toEqual(["clusters", "detail", "cluster-a"]);
+    expect(clusterKeys.users("cluster-a")).toEqual(["clusters", "users", "cluster-a"]);
   });
 });
diff --git a/web/src/features/core/clusters/__tests__/schemas.test.ts b/web/src/features/core/clusters/__tests__/schemas.test.ts
index 65fe62c0e..3e07a6095 100644
--- a/web/src/features/core/clusters/__tests__/schemas.test.ts
+++ b/web/src/features/core/clusters/__tests__/schemas.test.ts
@@ -20,11 +20,11 @@ import { computeClusterSchema, computeClusterUserSchema } from "../schemas";
 
 describe("computeClusterSchema", () => {
   it("accepts a cluster with id and name", () => {
-    expect(computeClusterSchema.parse({ id: "cluster-anvil", name: "Anvil" }).name).toBe("Anvil");
+    expect(computeClusterSchema.parse({ id: "cluster-a", name: "ClusterA" }).name).toBe("ClusterA");
   });
 
   it("rejects a cluster missing its name", () => {
-    expect(computeClusterSchema.safeParse({ id: "cluster-anvil" }).success).toBe(false);
+    expect(computeClusterSchema.safeParse({ id: "cluster-a" }).success).toBe(false);
   });
 });
 
@@ -32,7 +32,7 @@ describe("computeClusterUserSchema", () => {
   it("accepts a fully populated cluster user", () => {
     const parsed = computeClusterUserSchema.parse({
       id: "ccu-1",
-      compute_cluster_id: "cluster-anvil",
+      compute_cluster_id: "cluster-a",
       user_id: "user-ada-lovelace",
       local_username: "alovelace",
     });
@@ -42,7 +42,7 @@ describe("computeClusterUserSchema", () => {
   it("rejects a cluster user missing its local username", () => {
     const result = computeClusterUserSchema.safeParse({
       id: "ccu-1",
-      compute_cluster_id: "cluster-anvil",
+      compute_cluster_id: "cluster-a",
       user_id: "user-ada-lovelace",
     });
     expect(result.success).toBe(false);
diff --git a/web/src/features/core/clusters/api.ts b/web/src/features/core/clusters/api.ts
index 5b6cf7356..9f15fdc3e 100644
--- a/web/src/features/core/clusters/api.ts
+++ b/web/src/features/core/clusters/api.ts
@@ -24,9 +24,13 @@ import {
   computeClusterUserSchema,
 } from "./schemas";
 
+// The backend serializes empty lists as JSON null (Go nil slice).
+const clusterListSchema = z.array(computeClusterSchema).nullable();
+const clusterUserListSchema = z.array(computeClusterUserSchema).nullable();
+
 export async function listClusters(): Promise<ComputeCluster[]> {
   const raw = await apiFetch("/compute-clusters");
-  return z.array(computeClusterSchema).parse(raw);
+  return clusterListSchema.parse(raw) ?? [];
 }
 
 export async function getCluster(id: string): Promise<ComputeCluster> {
@@ -36,5 +40,5 @@ export async function getCluster(id: string): Promise<ComputeCluster> {
 
 export async function listClusterUsers(id: string): Promise<ComputeClusterUser[]> {
   const raw = await apiFetch(`/compute-clusters/${id}/users`);
-  return z.array(computeClusterUserSchema).parse(raw);
+  return clusterUserListSchema.parse(raw) ?? [];
 }
diff --git a/web/src/features/core/resources/__fixtures__/rates.json b/web/src/features/core/resources/__fixtures__/rates.json
index 833ca49ea..4c676c730 100644
--- a/web/src/features/core/resources/__fixtures__/rates.json
+++ b/web/src/features/core/resources/__fixtures__/rates.json
@@ -1,28 +1,28 @@
 [
   {
-    "id": "rate-anvil-cpu-current",
-    "compute_allocation_resource_id": "res-anvil-cpu",
+    "id": "rate-a-cpu-current",
+    "compute_allocation_resource_id": "res-a-cpu",
     "rate": 0.05,
     "start_time": "2020-01-01T00:00:00Z",
     "end_time": "2035-01-01T00:00:00Z"
   },
   {
-    "id": "rate-anvil-cpu-old",
-    "compute_allocation_resource_id": "res-anvil-cpu",
+    "id": "rate-a-cpu-old",
+    "compute_allocation_resource_id": "res-a-cpu",
     "rate": 0.04,
     "start_time": "2018-01-01T00:00:00Z",
     "end_time": "2020-01-01T00:00:00Z"
   },
   {
-    "id": "rate-anvil-gpu-current",
-    "compute_allocation_resource_id": "res-anvil-gpu",
+    "id": "rate-a-gpu-current",
+    "compute_allocation_resource_id": "res-a-gpu",
     "rate": 0.5,
     "start_time": "2021-06-01T00:00:00Z",
     "end_time": "2035-06-01T00:00:00Z"
   },
   {
-    "id": "rate-anvil-gpu-old",
-    "compute_allocation_resource_id": "res-anvil-gpu",
+    "id": "rate-a-gpu-old",
+    "compute_allocation_resource_id": "res-a-gpu",
     "rate": 0.45,
     "start_time": "2019-06-01T00:00:00Z",
     "end_time": "2021-06-01T00:00:00Z"
diff --git a/web/src/features/core/resources/__fixtures__/resources.json b/web/src/features/core/resources/__fixtures__/resources.json
index d4eeb62bb..664f304a3 100644
--- a/web/src/features/core/resources/__fixtures__/resources.json
+++ b/web/src/features/core/resources/__fixtures__/resources.json
@@ -1,17 +1,17 @@
 [
   {
-    "id": "res-anvil-cpu",
-    "name": "Anvil CPU",
+    "id": "res-a-cpu",
+    "name": "ClusterA CPU",
     "resource_type": "CPU",
     "resource_amount": 1000000,
-    "compute_cluster_id": "cluster-anvil"
+    "compute_cluster_id": "cluster-a"
   },
   {
-    "id": "res-anvil-gpu",
-    "name": "Anvil GPU",
+    "id": "res-a-gpu",
+    "name": "ClusterA GPU",
     "resource_type": "GPU",
     "resource_amount": 50000,
-    "compute_cluster_id": "cluster-anvil"
+    "compute_cluster_id": "cluster-a"
   },
   {
     "id": "res-bridges2-cpu",
@@ -35,17 +35,17 @@
     "compute_cluster_id": "cluster-expanse"
   },
   {
-    "id": "res-delta-cpu",
-    "name": "Delta CPU",
+    "id": "res-b-cpu",
+    "name": "ClusterB CPU",
     "resource_type": "CPU",
     "resource_amount": 600000,
-    "compute_cluster_id": "cluster-delta"
+    "compute_cluster_id": "cluster-b"
   },
   {
-    "id": "res-delta-gpu",
-    "name": "Delta GPU A100",
+    "id": "res-b-gpu",
+    "name": "ClusterB GPU A100",
     "resource_type": "GPU",
     "resource_amount": 40000,
-    "compute_cluster_id": "cluster-delta"
+    "compute_cluster_id": "cluster-b"
   }
 ]
diff --git a/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx b/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx
index a7ad1445b..f743edff6 100644
--- a/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx
+++ b/web/src/features/core/resources/__tests__/ResourcesRatesDrawer.test.tsx
@@ -15,68 +15,192 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import type { Rate } from "../schemas";
 
-// Windows are anchored far in the past/future so the ACTIVE/EXPIRED derivation
-// stays stable regardless of the wall clock at test time.
-const activeRate: Rate = {
-  id: "rate-current",
-  compute_allocation_resource_id: "res-1",
-  rate: 0.05,
-  start_time: "2000-01-01T00:00:00Z",
-  end_time: "2100-01-01T00:00:00Z",
-};
-const expiredRate: Rate = {
-  id: "rate-old",
-  compute_allocation_resource_id: "res-1",
-  rate: 0.04,
-  start_time: "2000-01-01T00:00:00Z",
-  end_time: "2001-01-01T00:00:00Z",
-};
+// Windows are anchored far in the past/future so classifyRate stays stable
+// regardless of the wall clock at test time (assumes now is 2011..2098).
+const expiredRate: Rate = rate("rate-expired", 0.03, "2000-01-01T00:00:00Z", "2001-01-01T00:00:00Z");
+const supersededRate: Rate = rate("rate-super", 0.04, "2001-01-01T00:00:00Z", "2100-01-01T00:00:00Z");
+const activeRate: Rate = rate("rate-active", 0.05, "2010-01-01T00:00:00Z", "2100-01-01T00:00:00Z");
+const scheduledRate: Rate = rate("rate-sched", 0.06, "2099-01-01T00:00:00Z", "2100-01-01T00:00:00Z");
+
+function rate(id: string, value: number, start: string, end: string): Rate {
+  return { id, compute_allocation_resource_id: "res-1", rate: value, start_time: start, end_time: end };
+}
 
 let rates: Rate[] = [];
 let effective: Rate | undefined;
+let canManage = true;
+const mutation = { mutateAsync: vi.fn(), isPending: false };
 
 vi.mock("../queries", () => ({
   useResourceRates: () => ({ data: rates, isLoading: false, error: null, refetch: vi.fn() }),
   useEffectiveRate: () => ({ data: effective, isLoading: false, error: null, refetch: vi.fn() }),
+  useCreateResourceRate: () => mutation,
+}));
+
+vi.mock("@/shared/casl/AbilityProvider", () => ({
+  useAbility: () => ({ can: () => canManage }),
+}));
+
+vi.mock("sonner", () => ({
+  toast: { success: vi.fn(), error: vi.fn() },
 }));
 
+import { toast } from "sonner";
 import { ResourcesRatesDrawer } from "../components/ResourcesRatesDrawer";
 
+function renderDrawer() {
+  return render(
+    <ResourcesRatesDrawer resourceId="res-1" resourceName="ClusterA CPU" onOpenChange={vi.fn()} />,
+  );
+}
+
+function openForm() {
+  fireEvent.click(screen.getByRole("button", { name: "+ Add rate" }));
+}
+
+function todayUtc(): string {
+  return new Date().toISOString().slice(0, 10);
+}
+
 beforeEach(() => {
-  rates = [activeRate, expiredRate];
+  rates = [expiredRate, supersededRate, activeRate, scheduledRate];
   effective = activeRate;
+  canManage = true;
+  mutation.isPending = false;
+  mutation.mutateAsync = vi.fn().mockResolvedValue({});
+  vi.mocked(toast.success).mockClear();
+  vi.mocked(toast.error).mockClear();
 });
 
 describe("<ResourcesRatesDrawer />", () => {
-  it("derives ACTIVE and EXPIRED badges per rate window", () => {
-    render(
-      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
+  it("renders the effective card with an ACTIVE badge, omitted on 404", () => {
+    const { rerender } = renderDrawer();
+    expect(screen.getByText("Effective rate")).toBeInTheDocument();
+
+    effective = undefined;
+    rerender(
+      <ResourcesRatesDrawer resourceId="res-1" resourceName="ClusterA CPU" onOpenChange={vi.fn()} />,
     );
-    // One badge in the effective card plus one per active history row.
-    expect(screen.getAllByText("ACTIVE").length).toBeGreaterThanOrEqual(2);
-    expect(screen.getByText("EXPIRED")).toBeInTheDocument();
+    expect(screen.queryByText("Effective rate")).not.toBeInTheDocument();
   });
 
-  it("titles the drawer with the resource name and shows the effective rate", () => {
-    render(
-      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
+  it("classifies each row and sorts start_time DESC", () => {
+    effective = undefined; // keep ACTIVE unique to the table
+    renderDrawer();
+    const rows = screen.getAllByRole("row").slice(1); // drop header
+    const badges = rows.map((r) => within(r).getByText(/ACTIVE|SCHEDULED|SUPERSEDED|EXPIRED/).textContent);
+    expect(badges).toEqual(["SCHEDULED", "ACTIVE", "SUPERSEDED", "EXPIRED"]);
+  });
+
+  it("hides Add rate without manage Allocation and shows it with it", () => {
+    canManage = false;
+    const { rerender } = renderDrawer();
+    expect(screen.queryByRole("button", { name: "+ Add rate" })).not.toBeInTheDocument();
+
+    canManage = true;
+    rerender(
+      <ResourcesRatesDrawer resourceId="res-1" resourceName="ClusterA CPU" onOpenChange={vi.fn()} />,
+    );
+    expect(screen.getByRole("button", { name: "+ Add rate" })).toBeInTheDocument();
+  });
+
+  it("opens the form with end date pre-filled to start + 1 year", () => {
+    renderDrawer();
+    openForm();
+    const start = todayUtc();
+    const expectedEnd = new Date(start);
+    expectedEnd.setUTCFullYear(expectedEnd.getUTCFullYear() + 1);
+    expect((screen.getByLabelText("Effective date") as HTMLInputElement).value).toBe(start);
+    expect((screen.getByLabelText("Ends") as HTMLInputElement).value).toBe(
+      expectedEnd.toISOString().slice(0, 10),
     );
-    expect(screen.getByText("Rates: Anvil CPU")).toBeInTheDocument();
-    expect(screen.getByText("Effective rate")).toBeInTheDocument();
   });
 
-  it("shows the empty state when no rates are recorded", () => {
+  it("rejects a negative rate inline without posting", async () => {
+    renderDrawer();
+    openForm();
+    fireEvent.change(screen.getByLabelText("Rate"), { target: { value: "-1" } });
+    fireEvent.click(screen.getByRole("button", { name: "Add rate" }));
+    expect(await screen.findByText(/zero or greater/i)).toBeInTheDocument();
+    expect(mutation.mutateAsync).not.toHaveBeenCalled();
+  });
+
+  it("rejects end on or before start inline without posting", async () => {
+    renderDrawer();
+    openForm();
+    fireEvent.change(screen.getByLabelText("Rate"), { target: { value: "0.07" } });
+    fireEvent.change(screen.getByLabelText("Ends"), {
+      target: { value: (screen.getByLabelText("Effective date") as HTMLInputElement).value },
+    });
+    fireEvent.click(screen.getByRole("button", { name: "Add rate" }));
+    expect(await screen.findByText(/after the start date/i)).toBeInTheDocument();
+    expect(mutation.mutateAsync).not.toHaveBeenCalled();
+  });
+
+  it("posts a today rate with a now start_time, toasts and collapses", async () => {
+    renderDrawer();
+    openForm();
+    fireEvent.change(screen.getByLabelText("Rate"), { target: { value: "0.07" } });
+    fireEvent.click(screen.getByRole("button", { name: "Add rate" }));
+
+    await waitFor(() => expect(mutation.mutateAsync).toHaveBeenCalledTimes(1));
+    const payload = mutation.mutateAsync.mock.calls[0]?.[0] as {
+      compute_allocation_resource_id: string;
+      rate: number;
+      start_time: string;
+    };
+    expect(payload.compute_allocation_resource_id).toBe("res-1");
+    expect(payload.rate).toBe(0.07);
+    // Today -> current wall-clock instant, not midnight.
+    expect(payload.start_time).not.toBe(`${todayUtc()}T00:00:00Z`);
+    expect(payload.start_time).toMatch(/T\d\d:\d\d/);
+    expect(toast.success).toHaveBeenCalledWith("Rate added.");
+    await waitFor(() =>
+      expect(screen.queryByRole("form", { name: "Add rate form" })).not.toBeInTheDocument(),
+    );
+  });
+
+  it("keeps the populated form and shows a backend error without toasting", async () => {
+    mutation.mutateAsync = vi.fn().mockRejectedValue(new Error("Rate conflicts with an existing window"));
+    renderDrawer();
+    openForm();
+    fireEvent.change(screen.getByLabelText("Rate"), { target: { value: "0.09" } });
+    fireEvent.click(screen.getByRole("button", { name: "Add rate" }));
+
+    expect(await screen.findByText(/conflicts with an existing window/i)).toBeInTheDocument();
+    expect(screen.getByRole("form", { name: "Add rate form" })).toBeInTheDocument();
+    expect((screen.getByLabelText("Rate") as HTMLInputElement).value).toBe("0.09");
+    expect(toast.success).not.toHaveBeenCalled();
+  });
+
+  it("switches the preview copy between immediate and scheduled", () => {
+    renderDrawer();
+    openForm();
+    expect(screen.getByText(/becomes the active rate immediately/i)).toBeInTheDocument();
+
+    fireEvent.change(screen.getByLabelText("Effective date"), { target: { value: "2099-06-01" } });
+    expect(screen.getByText(/Scheduled to take effect/i)).toBeInTheDocument();
+  });
+
+  it("warns about a pricing gap when no later rate follows the new end", () => {
+    rates = [rate("rate-only", 0.05, "2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z")];
+    renderDrawer();
+    openForm();
+    // Default end is ~1 year out; nothing in history starts after it.
+    expect(screen.getByText(/avoid a pricing gap/i)).toBeInTheDocument();
+  });
+
+  it("shows the empty state with an Add rate action", () => {
     rates = [];
     effective = undefined;
-    render(
-      <ResourcesRatesDrawer resourceId="res-1" resourceName="Anvil CPU" onOpenChange={vi.fn()} />,
-    );
+    renderDrawer();
     expect(
       screen.getByRole("heading", { name: /no rates recorded for this resource/i }),
     ).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "+ Add rate" })).toBeInTheDocument();
   });
 });
diff --git a/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
index e99c3efb9..218062101 100644
--- a/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
+++ b/web/src/features/core/resources/__tests__/ResourcesTab.test.tsx
@@ -22,8 +22,8 @@ import type { ComputeAllocationResource } from "../schemas";
 
 let resources: ComputeAllocationResource[] = [];
 const clusters: ComputeCluster[] = [
-  { id: "cluster-anvil", name: "Anvil" },
-  { id: "cluster-delta", name: "Delta" },
+  { id: "cluster-a", name: "ClusterA" },
+  { id: "cluster-b", name: "ClusterB" },
 ];
 
 vi.mock("../queries", () => ({
@@ -47,18 +47,18 @@ import { ResourcesTab } from "../components/ResourcesTab";
 beforeEach(() => {
   resources = [
     {
-      id: "res-anvil-cpu",
-      name: "Anvil CPU",
+      id: "res-a-cpu",
+      name: "ClusterA CPU",
       resource_type: "CPU",
       resource_amount: 1000,
-      compute_cluster_id: "cluster-anvil",
+      compute_cluster_id: "cluster-a",
     },
     {
-      id: "res-delta-gpu",
-      name: "Delta GPU",
+      id: "res-b-gpu",
+      name: "ClusterB GPU",
       resource_type: "GPU",
       resource_amount: 200,
-      compute_cluster_id: "cluster-delta",
+      compute_cluster_id: "cluster-b",
     },
   ];
 });
@@ -66,31 +66,31 @@ beforeEach(() => {
 describe("<ResourcesTab />", () => {
   it("resolves the cluster name as plain text, not a link", () => {
     render(<ResourcesTab />);
-    expect(screen.getByText("Anvil CPU")).toBeInTheDocument();
-    expect(screen.getAllByText("Anvil", { exact: true }).length).toBeGreaterThan(0);
-    expect(screen.queryByRole("link", { name: "Anvil" })).not.toBeInTheDocument();
+    expect(screen.getByText("ClusterA CPU")).toBeInTheDocument();
+    expect(screen.getAllByText("ClusterA", { exact: true }).length).toBeGreaterThan(0);
+    expect(screen.queryByRole("link", { name: "ClusterA" })).not.toBeInTheDocument();
   });
 
   it("filters by search text", () => {
     render(<ResourcesTab />);
     fireEvent.change(screen.getByLabelText("Search resources"), { target: { value: "gpu" } });
-    expect(screen.queryByText("Anvil CPU")).not.toBeInTheDocument();
-    expect(screen.getByText("Delta GPU")).toBeInTheDocument();
+    expect(screen.queryByText("ClusterA CPU")).not.toBeInTheDocument();
+    expect(screen.getByText("ClusterB GPU")).toBeInTheDocument();
   });
 
   it("filters by the selected cluster", () => {
     render(<ResourcesTab />);
     fireEvent.change(screen.getByLabelText("Filter by cluster"), {
-      target: { value: "cluster-delta" },
+      target: { value: "cluster-b" },
     });
-    expect(screen.queryByText("Anvil CPU")).not.toBeInTheDocument();
-    expect(screen.getByText("Delta GPU")).toBeInTheDocument();
+    expect(screen.queryByText("ClusterA CPU")).not.toBeInTheDocument();
+    expect(screen.getByText("ClusterB GPU")).toBeInTheDocument();
   });
 
   it("opens the rates drawer for the chosen resource", () => {
     render(<ResourcesTab />);
     fireEvent.click(screen.getAllByRole("button", { name: "Rates" })[0] as HTMLElement);
-    expect(screen.getByText("rates-drawer:Anvil CPU")).toBeInTheDocument();
+    expect(screen.getByText("rates-drawer:ClusterA CPU")).toBeInTheDocument();
   });
 
   it("shows the empty state when no resources are registered", () => {
diff --git a/web/src/features/core/resources/__tests__/api.test.ts b/web/src/features/core/resources/__tests__/api.test.ts
index b877fce32..0f6c4f7e9 100644
--- a/web/src/features/core/resources/__tests__/api.test.ts
+++ b/web/src/features/core/resources/__tests__/api.test.ts
@@ -31,15 +31,15 @@ afterEach(() => {
 });
 
 const resource = {
-  id: "res-anvil-cpu",
-  name: "Anvil CPU",
+  id: "res-a-cpu",
+  name: "ClusterA CPU",
   resource_type: "CPU",
   resource_amount: 1000000,
-  compute_cluster_id: "cluster-anvil",
+  compute_cluster_id: "cluster-a",
 };
 const rate = {
   id: "rate-1",
-  compute_allocation_resource_id: "res-anvil-cpu",
+  compute_allocation_resource_id: "res-a-cpu",
   rate: 0.05,
   start_time: "2020-01-01T00:00:00Z",
   end_time: "2035-01-01T00:00:00Z",
@@ -49,22 +49,32 @@ describe("listResources", () => {
   it("parses a bare array of resources", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, [resource]));
     const out = await listResources();
-    expect(out[0]?.name).toBe("Anvil CPU");
+    expect(out[0]?.name).toBe("ClusterA CPU");
+  });
+
+  it("returns an empty array when the backend sends null", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, "null"));
+    await expect(listResources()).resolves.toEqual([]);
   });
 });
 
 describe("listResourceRates", () => {
   it("parses a bare array of rates", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, [rate]));
-    const out = await listResourceRates("res-anvil-cpu");
+    const out = await listResourceRates("res-a-cpu");
     expect(out[0]?.rate).toBe(0.05);
   });
+
+  it("returns an empty array when the backend sends null", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, "null"));
+    await expect(listResourceRates("res-a-cpu")).resolves.toEqual([]);
+  });
 });
 
 describe("getEffectiveRate", () => {
   it("parses a single effective rate and appends the at query when given", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, rate));
-    await getEffectiveRate("res-anvil-cpu", "2026-06-01T00:00:00Z");
+    await getEffectiveRate("res-a-cpu", "2026-06-01T00:00:00Z");
     const url = fetchMock.mock.calls[0]?.[0] as string;
     expect(url).toContain("/rates/effective?at=");
   });
diff --git a/web/src/features/core/resources/__tests__/queries.test.tsx b/web/src/features/core/resources/__tests__/queries.test.tsx
new file mode 100644
index 000000000..52eb1fb90
--- /dev/null
+++ b/web/src/features/core/resources/__tests__/queries.test.tsx
@@ -0,0 +1,57 @@
+// 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import type { ReactNode } from "react";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { resourceKeys, useCreateResourceRate } from "../queries";
+
+const server = setupServer(
+  http.post("*/api/v1/compute-allocation-resource-rates", async ({ request }) => {
+    const body = (await request.json()) as Record<string, unknown>;
+    return HttpResponse.json({ id: "rate-new", ...body }, { status: 201 });
+  }),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+afterEach(() => server.resetHandlers());
+
+describe("useCreateResourceRate", () => {
+  it("invalidates the rates and effective queries for the resource on success", async () => {
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    const invalidate = vi.spyOn(client, "invalidateQueries");
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={client}>{children}</QueryClientProvider>
+    );
+
+    const { result } = renderHook(() => useCreateResourceRate("res-1"), { wrapper });
+    await result.current.mutateAsync({
+      compute_allocation_resource_id: "res-1",
+      rate: 0.07,
+      start_time: "2026-06-01T00:00:00Z",
+      end_time: "2027-06-01T00:00:00Z",
+    });
+
+    await waitFor(() => expect(result.current.isSuccess).toBe(true));
+    expect(invalidate).toHaveBeenCalledWith({ queryKey: resourceKeys.rates("res-1") });
+    expect(invalidate).toHaveBeenCalledWith({ queryKey: resourceKeys.effective("res-1") });
+  });
+});
diff --git a/web/src/features/core/resources/__tests__/schemas.test.ts b/web/src/features/core/resources/__tests__/schemas.test.ts
index 7d5035b4e..adfc086b5 100644
--- a/web/src/features/core/resources/__tests__/schemas.test.ts
+++ b/web/src/features/core/resources/__tests__/schemas.test.ts
@@ -22,18 +22,18 @@ describe("computeAllocationResourceSchema", () => {
   it("accepts a fully populated resource", () => {
     const parsed = computeAllocationResourceSchema.parse({
       id: "res-1",
-      name: "Anvil CPU",
+      name: "ClusterA CPU",
       resource_type: "CPU",
       resource_amount: 1000,
-      compute_cluster_id: "cluster-anvil",
+      compute_cluster_id: "cluster-a",
     });
-    expect(parsed.name).toBe("Anvil CPU");
+    expect(parsed.name).toBe("ClusterA CPU");
   });
 
   it("rejects a resource missing its cluster id", () => {
     const result = computeAllocationResourceSchema.safeParse({
       id: "res-1",
-      name: "Anvil CPU",
+      name: "ClusterA CPU",
       resource_type: "CPU",
       resource_amount: 1000,
     });
diff --git a/web/src/features/core/resources/api.ts b/web/src/features/core/resources/api.ts
index a75773281..9d5c75510 100644
--- a/web/src/features/core/resources/api.ts
+++ b/web/src/features/core/resources/api.ts
@@ -24,14 +24,18 @@ import {
   rateSchema,
 } from "./schemas";
 
+// The backend serializes empty lists as JSON null (Go nil slice).
+const resourceListSchema = z.array(computeAllocationResourceSchema).nullable();
+const rateListSchema = z.array(rateSchema).nullable();
+
 export async function listResources(): Promise<ComputeAllocationResource[]> {
   const raw = await apiFetch("/compute-allocation-resources");
-  return z.array(computeAllocationResourceSchema).parse(raw);
+  return resourceListSchema.parse(raw) ?? [];
 }
 
 export async function listResourceRates(id: string): Promise<Rate[]> {
   const raw = await apiFetch(`/compute-allocation-resources/${id}/rates`);
-  return z.array(rateSchema).parse(raw);
+  return rateListSchema.parse(raw) ?? [];
 }
 
 export async function getEffectiveRate(id: string, at?: string): Promise<Rate> {
@@ -39,3 +43,18 @@ export async function getEffectiveRate(id: string, at?: string): Promise<Rate> {
   const raw = await apiFetch(`/compute-allocation-resources/${id}/rates/effective${qs}`);
   return rateSchema.parse(raw);
 }
+
+export type CreateResourceRatePayload = {
+  compute_allocation_resource_id: string;
+  rate: number;
+  start_time: string;
+  end_time: string;
+};
+
+export async function createResourceRate(payload: CreateResourceRatePayload): Promise<Rate> {
+  const raw = await apiFetch("/compute-allocation-resource-rates", {
+    method: "POST",
+    body: payload,
+  });
+  return rateSchema.parse(raw);
+}
diff --git a/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx b/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx
index 5ec208e80..e1fee46a3 100644
--- a/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx
+++ b/web/src/features/core/resources/components/ResourcesRatesDrawer.tsx
@@ -17,13 +17,28 @@
 
 "use client";
 
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { useAbility } from "@/shared/casl/AbilityProvider";
+import { Button } from "@/shared/ui/button";
 import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
+import { Input } from "@/shared/ui/input";
+import { Label } from "@/shared/ui/label";
 import { CardSkeleton } from "@/shared/ui/Loading";
 import { SideDrawer } from "@/shared/ui/SideDrawer";
-import { StatusBadge } from "@/shared/ui/StatusBadge";
-import { useEffectiveRate, useResourceRates } from "../queries";
-import { isRateActive, type Rate } from "../schemas";
+import { StatusBadge, type StatusBadgeVariant } from "@/shared/ui/StatusBadge";
+import { useCreateResourceRate, useEffectiveRate, useResourceRates } from "../queries";
+import { classifyRate, createRateSchema, type CreateRateForm, type Rate, type RateStatus } from "../schemas";
+
+const STATUS_VARIANT: Record<RateStatus, StatusBadgeVariant> = {
+  ACTIVE: "active",
+  SCHEDULED: "pending",
+  SUPERSEDED: "inactive",
+  EXPIRED: "expired",
+};
 
 function formatDate(iso: string): string {
   try {
@@ -37,6 +52,21 @@ function formatDate(iso: string): string {
   }
 }
 
+function todayUtc(): string {
+  return new Date().toISOString().slice(0, 10);
+}
+
+function defaultForm(): CreateRateForm {
+  const start = todayUtc();
+  const end = new Date(start);
+  end.setUTCFullYear(end.getUTCFullYear() + 1);
+  return {
+    rate: undefined as unknown as number,
+    start_date: start,
+    end_date: end.toISOString().slice(0, 10),
+  };
+}
+
 export type ResourcesRatesDrawerProps = {
   resourceId: string | null;
   resourceName: string | null;
@@ -48,9 +78,61 @@ export function ResourcesRatesDrawer({
   resourceName,
   onOpenChange,
 }: ResourcesRatesDrawerProps) {
+  const ability = useAbility();
+  const canManage = ability.can("manage", "Allocation");
+
   const rates = useResourceRates(resourceId ?? undefined);
   const effective = useEffectiveRate(resourceId ?? undefined);
-  const history = rates.data ?? [];
+  const createMutation = useCreateResourceRate(resourceId ?? "");
+
+  const history = React.useMemo(
+    () => [...(rates.data ?? [])].sort((a, b) => Date.parse(b.start_time) - Date.parse(a.start_time)),
+    [rates.data],
+  );
+
+  const [formOpen, setFormOpen] = React.useState(false);
+  const [submitError, setSubmitError] = React.useState<string | null>(null);
+  const form = useForm<CreateRateForm>({
+    resolver: zodResolver(createRateSchema),
+    defaultValues: defaultForm(),
+    mode: "onBlur",
+  });
+
+  function openForm() {
+    form.reset(defaultForm());
+    setSubmitError(null);
+    setFormOpen(true);
+  }
+
+  const startDate = form.watch("start_date");
+  const endDate = form.watch("end_date");
+  const isImmediate = startDate === todayUtc();
+  const currentRate = effective.data?.rate;
+  // Backend forces an end date, so warn when nothing starts at or after it.
+  const leavesGap = Boolean(endDate) && !history.some((r) => Date.parse(r.start_time) >= Date.parse(`${endDate}T00:00:00Z`));
+
+  const onSubmit = form.handleSubmit(async (values) => {
+    if (!resourceId) return;
+    const start_time = values.start_date === todayUtc()
+      ? new Date().toISOString()
+      : `${values.start_date}T00:00:00Z`;
+    try {
+      await createMutation.mutateAsync({
+        compute_allocation_resource_id: resourceId,
+        rate: values.rate,
+        start_time,
+        end_time: `${values.end_date}T00:00:00Z`,
+      });
+      toast.success("Rate added.");
+      form.reset(defaultForm());
+      setSubmitError(null);
+      setFormOpen(false);
+    } catch (err) {
+      setSubmitError(err instanceof Error ? err.message : "Failed to add rate");
+    }
+  });
+
+  const errors = form.formState.errors;
 
   return (
     <SideDrawer
@@ -74,12 +156,100 @@ export function ResourcesRatesDrawer({
         </div>
       ) : null}
 
+      {canManage && history.length > 0 ? (
+        <div className="mb-4 flex items-start justify-between gap-4">
+          <p className="text-xs text-muted-foreground">
+            Rates can&apos;t be edited. Adding a rate supersedes the current one from its effective
+            date.
+          </p>
+          {!formOpen ? (
+            <Button variant="outline" size="sm" className="shrink-0" onClick={openForm}>
+              + Add rate
+            </Button>
+          ) : null}
+        </div>
+      ) : null}
+
+      {canManage && formOpen ? (
+        <form onSubmit={onSubmit} aria-label="Add rate form" className="mb-6 space-y-4 rounded-lg border border-border bg-card p-4">
+          <div className="space-y-2">
+            <Label htmlFor="rate-value">Rate</Label>
+            <Input
+              id="rate-value"
+              type="number"
+              step="0.01"
+              placeholder="e.g. 0.05"
+              {...form.register("rate", { valueAsNumber: true })}
+            />
+            {errors.rate?.message ? (
+              <p className="text-xs text-destructive">{errors.rate.message}</p>
+            ) : null}
+          </div>
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="rate-start">Effective date</Label>
+              <Input id="rate-start" type="date" {...form.register("start_date")} />
+              {errors.start_date?.message ? (
+                <p className="text-xs text-destructive">{errors.start_date.message}</p>
+              ) : null}
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="rate-end">Ends</Label>
+              <Input id="rate-end" type="date" {...form.register("end_date")} />
+              {errors.end_date?.message ? (
+                <p className="text-xs text-destructive">{errors.end_date.message}</p>
+              ) : null}
+            </div>
+          </div>
+
+          <p className="text-xs text-muted-foreground">
+            {isImmediate
+              ? `This becomes the active rate immediately. Current rate (${currentRate ?? "none"}) stays in history.`
+              : `Scheduled to take effect on ${formatDate(`${startDate}T00:00:00Z`)}. The current rate (${currentRate ?? "none"}) stays active until then.`}
+          </p>
+
+          {leavesGap ? (
+            <p className="text-xs text-[color:var(--custos-amber-700)]">
+              No rate is effective after {formatDate(`${endDate}T00:00:00Z`)}. Add a follow-up rate to
+              avoid a pricing gap.
+            </p>
+          ) : null}
+
+          {submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
+
+          <div className="flex items-center justify-end gap-2">
+            <Button
+              type="button"
+              variant="ghost"
+              onClick={() => {
+                setFormOpen(false);
+                setSubmitError(null);
+              }}
+            >
+              Cancel
+            </Button>
+            <Button type="submit" disabled={createMutation.isPending}>
+              {createMutation.isPending ? "Adding…" : "Add rate"}
+            </Button>
+          </div>
+        </form>
+      ) : null}
+
       {rates.isLoading ? (
         <CardSkeleton />
       ) : rates.error ? (
         <ErrorState message={(rates.error as Error).message} onRetry={() => rates.refetch()} />
       ) : history.length === 0 ? (
-        <EmptyState heading="No rates recorded for this resource." />
+        <EmptyState
+          heading="No rates recorded for this resource."
+          cta={
+            canManage && !formOpen ? (
+              <Button variant="outline" size="sm" onClick={openForm}>
+                + Add rate
+              </Button>
+            ) : undefined
+          }
+        />
       ) : (
         <table className="w-full text-left text-sm">
           <thead className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -91,20 +261,19 @@ export function ResourcesRatesDrawer({
             </tr>
           </thead>
           <tbody>
-            {history.map((rate: Rate) => (
-              <tr key={rate.id} className="border-t border-border/60">
-                <td className="py-2 pr-4 font-medium text-foreground">{rate.rate}</td>
-                <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.start_time)}</td>
-                <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.end_time)}</td>
-                <td className="py-2">
-                  {isRateActive(rate) ? (
-                    <StatusBadge variant="active" label="ACTIVE" />
-                  ) : (
-                    <StatusBadge variant="expired" label="EXPIRED" />
-                  )}
-                </td>
-              </tr>
-            ))}
+            {history.map((rate: Rate) => {
+              const status = classifyRate(rate, history);
+              return (
+                <tr key={rate.id} className="border-t border-border/60">
+                  <td className="py-2 pr-4 font-medium text-foreground">{rate.rate}</td>
+                  <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.start_time)}</td>
+                  <td className="py-2 pr-4 text-muted-foreground">{formatDate(rate.end_time)}</td>
+                  <td className="py-2">
+                    <StatusBadge variant={STATUS_VARIANT[status]} label={status} />
+                  </td>
+                </tr>
+              );
+            })}
           </tbody>
         </table>
       )}
diff --git a/web/src/features/core/resources/queries.ts b/web/src/features/core/resources/queries.ts
index fab27aae7..607008450 100644
--- a/web/src/features/core/resources/queries.ts
+++ b/web/src/features/core/resources/queries.ts
@@ -17,8 +17,8 @@
 
 "use client";
 
-import { useQuery } from "@tanstack/react-query";
-import { getEffectiveRate, listResourceRates, listResources } from "./api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createResourceRate, getEffectiveRate, listResourceRates, listResources } from "./api";
 
 export const resourceKeys = {
   all: ["resources"] as const,
@@ -59,3 +59,14 @@ export function useEffectiveRate(id: string | undefined) {
     ...DEFAULTS,
   });
 }
+
+export function useCreateResourceRate(resourceId: string) {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: createResourceRate,
+    onSuccess: () => {
+      client.invalidateQueries({ queryKey: resourceKeys.rates(resourceId) });
+      client.invalidateQueries({ queryKey: resourceKeys.effective(resourceId) });
+    },
+  });
+}
diff --git a/web/src/features/core/resources/schemas.ts b/web/src/features/core/resources/schemas.ts
index ae1312adc..12f288feb 100644
--- a/web/src/features/core/resources/schemas.ts
+++ b/web/src/features/core/resources/schemas.ts
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-import type { z } from "zod";
+import { z } from "zod";
 import {
   zComputeAllocationResource,
   zComputeAllocationResourceRate,
@@ -48,3 +48,32 @@ export function isRateActive(rate: Rate, at: Date = new Date()): boolean {
   const now = at.getTime();
   return now >= start && now < end;
 }
+
+export type RateStatus = "ACTIVE" | "SCHEDULED" | "SUPERSEDED" | "EXPIRED";
+
+// Effective = the containing window with the latest start_time, mirroring the
+// backend ORDER BY start_time DESC. Overlaps resolve to SUPERSEDED, not errors.
+export function classifyRate(rate: Rate, all: Rate[], now: number = Date.now()): RateStatus {
+  const start = Date.parse(rate.start_time);
+  const end = Date.parse(rate.end_time);
+  if (start > now) return "SCHEDULED";
+  if (end <= now) return "EXPIRED";
+  const effectiveStart = Math.max(
+    ...all
+      .filter((r) => Date.parse(r.start_time) <= now && Date.parse(r.end_time) > now)
+      .map((r) => Date.parse(r.start_time)),
+  );
+  return start === effectiveStart ? "ACTIVE" : "SUPERSEDED";
+}
+
+export const createRateSchema = z
+  .object({
+    rate: z.number({ message: "Rate is required" }).min(0, "Rate must be zero or greater"),
+    start_date: z.string().min(1, "Start date is required"),
+    end_date: z.string().min(1, "End date is required"),
+  })
+  .refine((d) => d.end_date > d.start_date, {
+    path: ["end_date"],
+    message: "End date must be after the start date",
+  });
+export type CreateRateForm = z.infer<typeof createRateSchema>;
diff --git a/web/src/mocks/handlers/resources.ts b/web/src/mocks/handlers/resources.ts
index d568f3036..3df4bdc6d 100644
--- a/web/src/mocks/handlers/resources.ts
+++ b/web/src/mocks/handlers/resources.ts
@@ -45,4 +45,17 @@ export const resourcesHandlers = [
     const id = String(params.id);
     return HttpResponse.json(ratesFor(id));
   }),
+
+  http.post("*/api/v1/compute-allocation-resource-rates", async ({ request }) => {
+    const body = (await request.json()) as Partial<Rate>;
+    const created: Rate = {
+      id: `rate-${Date.now()}`,
+      compute_allocation_resource_id: String(body.compute_allocation_resource_id),
+      rate: Number(body.rate),
+      start_time: String(body.start_time),
+      end_time: String(body.end_time),
+    };
+    rates.push(created);
+    return HttpResponse.json(created, { status: 201 });
+  }),
 ];
diff --git a/web/tests/admin-read-only.e2e.ts b/web/tests/admin-read-only.e2e.ts
index 3d96e7956..1ecedf282 100644
--- a/web/tests/admin-read-only.e2e.ts
+++ b/web/tests/admin-read-only.e2e.ts
@@ -27,8 +27,8 @@ test.describe("admin resources & clusters read-only sweep", () => {
     });
 
     // Clusters is the default tab.
-    await page.getByRole("button", { name: "Anvil", exact: true }).click();
-    await expect(page.getByText("Cluster: Anvil")).toBeVisible({ timeout: 15_000 });
+    await page.getByRole("button", { name: "ClusterA", exact: true }).click();
+    await expect(page.getByText("Cluster: ClusterA")).toBeVisible({ timeout: 15_000 });
     await expect(page.getByText("ghopper")).toBeVisible();
   });
 
@@ -41,13 +41,38 @@ test.describe("admin resources & clusters read-only sweep", () => {
 
     await page.getByRole("tab", { name: /^resources$/i }).click();
     await page
-      .getByRole("row", { name: /Anvil CPU/ })
+      .getByRole("row", { name: /ClusterA CPU/ })
       .getByRole("button", { name: "Rates" })
       .click();
 
-    await expect(page.getByText("Rates: Anvil CPU")).toBeVisible({ timeout: 15_000 });
+    await expect(page.getByText("Rates: ClusterA CPU")).toBeVisible({ timeout: 15_000 });
     // History carries the current rate plus the superseded one (client-derived).
     await expect(page.getByText("EXPIRED")).toBeVisible();
     await expect(page.getByText("0.04")).toBeVisible();
   });
+
+  test("admin adds a rate that becomes active and supersedes the current one", async ({ page }) => {
+    await signInAs(page, "admin");
+    await page.goto("/admin/resources");
+    await expect(page.getByRole("heading", { name: /Resources & Clusters/ })).toBeVisible({
+      timeout: 20_000,
+    });
+
+    await page.getByRole("tab", { name: /^resources$/i }).click();
+    await page
+      .getByRole("row", { name: /ClusterA GPU/ })
+      .getByRole("button", { name: "Rates" })
+      .click();
+    await expect(page.getByText("Rates: ClusterA GPU")).toBeVisible({ timeout: 15_000 });
+
+    await page.getByRole("button", { name: "+ Add rate" }).click();
+    await page.getByLabel("Rate", { exact: true }).fill("0.77");
+    await page.getByRole("button", { name: "Add rate" }).click();
+
+    await expect(page.getByText("Rate added.")).toBeVisible({ timeout: 15_000 });
+    const newRow = page.getByRole("row", { name: /0\.77/ });
+    await expect(newRow.getByText("ACTIVE")).toBeVisible();
+    // The former current rate stays in its window but loses the tie-break.
+    await expect(page.getByText("SUPERSEDED")).toBeVisible();
+  });
 });
diff --git a/web/tests/admin-viewer.e2e.ts b/web/tests/admin-viewer.e2e.ts
index e301986a3..ede635356 100644
--- a/web/tests/admin-viewer.e2e.ts
+++ b/web/tests/admin-viewer.e2e.ts
@@ -41,5 +41,14 @@ test.describe("admin viewer persona", () => {
     await expect(page.getByRole("heading", { name: /Resources & Clusters/ })).toBeVisible();
     await expect(page.getByRole("tab", { name: /^clusters$/i })).toBeVisible();
     await expect(page.getByRole("tab", { name: /^resources$/i })).toBeVisible();
+
+    // Viewer lacks manage Allocation, so the rates drawer offers no create action.
+    await page.getByRole("tab", { name: /^resources$/i }).click();
+    await page
+      .getByRole("row", { name: /ClusterA CPU/ })
+      .getByRole("button", { name: "Rates" })
+      .click();
+    await expect(page.getByText("Rates: ClusterA CPU")).toBeVisible({ timeout: 15_000 });
+    await expect(page.getByRole("button", { name: "+ Add rate" })).toHaveCount(0);
   });
 });


(airavata-custos) 06/08: Restrict the member role change in the allocations and projects

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

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

commit 6eca06864ddd0f0ed5e7263cb4ce9a63b86c8088
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 05:59:25 2026 -0400

    Restrict the member role change in the allocations and projects
---
 dev-ops/compose/seeds/sample_projects.sql          |  7 +++
 .../core/allocations/__fixtures__/resources.json   | 45 +++++++++------
 .../core/allocations/__tests__/api.test.ts         | 40 ++++++++++++++
 web/src/features/core/allocations/api.ts           | 26 ++++-----
 .../allocations/components/AllocationDetail.tsx    |  6 ++
 .../components/AllocationMembersTab.tsx            |  3 +-
 .../components/AllocationOverviewTab.tsx           | 38 ++++++++-----
 .../allocations/components/AllocationUsageTab.tsx  | 64 ++++++++++++----------
 .../allocations/components/MemberEditDialog.tsx    | 46 ++--------------
 web/src/features/core/allocations/queries.ts       | 11 ++++
 web/src/features/core/allocations/schemas.ts       | 28 ++++++----
 .../core/projects/components/ProjectMembersTab.tsx | 10 +---
 .../mocks/__tests__/fixtures-vs-schemas.test.ts    | 30 +++++++++-
 web/src/mocks/handlers/allocations.ts              | 29 ++++++++--
 web/tests/allocation-members-edit.e2e.ts           |  2 +-
 web/tests/allocations-detail.e2e.ts                |  5 ++
 16 files changed, 250 insertions(+), 140 deletions(-)

diff --git a/dev-ops/compose/seeds/sample_projects.sql b/dev-ops/compose/seeds/sample_projects.sql
index b7c4827d8..2022609eb 100644
--- a/dev-ops/compose/seeds/sample_projects.sql
+++ b/dev-ops/compose/seeds/sample_projects.sql
@@ -652,3 +652,10 @@ INSERT IGNORE INTO compute_allocation_usages (id, compute_allocation_id, used_ra
   ('smp-87de20f2ff9c078f1bad', 'smp-0384acb90162ba62c422', 12896, 12896, '2026-02-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-22', 'res-cpu'),
   ('smp-63be3f2e1dc38e8cc659', 'smp-0384acb90162ba62c422', 13018, 13018, '2026-04-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-24', 'res-cpu'),
   ('smp-c90ca83a563957d78e84', 'smp-0384acb90162ba62c422', 13140, 13140, '2026-06-15 12:00:00', 'smp-2fd62ff24cea6be8de4c', 'job-ses240029-26', 'res-cpu');
+
+-- Allocation history entries so the allocation History tab has rows.
+INSERT IGNORE INTO compute_allocation_diffs (id, compute_allocation_id, diff_type, new_su_amount, status, timestamp, description) VALUES
+  ('smp-diff-1', 'smp-c4071741a17cbea31f46', 'ALLOCATION_STATUS_CHANGE', 500000, 'ACTIVE', '2026-01-15 09:00:00', 'Allocation activated after project approval'),
+  ('smp-diff-2', 'smp-c4071741a17cbea31f46', 'USAGE_UPDATE', 500000, 'ACTIVE', '2026-04-01 02:00:00', 'SU usage updated from quarterly accounting run'),
+  ('smp-diff-3', 'smp-c4071741a17cbea31f46', 'ALLOCATION_INCREASE', 650000, 'ACTIVE', '2026-06-20 14:30:00', 'Supplement approved via change request'),
+  ('smp-diff-4', 'smp-81d26521f4dc52bb030b', 'ALLOCATION_STATUS_CHANGE', 250000, 'INACTIVE', '2026-05-30 00:00:00', 'Allocation end date reached');
diff --git a/web/src/features/core/allocations/__fixtures__/resources.json b/web/src/features/core/allocations/__fixtures__/resources.json
index e57651903..6922a2058 100644
--- a/web/src/features/core/allocations/__fixtures__/resources.json
+++ b/web/src/features/core/allocations/__fixtures__/resources.json
@@ -1,33 +1,44 @@
 {
   "alloc-001": [
     {
-      "id": "res-001",
-      "name": "gpu-a100",
-      "resource_type": "gpu",
-      "resource_amount": 32
+      "id": "res-a-gpu",
+      "name": "ClusterA GPU",
+      "resource_type": "GPU",
+      "resource_amount": 50000,
+      "compute_cluster_id": "cluster-a"
     },
     {
-      "id": "res-002",
-      "name": "gpu-h100",
-      "resource_type": "gpu",
-      "resource_amount": 16
+      "id": "res-b-gpu",
+      "name": "ClusterB GPU A100",
+      "resource_type": "GPU",
+      "resource_amount": 40000,
+      "compute_cluster_id": "cluster-b"
     }
   ],
   "alloc-002": [
     {
-      "id": "res-003",
-      "name": "cpu-standard",
-      "resource_type": "cpu",
-      "resource_amount": 256
+      "id": "res-a-cpu",
+      "name": "ClusterA CPU",
+      "resource_type": "CPU",
+      "resource_amount": 1000000,
+      "compute_cluster_id": "cluster-a"
     }
   ],
   "alloc-003": [
     {
-      "id": "res-004",
-      "name": "gpu-a100",
-      "resource_type": "gpu",
-      "resource_amount": 24
+      "id": "res-a-gpu",
+      "name": "ClusterA GPU",
+      "resource_type": "GPU",
+      "resource_amount": 50000,
+      "compute_cluster_id": "cluster-a"
+    },
+    {
+      "id": "res-c-cpu",
+      "name": "ClusterC RM",
+      "resource_type": "CPU",
+      "resource_amount": 800000,
+      "compute_cluster_id": "cluster-c"
     }
   ],
   "alloc-004": []
-}
+}
\ No newline at end of file
diff --git a/web/src/features/core/allocations/__tests__/api.test.ts b/web/src/features/core/allocations/__tests__/api.test.ts
index 6ab6eef7a..136f4590b 100644
--- a/web/src/features/core/allocations/__tests__/api.test.ts
+++ b/web/src/features/core/allocations/__tests__/api.test.ts
@@ -21,6 +21,7 @@ import {
   approveChangeRequest,
   getAllocation,
   getChangeRequest,
+  listAllocationDiffs,
   listAllocationMembers,
   listAllocationResources,
   listAllocations,
@@ -104,6 +105,21 @@ describe("getAllocation", () => {
 });
 
 describe("listAllocationResources", () => {
+  it("parses attached resource rows", async () => {
+    const resource = {
+      id: "res-a-gpu",
+      name: "ClusterA GPU",
+      resource_type: "GPU",
+      resource_amount: 50000,
+      compute_cluster_id: "cluster-a",
+    };
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [resource]));
+    const out = await listAllocationResources("alloc-001");
+    expect(out).toHaveLength(1);
+    expect(out[0]?.name).toBe("ClusterA GPU");
+    expect(out[0]?.resource_type).toBe("GPU");
+  });
+
   it("returns [] when backend returns null", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, null));
     const out = await listAllocationResources("alloc-001");
@@ -111,6 +127,30 @@ describe("listAllocationResources", () => {
   });
 });
 
+describe("listAllocationDiffs", () => {
+  it("parses diff rows", async () => {
+    const diff = {
+      id: "diff-001",
+      compute_allocation_id: "alloc-001",
+      diff_type: "USAGE_UPDATE",
+      new_su_amount: 250000,
+      status: "ACTIVE",
+      timestamp: "2026-05-15T08:30:00Z",
+    };
+    fetchMock.mockResolvedValueOnce(mockResponse(200, [diff]));
+    const out = await listAllocationDiffs("alloc-001");
+    expect(out).toHaveLength(1);
+    expect(out[0]?.diff_type).toBe("USAGE_UPDATE");
+    expect(fetchMock.mock.calls[0]?.[0]).toContain("/compute-allocations/alloc-001/diffs");
+  });
+
+  it("returns [] when backend returns null", async () => {
+    fetchMock.mockResolvedValueOnce(mockResponse(200, null));
+    const out = await listAllocationDiffs("alloc-001");
+    expect(out).toEqual([]);
+  });
+});
+
 describe("listAllocationMembers", () => {
   it("validates rows", async () => {
     fetchMock.mockResolvedValueOnce(mockResponse(200, [member]));
diff --git a/web/src/features/core/allocations/api.ts b/web/src/features/core/allocations/api.ts
index a76125ef2..b5d0fec27 100644
--- a/web/src/features/core/allocations/api.ts
+++ b/web/src/features/core/allocations/api.ts
@@ -17,19 +17,22 @@
 
 import { apiFetch } from "@/shared/api/client";
 import {
+  type AllocationDiff,
   type AllocationMembership,
+  type AttachedResource,
   type AllocationUsage,
   type ChangeRequest,
   type ChangeRequestEvent,
   type ComputeAllocation,
   type ComputeAllocationList,
-  type ComputeAllocationResource,
   type CreateChangeRequestPayload,
   type CreateMembershipPayload,
   type UpdateChangeRequestPayload,
   type UpdateMembershipPayload,
+  allocationDiffListSchema,
   allocationMembershipListSchema,
   allocationMembershipSchema,
+  attachedResourceListSchema,
   allocationUsageListSchema,
   changeRequestEventListSchema,
   changeRequestListSchema,
@@ -42,7 +45,6 @@ import {
   updateMembershipPayloadSchema,
 } from "./schemas";
 import type { AllocationListParams, ChangeRequestListParams } from "./types";
-import { z } from "zod";
 
 export async function listAllocations(
   params: AllocationListParams = {},
@@ -63,20 +65,14 @@ export async function getAllocation(id: string): Promise<ComputeAllocation> {
   return computeAllocationSchema.parse(raw);
 }
 
-export async function listAllocationResources(
-  id: string,
-): Promise<ComputeAllocationResource[]> {
+export async function listAllocationResources(id: string): Promise<AttachedResource[]> {
   const raw = await apiFetch(`/compute-allocations/${id}/resources`);
-  return z
-    .array(
-      z.object({
-        id: z.string(),
-        name: z.string(),
-        resource_type: z.string(),
-        resource_amount: z.number().int(),
-      }),
-    )
-    .parse(raw ?? []);
+  return attachedResourceListSchema.parse(raw ?? []);
+}
+
+export async function listAllocationDiffs(id: string): Promise<AllocationDiff[]> {
+  const raw = await apiFetch(`/compute-allocations/${id}/diffs`);
+  return allocationDiffListSchema.parse(raw ?? []);
 }
 
 export async function listAllocationUsage(id: string): Promise<AllocationUsage[]> {
diff --git a/web/src/features/core/allocations/components/AllocationDetail.tsx b/web/src/features/core/allocations/components/AllocationDetail.tsx
index 7d50114d1..124199f1a 100644
--- a/web/src/features/core/allocations/components/AllocationDetail.tsx
+++ b/web/src/features/core/allocations/components/AllocationDetail.tsx
@@ -25,6 +25,7 @@ import { TabsRouter } from "@/shared/ui/TabsRouter";
 import { useAllocation, useAllocationMembers } from "../queries";
 import { AllocationChangeRequestsTab } from "./AllocationChangeRequestsTab";
 import { AllocationDetailHeader } from "./AllocationDetailHeader";
+import { AllocationHistoryTab } from "./AllocationHistoryTab";
 import { AllocationMembersTab } from "./AllocationMembersTab";
 import { AllocationOverviewTab } from "./AllocationOverviewTab";
 import { AllocationUsageTab } from "./AllocationUsageTab";
@@ -86,6 +87,11 @@ export function AllocationDetail({ allocationId }: AllocationDetailProps) {
             label: "Usage",
             content: <AllocationUsageTab allocation={allocation} />,
           },
+          {
+            value: "history",
+            label: "History",
+            content: <AllocationHistoryTab allocation={allocation} />,
+          },
         ]}
       />
     </section>
diff --git a/web/src/features/core/allocations/components/AllocationMembersTab.tsx b/web/src/features/core/allocations/components/AllocationMembersTab.tsx
index 0dc87c541..07a803405 100644
--- a/web/src/features/core/allocations/components/AllocationMembersTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationMembersTab.tsx
@@ -150,8 +150,9 @@ export function AllocationMembersTab({ allocation, canManage }: AllocationMember
       header: "",
       align: "right",
       interactive: true,
+      // PI and CO_PI are upstream-owned; the portal never edits or removes them.
       cell: (row) =>
-        canManage ? (
+        canManage && row.role !== "PI" && row.role !== "CO_PI" ? (
           <div className="flex justify-end gap-2">
             <Button
               variant="ghost"
diff --git a/web/src/features/core/allocations/components/AllocationOverviewTab.tsx b/web/src/features/core/allocations/components/AllocationOverviewTab.tsx
index f56e9dcf7..d88d936c1 100644
--- a/web/src/features/core/allocations/components/AllocationOverviewTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationOverviewTab.tsx
@@ -17,10 +17,11 @@
 
 "use client";
 
+import { useEffectiveRate } from "@/features/core/resources/queries";
 import { ErrorState } from "@/shared/ui/ErrorState";
 import { TableSkeleton } from "@/shared/ui/Loading";
 import { useAllocationResources } from "../queries";
-import type { ComputeAllocation } from "../schemas";
+import type { AttachedResource, ComputeAllocation } from "../schemas";
 
 export type AllocationOverviewTabProps = {
   allocation: ComputeAllocation;
@@ -30,6 +31,26 @@ function formatNumber(n: number): string {
   return new Intl.NumberFormat().format(n);
 }
 
+function AllocationResourceRow({ resource }: { resource: AttachedResource }) {
+  const rateQuery = useEffectiveRate(resource.id);
+  return (
+    <li className="flex items-center justify-between rounded-md border bg-card px-3 py-2 text-sm">
+      <div>
+        <span className="font-medium">{resource.name}</span>
+        <span className="ml-2 text-xs uppercase tracking-wide text-muted-foreground">
+          {resource.resource_type}
+        </span>
+        {rateQuery.data ? (
+          <div className="mt-0.5 text-xs text-muted-foreground">
+            {formatNumber(rateQuery.data.rate)} SU/unit
+          </div>
+        ) : null}
+      </div>
+      <span className="tabular-nums">{formatNumber(resource.resource_amount)}</span>
+    </li>
+  );
+}
+
 export function AllocationOverviewTab({ allocation }: AllocationOverviewTabProps) {
   const resourcesQuery = useAllocationResources(allocation.id);
 
@@ -74,19 +95,8 @@ export function AllocationOverviewTab({ allocation }: AllocationOverviewTabProps
           <p className="text-sm text-muted-foreground">No resources attached.</p>
         ) : (
           <ul className="space-y-1">
-            {resourcesQuery.data.map((r) => (
-              <li
-                key={r.id}
-                className="flex items-center justify-between rounded-md border bg-card px-3 py-2 text-sm"
-              >
-                <div>
-                  <span className="font-medium">{r.name}</span>
-                  <span className="ml-2 text-xs uppercase tracking-wide text-muted-foreground">
-                    {r.resource_type}
-                  </span>
-                </div>
-                <span className="tabular-nums">{formatNumber(r.resource_amount)}</span>
-              </li>
+            {resourcesQuery.data.map((resource) => (
+              <AllocationResourceRow key={resource.id} resource={resource} />
             ))}
           </ul>
         )}
diff --git a/web/src/features/core/allocations/components/AllocationUsageTab.tsx b/web/src/features/core/allocations/components/AllocationUsageTab.tsx
index bd4d31a0a..d24b3a856 100644
--- a/web/src/features/core/allocations/components/AllocationUsageTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationUsageTab.tsx
@@ -17,6 +17,7 @@
 
 "use client";
 
+import { useResourceSummaries } from "@/features/core/resources/queries";
 import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
 import { TableSkeleton } from "@/shared/ui/Loading";
@@ -34,6 +35,7 @@ function formatNumber(n: number): string {
 
 export function AllocationUsageTab({ allocation }: AllocationUsageTabProps) {
   const query = useAllocationUsage(allocation.id);
+  const summariesQuery = useResourceSummaries();
   if (query.isLoading) return <TableSkeleton rows={3} columns={3} />;
   if (query.error) {
     return <ErrorState message={(query.error as Error).message} onRetry={() => query.refetch()} />;
@@ -41,21 +43,13 @@ export function AllocationUsageTab({ allocation }: AllocationUsageTabProps) {
   const rows = query.data ?? [];
   const usedTotal = rows.reduce((acc, r) => acc + r.used_su_amount, 0);
 
-  if (rows.length === 0) {
-    return (
-      <div className="space-y-4">
-        <UsageBar
-          value={0}
-          max={allocation.initial_su_amount}
-          label={`0 / ${formatNumber(allocation.initial_su_amount)} SUs`}
-          ariaLabel="Allocation SU usage"
-        />
-        <EmptyState
-          heading="No usage recorded"
-          description="Jobs that consume this allocation will appear here."
-        />
-      </div>
-    );
+  const nameById = new Map((summariesQuery.data ?? []).map((s) => [s.id, s.name]));
+  // Per-resource SU used, summed from usage rows. No per-resource SU allocation
+  // exists, so bars show each resource's share of total usage, not a quota.
+  const byResource = new Map<string, number>();
+  for (const row of rows) {
+    const key = row.compute_allocation_resource_id;
+    byResource.set(key, (byResource.get(key) ?? 0) + row.used_su_amount);
   }
 
   return (
@@ -66,20 +60,32 @@ export function AllocationUsageTab({ allocation }: AllocationUsageTabProps) {
         label={`${formatNumber(usedTotal)} / ${formatNumber(allocation.initial_su_amount)} SUs`}
         ariaLabel="Allocation SU usage"
       />
-      <ul className="space-y-1">
-        {rows.map((row) => (
-          <li
-            key={row.id}
-            className="flex items-center justify-between rounded-md border bg-card px-3 py-2 text-sm"
-          >
-            <div>
-              <div className="font-medium">{row.user_id}</div>
-              <div className="font-mono text-xs text-muted-foreground">{row.job_id}</div>
-            </div>
-            <span className="tabular-nums">{formatNumber(row.used_su_amount)}</span>
-          </li>
-        ))}
-      </ul>
+      {rows.length === 0 ? (
+        <EmptyState
+          heading="No usage recorded"
+          description="Jobs that consume this allocation will appear here."
+        />
+      ) : (
+        <ul className="space-y-3">
+          {[...byResource.entries()].map(([resourceId, used]) => {
+            const name = nameById.get(resourceId) ?? resourceId;
+            return (
+              <li key={resourceId} className="rounded-md border bg-card px-3 py-2">
+                <div className="mb-1 flex items-baseline justify-between text-sm">
+                  <span className="font-medium">{name}</span>
+                  <span className="tabular-nums">{formatNumber(used)} SU</span>
+                </div>
+                <UsageBar
+                  value={used}
+                  max={usedTotal}
+                  ariaLabel={`${name} share of usage`}
+                  size="sm"
+                />
+              </li>
+            );
+          })}
+        </ul>
+      )}
     </div>
   );
 }
diff --git a/web/src/features/core/allocations/components/MemberEditDialog.tsx b/web/src/features/core/allocations/components/MemberEditDialog.tsx
index 4268b6ca6..dae803573 100644
--- a/web/src/features/core/allocations/components/MemberEditDialog.tsx
+++ b/web/src/features/core/allocations/components/MemberEditDialog.tsx
@@ -27,25 +27,21 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/shared/ui/dialog";
-import { Input } from "@/shared/ui/input";
 import { Label } from "@/shared/ui/label";
-import type { AllocationMembership, AllocationStatus } from "../schemas";
+import type { AllocationMembership } from "../schemas";
 
-const ROLES = ["PI", "CO_PI", "ALLOCATION_MANAGER", "MEMBER"] as const;
+// Only these roles are portal-assignable. PI and CO_PI come from upstream and
+// are never granted or revoked here. Status and end date are system-tracked.
+const ROLES = ["ALLOCATION_MANAGER", "MEMBER"] as const;
 type MemberRole = (typeof ROLES)[number];
 
 const ROLE_LABELS: Record<MemberRole, string> = {
-  PI: "PI",
-  CO_PI: "Co-PI",
   ALLOCATION_MANAGER: "Allocation Manager",
   MEMBER: "Member",
 };
 
 export type MemberEditPayload = {
   role: MemberRole;
-  membership_status: AllocationStatus;
-  start_time?: string;
-  end_time?: string;
 };
 
 export type MemberEditDialogProps = {
@@ -57,14 +53,10 @@ export type MemberEditDialogProps = {
 
 export function MemberEditDialog({ member, onClose, onSubmit, isPending }: MemberEditDialogProps) {
   const [role, setRole] = React.useState<MemberRole>("MEMBER");
-  const [status, setStatus] = React.useState<AllocationStatus>("ACTIVE");
-  const [endTime, setEndTime] = React.useState("");
 
   React.useEffect(() => {
     if (member) {
-      setRole((member.role as MemberRole | undefined) ?? "MEMBER");
-      setStatus(member.membership_status);
-      setEndTime(member.end_time.slice(0, 10));
+      setRole(member.role === "ALLOCATION_MANAGER" ? "ALLOCATION_MANAGER" : "MEMBER");
     }
   }, [member]);
 
@@ -79,11 +71,7 @@ export function MemberEditDialog({ member, onClose, onSubmit, isPending }: Membe
           className="space-y-4"
           onSubmit={(e) => {
             e.preventDefault();
-            onSubmit({
-              role,
-              membership_status: status,
-              ...(endTime ? { end_time: new Date(endTime).toISOString() } : {}),
-            });
+            onSubmit({ role });
           }}
         >
           <div className="space-y-2">
@@ -101,28 +89,6 @@ export function MemberEditDialog({ member, onClose, onSubmit, isPending }: Membe
               ))}
             </select>
           </div>
-          <div className="space-y-2">
-            <Label htmlFor="member-status">Membership status</Label>
-            <select
-              id="member-status"
-              value={status}
-              onChange={(e) => setStatus(e.target.value as AllocationStatus)}
-              className="h-9 w-full rounded-md border bg-background px-3 text-sm"
-            >
-              <option value="ACTIVE">ACTIVE</option>
-              <option value="INACTIVE">INACTIVE</option>
-              <option value="DELETED">DELETED</option>
-            </select>
-          </div>
-          <div className="space-y-2">
-            <Label htmlFor="member-end">End date</Label>
-            <Input
-              id="member-end"
-              type="date"
-              value={endTime}
-              onChange={(e) => setEndTime(e.target.value)}
-            />
-          </div>
           <DialogFooter>
             <Button type="button" variant="outline" onClick={onClose}>
               Cancel
diff --git a/web/src/features/core/allocations/queries.ts b/web/src/features/core/allocations/queries.ts
index dce312c3c..89d70e4f2 100644
--- a/web/src/features/core/allocations/queries.ts
+++ b/web/src/features/core/allocations/queries.ts
@@ -23,6 +23,7 @@ import {
   approveChangeRequest,
   getAllocation,
   getChangeRequest,
+  listAllocationDiffs,
   listAllocationMembers,
   listAllocationResources,
   listAllocationUsage,
@@ -48,6 +49,7 @@ export const allocationKeys = {
   detail: (id: string) => [...allocationKeys.all, "detail", id] as const,
   resources: (id: string) => [...allocationKeys.detail(id), "resources"] as const,
   usage: (id: string) => [...allocationKeys.detail(id), "usage"] as const,
+  diffs: (id: string) => [...allocationKeys.detail(id), "diffs"] as const,
   members: (id: string) => [...allocationKeys.detail(id), "members"] as const,
   changeRequests: (params: ChangeRequestListParams = {}) =>
     [...allocationKeys.all, "change-requests", "list", params] as const,
@@ -110,6 +112,15 @@ export function useAllocationUsage(id: string | undefined) {
   });
 }
 
+export function useAllocationDiffs(id: string | undefined) {
+  return useQuery({
+    queryKey: id ? allocationKeys.diffs(id) : [...allocationKeys.all, "diffs", "none"],
+    queryFn: () => listAllocationDiffs(id as string),
+    enabled: Boolean(id),
+    ...DEFAULTS,
+  });
+}
+
 export function useAllocationMembers(id: string | undefined) {
   return useQuery({
     queryKey: id ? allocationKeys.members(id) : [...allocationKeys.all, "members", "none"],
diff --git a/web/src/features/core/allocations/schemas.ts b/web/src/features/core/allocations/schemas.ts
index b27f924c3..45a6759df 100644
--- a/web/src/features/core/allocations/schemas.ts
+++ b/web/src/features/core/allocations/schemas.ts
@@ -16,7 +16,7 @@
 // under the License.
 
 import { z } from "zod";
-import { zAllocationStatus } from "@/generated/core/zod.gen";
+import { zAllocationStatus, zComputeAllocationDiff } from "@/generated/core/zod.gen";
 
 export const allocationStatusSchema = zAllocationStatus;
 export type AllocationStatus = z.infer<typeof allocationStatusSchema>;
@@ -54,22 +54,30 @@ export const computeAllocationListSchema = z.object({
 });
 export type ComputeAllocationList = z.infer<typeof computeAllocationListSchema>;
 
-export const computeAllocationResourceSchema = z.object({
+// GET /compute-allocations/{id}/resources returns the resource records joined
+// through the allocation's mappings; per-allocation amounts are not exposed.
+export const attachedResourceSchema = z.object({
   id: z.string(),
   name: z.string(),
   resource_type: z.string(),
   resource_amount: z.number().int(),
+  compute_cluster_id: z.string(),
 });
-export type ComputeAllocationResource = z.infer<typeof computeAllocationResourceSchema>;
+export type AttachedResource = z.infer<typeof attachedResourceSchema>;
 
-export const computeAllocationResourceRateSchema = z.object({
-  id: z.string(),
-  compute_allocation_resource_id: z.string(),
-  rate: z.number(),
-  start_time: z.string(),
-  end_time: z.string(),
+export const attachedResourceListSchema = z.array(attachedResourceSchema);
+
+// Mirrors zComputeAllocationDiff; tighten the fields the history view reads.
+export const allocationDiffSchema = zComputeAllocationDiff.required({
+  id: true,
+  compute_allocation_id: true,
+  diff_type: true,
+  status: true,
+  timestamp: true,
 });
-export type ComputeAllocationResourceRate = z.infer<typeof computeAllocationResourceRateSchema>;
+export type AllocationDiff = z.infer<typeof allocationDiffSchema>;
+
+export const allocationDiffListSchema = z.array(allocationDiffSchema);
 
 export const allocationMembershipRoleSchema = z.enum([
   "PI",
diff --git a/web/src/features/core/projects/components/ProjectMembersTab.tsx b/web/src/features/core/projects/components/ProjectMembersTab.tsx
index a96c771d9..493dfae94 100644
--- a/web/src/features/core/projects/components/ProjectMembersTab.tsx
+++ b/web/src/features/core/projects/components/ProjectMembersTab.tsx
@@ -33,7 +33,6 @@ import {
 } from "@/shared/ui/dialog";
 import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
-import { Input } from "@/shared/ui/input";
 import { Label } from "@/shared/ui/label";
 import { TableSkeleton } from "@/shared/ui/Loading";
 import { StatusBadge } from "@/shared/ui/StatusBadge";
@@ -164,8 +163,9 @@ export function ProjectMembersTab({ projectId, canManage }: ProjectMembersTabPro
       header: "",
       align: "right",
       interactive: true,
+      // PI and CO_PI come from the upstream allocation; never edited or removed here.
       cell: (row) =>
-        canManage ? (
+        canManage && row.role !== "PI" && row.role !== "CO_PI" ? (
           <div className="flex justify-end gap-2">
             <Button
               variant="ghost"
@@ -222,7 +222,7 @@ function EditMemberDialog({ member, onClose, onSubmit, isPending }: EditMemberDi
   const [role, setRole] = React.useState<ProjectMemberRole>("MEMBER");
 
   React.useEffect(() => {
-    if (member) setRole(member.role);
+    if (member) setRole(member.role === "ALLOCATION_MANAGER" ? "ALLOCATION_MANAGER" : "MEMBER");
   }, [member]);
 
   return (
@@ -247,14 +247,10 @@ function EditMemberDialog({ member, onClose, onSubmit, isPending }: EditMemberDi
               onChange={(e) => setRole(e.target.value as ProjectMemberRole)}
               className="h-9 w-full rounded-md border bg-background px-3 text-sm"
             >
-              <option value="PI">PI</option>
-              <option value="CO_PI">Co-PI</option>
               <option value="ALLOCATION_MANAGER">Allocation Manager</option>
               <option value="MEMBER">Member</option>
             </select>
           </div>
-          {/* Spec keeps the dialog minimal; status updates land later */}
-          <Input type="hidden" />
           <DialogFooter>
             <Button type="button" variant="outline" onClick={onClose}>
               Cancel
diff --git a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
index 9780782b9..394d7493f 100644
--- a/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
+++ b/web/src/mocks/__tests__/fixtures-vs-schemas.test.ts
@@ -20,7 +20,15 @@
 import { describe, expect, it } from "vitest";
 import { z } from "zod";
 import allocationsFixture from "@/features/core/allocations/__fixtures__/allocations.json";
-import { allocationStatusSchema } from "@/features/core/allocations/schemas";
+import allocationResourcesFixture from "@/features/core/allocations/__fixtures__/resources.json";
+import allocationDiffsFixture from "@/features/core/allocations/__fixtures__/diffs.json";
+import allocationUsageFixture from "@/features/core/allocations/__fixtures__/usage.json";
+import {
+  allocationDiffSchema,
+  attachedResourceSchema,
+  allocationStatusSchema,
+  allocationUsageSchema,
+} from "@/features/core/allocations/schemas";
 import clusterUsersFixture from "@/features/core/clusters/__fixtures__/cluster-users.json";
 import clustersFixture from "@/features/core/clusters/__fixtures__/clusters.json";
 import {
@@ -64,6 +72,26 @@ describe("MSW fixtures pass current Zod schemas", () => {
     expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
   });
 
+  it("allocation resource fixtures validate against attachedResourceSchema", () => {
+    const rows = Object.values(
+      allocationResourcesFixture as Record<string, unknown[]>,
+    ).flat();
+    const result = z.array(attachedResourceSchema).safeParse(rows);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("allocation diff fixtures validate against allocationDiffSchema", () => {
+    const rows = Object.values(allocationDiffsFixture as Record<string, unknown[]>).flat();
+    const result = z.array(allocationDiffSchema).safeParse(rows);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
+  it("allocation usage fixtures validate against allocationUsageSchema", () => {
+    const rows = Object.values(allocationUsageFixture as Record<string, unknown[]>).flat();
+    const result = z.array(allocationUsageSchema).safeParse(rows);
+    expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
+  });
+
   it("cluster fixtures validate against computeClusterSchema", () => {
     const result = z.array(computeClusterSchema).safeParse(clustersFixture);
     expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true);
diff --git a/web/src/mocks/handlers/allocations.ts b/web/src/mocks/handlers/allocations.ts
index edf3c060f..226b5af3b 100644
--- a/web/src/mocks/handlers/allocations.ts
+++ b/web/src/mocks/handlers/allocations.ts
@@ -18,15 +18,19 @@
 import { http, HttpResponse } from "msw";
 import allocationsFixture from "@/features/core/allocations/__fixtures__/allocations.json";
 import resourcesFixture from "@/features/core/allocations/__fixtures__/resources.json";
+import diffsFixture from "@/features/core/allocations/__fixtures__/diffs.json";
+import usageFixture from "@/features/core/allocations/__fixtures__/usage.json";
 import membersFixture from "@/features/core/allocations/__fixtures__/members.json";
 import changeRequestsFixture from "@/features/core/allocations/__fixtures__/change-requests.json";
 import eventsFixture from "@/features/core/allocations/__fixtures__/events.json";
 import type {
+  AllocationDiff,
   AllocationMembership,
+  AttachedResource,
+  AllocationUsage,
   ChangeRequest,
   ChangeRequestEvent,
   ComputeAllocation,
-  ComputeAllocationResource,
   CreateChangeRequestPayload,
   CreateMembershipPayload,
   UpdateChangeRequestPayload,
@@ -36,11 +40,13 @@ import type {
 const allocations: ComputeAllocation[] = (allocationsFixture as ComputeAllocation[]).map((a) => ({
   ...a,
 }));
-const resourcesByAlloc: Record<string, ComputeAllocationResource[]> = Object.fromEntries(
-  Object.entries(resourcesFixture as Record<string, ComputeAllocationResource[]>).map(
+const resourcesByAlloc: Record<string, AttachedResource[]> = Object.fromEntries(
+  Object.entries(resourcesFixture as Record<string, AttachedResource[]>).map(
     ([id, rows]) => [id, rows.map((r) => ({ ...r }))],
   ),
 );
+const usageByAlloc = usageFixture as Record<string, AllocationUsage[]>;
+const diffsByAlloc = diffsFixture as Record<string, AllocationDiff[]>;
 const membersByAlloc: Record<string, AllocationMembership[]> = Object.fromEntries(
   Object.entries(membersFixture as Record<string, AllocationMembership[]>).map(([id, rows]) => [
     id,
@@ -107,8 +113,21 @@ export const allocationsHandlers = [
 
   http.get("*/api/v1/compute-allocations/:id/usages", ({ params }) => {
     const id = String(params.id);
-    void id;
-    return HttpResponse.json([]);
+    return HttpResponse.json(usageByAlloc[id] ?? []);
+  }),
+
+  http.get("*/api/v1/compute-allocations/:id/diffs", ({ params }) => {
+    const id = String(params.id);
+    return HttpResponse.json(diffsByAlloc[id] ?? []);
+  }),
+
+  http.get("*/api/v1/compute-allocations/:id/diffs/latest", ({ params }) => {
+    const id = String(params.id);
+    const rows = [...(diffsByAlloc[id] ?? [])].sort(
+      (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
+    );
+    if (rows.length === 0) return HttpResponse.json({ error: "no diffs" }, { status: 404 });
+    return HttpResponse.json(rows[0]);
   }),
 
   http.get("*/api/v1/compute-allocations/:id/memberships", ({ params }) => {
diff --git a/web/tests/allocation-members-edit.e2e.ts b/web/tests/allocation-members-edit.e2e.ts
index 584bb8027..2d0ec5194 100644
--- a/web/tests/allocation-members-edit.e2e.ts
+++ b/web/tests/allocation-members-edit.e2e.ts
@@ -39,7 +39,7 @@ test.describe("allocation members tab", () => {
     const editButtons = page.getByRole("button", { name: /^edit /i });
     await editButtons.first().click();
     await expect(page.getByRole("heading", { name: /^Edit member$/i })).toBeVisible();
-    await page.getByLabel(/^role$/i).selectOption("CO_PI");
+    await page.getByLabel(/^role$/i).selectOption("ALLOCATION_MANAGER");
     await page.getByRole("button", { name: /^save$/i }).click();
     await expect(page.getByRole("heading", { name: /^Edit member$/i })).toBeHidden({
       timeout: 15_000,
diff --git a/web/tests/allocations-detail.e2e.ts b/web/tests/allocations-detail.e2e.ts
index c4d83e8f9..7b09c2a3c 100644
--- a/web/tests/allocations-detail.e2e.ts
+++ b/web/tests/allocations-detail.e2e.ts
@@ -37,6 +37,11 @@ test.describe("allocation detail", () => {
     await expect(page.getByRole("heading", { name: /no usage recorded/i })).toBeVisible({
       timeout: 15_000,
     });
+
+    await page.getByRole("tab", { name: /^history$/i }).click();
+    await expect(page.getByText(/ALLOCATION_STATUS_CHANGE/i).first()).toBeVisible({
+      timeout: 15_000,
+    });
   });
 
   test("axe sweep on allocation detail", async ({ page }) => {


(airavata-custos) 04/08: Regenerate portal SDK for the resource summary endpoint

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

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

commit 1d87a84acd7fbe40fa5c12096a12c2c3dd11fd98
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jul 8 03:42:26 2026 -0400

    Regenerate portal SDK for the resource summary endpoint
---
 web/src/generated/core/index.ts     |  4 +--
 web/src/generated/core/sdk.gen.ts   | 13 +++++++++-
 web/src/generated/core/types.gen.ts | 51 +++++++++++++++++++++++++++++++++++++
 web/src/generated/core/zod.gen.ts   | 19 ++++++++++++++
 4 files changed, 84 insertions(+), 3 deletions(-)

diff --git a/web/src/generated/core/index.ts b/web/src/generated/core/index.ts
index e2d15c228..c28645ac0 100644
--- a/web/src/generated/core/index.ts
+++ b/web/src/generated/core/index.ts
@@ -1,4 +1,4 @@
 // This file is auto-generated by @hey-api/openapi-ts
 
-export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
-export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocationUsage, ComputeClu [...]
+export { deleteComputeAllocationChangeRequestEventsById, deleteComputeAllocationChangeRequestsById, deleteComputeAllocationDiffsById, deleteComputeAllocationMembershipResourceOverridesById, deleteComputeAllocationMembershipsById, deleteComputeAllocationsByIdResourcesByResourceId, deleteComputeAllocationUsagesById, deleteComputeClusterUsersById, deleteRolesById, deleteRolesByIdPrivilegesByKey, deleteUserIdentitiesById, deleteUsersByIdPrivilegesByKey, deleteUsersByIdRolesByRoleId, getAudit [...]
+export type { AllocationMembershipResponse, AllocationStatus, AllocationSuTotalResponse, AttachResourceRequest, CallerProfileResponse, ClientOptions, ComputeAllocation, ComputeAllocationChangeRequest, ComputeAllocationChangeRequestEvent, ComputeAllocationDiff, ComputeAllocationListResponse, ComputeAllocationMembership, ComputeAllocationMembershipResourceOverride, ComputeAllocationResource, ComputeAllocationResourceMapping, ComputeAllocationResourceRate, ComputeAllocationResourceSummary,  [...]
diff --git a/web/src/generated/core/sdk.gen.ts b/web/src/generated/core/sdk.gen.ts
index d8cd426e2..bd4bd21f9 100644
--- a/web/src/generated/core/sdk.gen.ts
+++ b/web/src/generated/core/sdk.gen.ts
@@ -2,7 +2,7 @@
 
 import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client';
 import { client } from './client.gen';
-import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
+import type { DeleteComputeAllocationChangeRequestEventsByIdData, DeleteComputeAllocationChangeRequestEventsByIdErrors, DeleteComputeAllocationChangeRequestEventsByIdResponses, DeleteComputeAllocationChangeRequestsByIdData, DeleteComputeAllocationChangeRequestsByIdErrors, DeleteComputeAllocationChangeRequestsByIdResponses, DeleteComputeAllocationDiffsByIdData, DeleteComputeAllocationDiffsByIdErrors, DeleteComputeAllocationDiffsByIdResponses, DeleteComputeAllocationMembershipResourceOverr [...]
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
     /**
@@ -387,6 +387,17 @@ export const getComputeAllocationResourcesByIdRatesEffective = <ThrowOnError ext
     ...options
 });
 
+/**
+ * List compute allocation resources with aggregate figures
+ *
+ * Each row is a resource plus its allocation count, total allocated amount, total used SUs, and rate count.
+ */
+export const getComputeAllocationResourcesSummary = <ThrowOnError extends boolean = false>(options?: Options<GetComputeAllocationResourcesSummaryData, ThrowOnError>): RequestResult<GetComputeAllocationResourcesSummaryResponses, GetComputeAllocationResourcesSummaryErrors, ThrowOnError> => (options?.client ?? client).get<GetComputeAllocationResourcesSummaryResponses, GetComputeAllocationResourcesSummaryErrors, ThrowOnError>({
+    security: [{ name: 'Authorization', type: 'apiKey' }],
+    url: '/compute-allocation-resources/summary',
+    ...options
+});
+
 /**
  * Create a compute allocation usage record
  */
diff --git a/web/src/generated/core/types.gen.ts b/web/src/generated/core/types.gen.ts
index 69ca89d0a..9d106a927 100644
--- a/web/src/generated/core/types.gen.ts
+++ b/web/src/generated/core/types.gen.ts
@@ -208,6 +208,30 @@ export type ComputeAllocationResourceRate = {
     start_time?: string;
 };
 
+export type ComputeAllocationResourceSummary = {
+    allocation_count?: number;
+    /**
+     * The ID of the compute cluster the resource (partition) belongs to.
+     */
+    compute_cluster_id?: string;
+    id?: string;
+    /**
+     * resource / partition name, e.g., "cpu-01", "gpu-01", "gpu-interactive", etc.
+     */
+    name?: string;
+    rate_count?: number;
+    /**
+     * Number of CPUs, GPUs.
+     */
+    resource_amount?: number;
+    /**
+     * TRES: cpu, gres/gpu
+     */
+    resource_type?: string;
+    total_allocated?: number;
+    total_used_su?: number;
+};
+
 export type ComputeAllocationUsage = {
     compute_allocation_id?: string;
     /**
@@ -1708,6 +1732,33 @@ export type GetComputeAllocationResourcesByIdRatesEffectiveResponses = {
 
 export type GetComputeAllocationResourcesByIdRatesEffectiveResponse = GetComputeAllocationResourcesByIdRatesEffectiveResponses[keyof GetComputeAllocationResourcesByIdRatesEffectiveResponses];
 
+export type GetComputeAllocationResourcesSummaryData = {
+    body?: never;
+    path?: never;
+    query?: never;
+    url: '/compute-allocation-resources/summary';
+};
+
+export type GetComputeAllocationResourcesSummaryErrors = {
+    /**
+     * Unauthorized
+     */
+    401: {
+        error?: string;
+    };
+};
+
+export type GetComputeAllocationResourcesSummaryError = GetComputeAllocationResourcesSummaryErrors[keyof GetComputeAllocationResourcesSummaryErrors];
+
+export type GetComputeAllocationResourcesSummaryResponses = {
+    /**
+     * OK
+     */
+    200: Array<ComputeAllocationResourceSummary>;
+};
+
+export type GetComputeAllocationResourcesSummaryResponse = GetComputeAllocationResourcesSummaryResponses[keyof GetComputeAllocationResourcesSummaryResponses];
+
 export type PostComputeAllocationUsagesData = {
     /**
      * Usage payload
diff --git a/web/src/generated/core/zod.gen.ts b/web/src/generated/core/zod.gen.ts
index 19ccd7926..c69932055 100644
--- a/web/src/generated/core/zod.gen.ts
+++ b/web/src/generated/core/zod.gen.ts
@@ -138,6 +138,20 @@ export const zComputeAllocationResourceRate = z.object({
 
 export type computeAllocationResourceRateZodType = z.infer<typeof zComputeAllocationResourceRate>;
 
+export const zComputeAllocationResourceSummary = z.object({
+    allocation_count: z.int().optional(),
+    compute_cluster_id: z.string().optional(),
+    id: z.string().optional(),
+    name: z.string().optional(),
+    rate_count: z.int().optional(),
+    resource_amount: z.int().optional(),
+    resource_type: z.string().optional(),
+    total_allocated: z.int().optional(),
+    total_used_su: z.number().optional()
+});
+
+export type computeAllocationResourceSummaryZodType = z.infer<typeof zComputeAllocationResourceSummary>;
+
 export const zComputeAllocationUsage = z.object({
     compute_allocation_id: z.string().optional(),
     compute_allocation_resource_id: z.string().optional(),
@@ -804,6 +818,11 @@ export const zGetComputeAllocationResourcesByIdRatesEffectiveQuery = z.object({
  */
 export const zGetComputeAllocationResourcesByIdRatesEffectiveResponse = zComputeAllocationResourceRate;
 
+/**
+ * OK
+ */
+export const zGetComputeAllocationResourcesSummaryResponse = z.array(zComputeAllocationResourceSummary);
+
 /**
  * Usage payload
  */