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/23 19:18:40 UTC

(airavata-custos) 08/35: Add access requests admin queue with bulk approve

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

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

commit 2163ee67eb179d863c41a0a8e1f46652a6291b0e
Author: lahiruj <la...@gmail.com>
AuthorDate: Thu Jul 16 02:06:49 2026 -0400

    Add access requests admin queue with bulk approve
---
 .../app/(portal)/admin/access-requests/page.tsx    |   8 +-
 .../__tests__/AccessRequestDrawer.test.tsx         |  99 +++++
 .../__tests__/AccessRequestsQueue.test.tsx         | 182 +++++++++
 .../components/AccessRequestDrawer.tsx             | 127 ++++++
 .../components/AccessRequestsQueue.tsx             | 454 +++++++++++++++++++++
 web/src/features/core/access-requests/queries.ts   |  14 +
 web/src/features/core/access-requests/schemas.ts   |   3 +
 web/src/mocks/handlers/access-requests.ts          |  37 +-
 8 files changed, 916 insertions(+), 8 deletions(-)

diff --git a/web/src/app/(portal)/admin/access-requests/page.tsx b/web/src/app/(portal)/admin/access-requests/page.tsx
index 4be4e6d63..839bcc93b 100644
--- a/web/src/app/(portal)/admin/access-requests/page.tsx
+++ b/web/src/app/(portal)/admin/access-requests/page.tsx
@@ -16,21 +16,17 @@
 // under the License.
 
 import type { Metadata } from "next";
-import { EmptyState } from "@/shared/ui/EmptyState";
+import { AccessRequestsQueue } from "@/features/core/access-requests/components/AccessRequestsQueue";
 
 export const metadata: Metadata = {
   title: "Access Requests — Admin",
 };
 
-// Placeholder: the queue lands in the next phase on this branch.
 export default function AdminAccessRequestsPage() {
   return (
     <div className="flex flex-col gap-6">
       <h1 className="font-display text-[28px] font-bold leading-tight">Access Requests</h1>
-      <EmptyState
-        heading="Review queue coming in this branch"
-        description="Pending trial-access requests will be listed here for approval."
-      />
+      <AccessRequestsQueue />
     </div>
   );
 }
diff --git a/web/src/features/core/access-requests/__tests__/AccessRequestDrawer.test.tsx b/web/src/features/core/access-requests/__tests__/AccessRequestDrawer.test.tsx
new file mode 100644
index 000000000..636946781
--- /dev/null
+++ b/web/src/features/core/access-requests/__tests__/AccessRequestDrawer.test.tsx
@@ -0,0 +1,99 @@
+// 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 { AccessRequest } from "../schemas";
+
+vi.mock("@/features/core/allocations/queries", () => ({
+  useAllocation: (id: string | undefined) => ({
+    data: id ? { id, name: "pearc26-tutorial", initial_su_amount: 25000 } : undefined,
+  }),
+}));
+
+vi.mock("next/navigation", () => ({
+  useSearchParams: () => new URLSearchParams(""),
+}));
+
+import { AccessRequestDrawer } from "../components/AccessRequestDrawer";
+
+const base: AccessRequest = {
+  id: "areq-1",
+  oidc_sub: "sub-1",
+  email: "amara.osei@example.edu",
+  name: "Amara Osei",
+  institution: "Example University",
+  event_code: "PEARC26",
+  reason: "Hands-on tutorial attendee.",
+  status: "PENDING",
+  approver_id: "",
+  deny_reason: "",
+  expires_at: null,
+  created_user_id: "",
+  timestamp: "2026-07-10T14:05:00Z",
+  decided_at: null,
+  allocation_id: "alloc-1",
+};
+
+describe("<AccessRequestDrawer />", () => {
+  it("renders request fields without a decision block while pending", () => {
+    render(<AccessRequestDrawer request={base} onClose={vi.fn()} />);
+    expect(screen.getByText("Example University")).toBeInTheDocument();
+    expect(screen.getByText("Hands-on tutorial attendee.")).toBeInTheDocument();
+    expect(screen.queryByText("Decision")).not.toBeInTheDocument();
+  });
+
+  it("renders the decision block with allocation and expiry for approved", () => {
+    render(
+      <AccessRequestDrawer
+        request={{
+          ...base,
+          status: "APPROVED",
+          approver_id: "user-admin-0001",
+          decided_at: "2026-07-11T08:00:00Z",
+          expires_at: "2026-08-09T00:00:00Z",
+        }}
+        onClose={vi.fn()}
+      />,
+    );
+    expect(screen.getByText("Decision")).toBeInTheDocument();
+    expect(screen.getByText("Approved")).toBeInTheDocument();
+    expect(screen.getByText("user-adm…")).toBeInTheDocument();
+    expect(screen.getByText(/pearc26-tutorial/)).toBeInTheDocument();
+    const credits = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(25000);
+    expect(screen.getByText(new RegExp(`${credits} credits`))).toBeInTheDocument();
+    expect(screen.getByText("Allocation ends")).toBeInTheDocument();
+  });
+
+  it("renders the deny reason verbatim for denied", () => {
+    render(
+      <AccessRequestDrawer
+        request={{
+          ...base,
+          status: "DENIED",
+          approver_id: "user-admin-0001",
+          decided_at: "2026-07-11T08:00:00Z",
+          deny_reason: "Not a tutorial attendee",
+        }}
+        onClose={vi.fn()}
+      />,
+    );
+    expect(screen.getByText("Denied")).toBeInTheDocument();
+    expect(screen.getByText("Not a tutorial attendee")).toBeInTheDocument();
+    expect(screen.queryByText("Granted allocation")).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/features/core/access-requests/__tests__/AccessRequestsQueue.test.tsx b/web/src/features/core/access-requests/__tests__/AccessRequestsQueue.test.tsx
new file mode 100644
index 000000000..27251e52b
--- /dev/null
+++ b/web/src/features/core/access-requests/__tests__/AccessRequestsQueue.test.tsx
@@ -0,0 +1,182 @@
+// 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 { act, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { AccessRequest } from "../schemas";
+
+let rows: AccessRequest[] = [];
+const decideMutate = vi.fn();
+const decideMutateAsync = vi.fn();
+
+vi.mock("../queries", () => ({
+  useAccessRequests: () => ({ data: rows, isLoading: false, error: null, refetch: vi.fn() }),
+  useDecideAccessRequest: () => ({ mutate: decideMutate, mutateAsync: decideMutateAsync }),
+}));
+
+vi.mock("sonner", () => ({
+  toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
+}));
+
+vi.mock("next/navigation", () => ({
+  useSearchParams: () => new URLSearchParams(""),
+}));
+
+// The drawer pulls the allocation feature; keep the queue tests self-contained.
+vi.mock("@/features/core/allocations/queries", () => ({
+  useAllocation: () => ({ data: undefined }),
+}));
+
+import { toast } from "sonner";
+import { AccessRequestsQueue } from "../components/AccessRequestsQueue";
+
+function request(id: string, name: string, overrides: Partial<AccessRequest> = {}): AccessRequest {
+  return {
+    id,
+    oidc_sub: `sub-${id}`,
+    email: `${id}@example.edu`,
+    name,
+    institution: "Example University",
+    event_code: "PEARC26",
+    reason: "",
+    status: "PENDING",
+    approver_id: "",
+    deny_reason: "",
+    expires_at: null,
+    created_user_id: "",
+    timestamp: "2026-07-10T14:05:00Z",
+    decided_at: null,
+    allocation_id: "alloc-1",
+    ...overrides,
+  };
+}
+
+function rowFor(name: string): HTMLElement {
+  const cell = screen.getByText(name);
+  const tr = cell.closest("tr");
+  if (!tr) throw new Error(`no row for ${name}`);
+  return tr;
+}
+
+beforeEach(() => {
+  rows = [
+    request("areq-1", "Amara Osei"),
+    request("areq-2", "Li Wei"),
+    request("areq-3", "Marcus Rivera", {
+      status: "APPROVED",
+      approver_id: "user-admin-001",
+      decided_at: "2026-07-11T08:00:00Z",
+      expires_at: "2026-08-09T00:00:00Z",
+    }),
+  ];
+  decideMutate.mockReset();
+  decideMutateAsync
+    .mockReset()
+    .mockResolvedValue(request("areq-1", "Amara Osei", { status: "APPROVED" }));
+  vi.mocked(toast).mockClear();
+  vi.mocked(toast.error).mockClear();
+});
+
+afterEach(() => {
+  vi.useRealTimers();
+});
+
+describe("<AccessRequestsQueue />", () => {
+  it("selects only pending rows and shows the bulk bar count", () => {
+    render(<AccessRequestsQueue />);
+
+    // Decided rows have no checkbox.
+    expect(within(rowFor("Marcus Rivera")).queryByRole("checkbox")).not.toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole("checkbox", { name: "Select all pending" }));
+    expect(screen.getByRole("button", { name: "Approve 2 selected" })).toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole("checkbox", { name: "Select Li Wei" }));
+    expect(screen.getByRole("button", { name: "Approve 1 selected" })).toBeInTheDocument();
+  });
+
+  it("does not fire the PUT when undo is clicked inside the window", () => {
+    vi.useFakeTimers();
+    render(<AccessRequestsQueue />);
+
+    fireEvent.click(within(rowFor("Amara Osei")).getByRole("button", { name: "Approve" }));
+    expect(within(rowFor("Amara Osei")).getByText("Approved")).toBeInTheDocument();
+    expect(decideMutate).not.toHaveBeenCalled();
+
+    const [, opts] = vi.mocked(toast).mock.calls[0] as unknown as [
+      string,
+      { action: { onClick: () => void } },
+    ];
+    act(() => {
+      opts.action.onClick();
+    });
+    act(() => {
+      vi.advanceTimersByTime(6000);
+    });
+
+    expect(decideMutate).not.toHaveBeenCalled();
+    expect(within(rowFor("Amara Osei")).getByText("Pending")).toBeInTheDocument();
+  });
+
+  it("fires the PUT only after the undo window expires", () => {
+    vi.useFakeTimers();
+    render(<AccessRequestsQueue />);
+
+    fireEvent.click(within(rowFor("Amara Osei")).getByRole("button", { name: "Approve" }));
+    act(() => {
+      vi.advanceTimersByTime(4999);
+    });
+    expect(decideMutate).not.toHaveBeenCalled();
+
+    act(() => {
+      vi.advanceTimersByTime(1);
+    });
+    expect(decideMutate).toHaveBeenCalledTimes(1);
+    expect(decideMutate.mock.calls[0]?.[0]).toEqual({
+      id: "areq-1",
+      body: { status: "APPROVED" },
+    });
+  });
+
+  it("round-trips the deny reason through the popover", () => {
+    render(<AccessRequestsQueue />);
+
+    fireEvent.click(within(rowFor("Li Wei")).getByRole("button", { name: "Deny" }));
+    fireEvent.change(screen.getByLabelText("Reason (optional)"), {
+      target: { value: "Not a tutorial attendee" },
+    });
+    fireEvent.click(screen.getByRole("button", { name: "Deny request" }));
+
+    expect(decideMutate).toHaveBeenCalledTimes(1);
+    expect(decideMutate.mock.calls[0]?.[0]).toEqual({
+      id: "areq-2",
+      body: { status: "DENIED", deny_reason: "Not a tutorial attendee" },
+    });
+  });
+
+  it("bulk approve issues one PUT per selected row", async () => {
+    render(<AccessRequestsQueue />);
+
+    fireEvent.click(screen.getByRole("checkbox", { name: "Select all pending" }));
+    await act(async () => {
+      fireEvent.click(screen.getByRole("button", { name: "Approve 2 selected" }));
+    });
+
+    expect(decideMutateAsync).toHaveBeenCalledTimes(2);
+    expect(decideMutateAsync.mock.calls.map((c) => c[0]?.id)).toEqual(["areq-1", "areq-2"]);
+  });
+});
diff --git a/web/src/features/core/access-requests/components/AccessRequestDrawer.tsx b/web/src/features/core/access-requests/components/AccessRequestDrawer.tsx
new file mode 100644
index 000000000..88e094f6b
--- /dev/null
+++ b/web/src/features/core/access-requests/components/AccessRequestDrawer.tsx
@@ -0,0 +1,127 @@
+// 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 { useAllocation } from "@/features/core/allocations/queries";
+import { formatCreditsFull } from "@/features/core/analytics/lib";
+import { SideDrawer } from "@/shared/ui/SideDrawer";
+import type { AccessRequest } from "../schemas";
+import { formatDate, statusBadgeFor } from "./AccessRequestsQueue";
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <div className="grid grid-cols-[140px_1fr] gap-2 text-sm">
+      <dt className="text-muted-foreground">{label}</dt>
+      <dd className="text-foreground">{children}</dd>
+    </div>
+  );
+}
+
+function GrantedAllocation({ allocationId }: { allocationId: string }) {
+  const { data: allocation } = useAllocation(allocationId);
+  if (!allocation) return <span className="text-muted-foreground">—</span>;
+  return (
+    <span>
+      {allocation.name}
+      <span className="text-muted-foreground">
+        {" "}
+        · {formatCreditsFull(allocation.initial_su_amount)} credits
+      </span>
+    </span>
+  );
+}
+
+export function AccessRequestDrawer({
+  request,
+  onClose,
+}: {
+  request: AccessRequest | null;
+  onClose: () => void;
+}) {
+  const decided = request !== null && request.status !== "PENDING";
+  return (
+    <SideDrawer
+      open={request !== null}
+      onOpenChange={(open) => {
+        if (!open) onClose();
+      }}
+      title={request?.name}
+      description={request?.email}
+      width="sm"
+      modal={false}
+      disablePointerDismissal
+    >
+      {request && (
+        <div className="space-y-5">
+          <section>
+            <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+              Request
+            </h3>
+            <dl className="space-y-2">
+              <Field label="Institution">{request.institution}</Field>
+              <Field label="Event">
+                <span className="font-mono text-xs">{request.event_code}</span>
+              </Field>
+              <Field label="Reason">{request.reason || "—"}</Field>
+              <Field label="Submitted">{formatDate(request.timestamp)}</Field>
+            </dl>
+          </section>
+
+          {decided ? (
+            <>
+              <div className="border-t border-border" />
+              <section>
+                <h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
+                  Decision
+                </h3>
+                <dl className="space-y-2">
+                  <Field label="Status">{statusBadgeFor(request.status)}</Field>
+                  <Field label="Decided by">
+                    <span className="font-mono text-xs" title={request.approver_id}>
+                      {request.approver_id ? `${request.approver_id.slice(0, 8)}…` : "—"}
+                    </span>
+                  </Field>
+                  <Field label="Decided at">
+                    {request.decided_at ? formatDate(request.decided_at) : "—"}
+                  </Field>
+                  {request.status === "APPROVED" ? (
+                    <>
+                      <Field label="Granted allocation">
+                        {request.allocation_id ? (
+                          <GrantedAllocation allocationId={request.allocation_id} />
+                        ) : (
+                          "—"
+                        )}
+                      </Field>
+                      <Field label="Allocation ends">
+                        {request.expires_at ? formatDate(request.expires_at) : "—"}
+                      </Field>
+                    </>
+                  ) : (
+                    <Field label="Deny reason">{request.deny_reason || "—"}</Field>
+                  )}
+                </dl>
+              </section>
+            </>
+          ) : null}
+        </div>
+      )}
+    </SideDrawer>
+  );
+}
diff --git a/web/src/features/core/access-requests/components/AccessRequestsQueue.tsx b/web/src/features/core/access-requests/components/AccessRequestsQueue.tsx
new file mode 100644
index 000000000..1a9a37c47
--- /dev/null
+++ b/web/src/features/core/access-requests/components/AccessRequestsQueue.tsx
@@ -0,0 +1,454 @@
+// 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 { Popover as PopoverPrimitive } from "@base-ui/react/popover";
+import * as React from "react";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import {
+  replaceShallowSearchParams,
+  useShallowSearchParams,
+} from "@/shared/hooks/useShallowSearchParams";
+import { Button } from "@/shared/ui/button";
+import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
+import { ErrorState } from "@/shared/ui/ErrorState";
+import { Input } from "@/shared/ui/input";
+import { TableSkeleton } from "@/shared/ui/Loading";
+import { StatusBadge } from "@/shared/ui/StatusBadge";
+import { useAccessRequests, useDecideAccessRequest } from "../queries";
+import type { AccessRequest, AccessRequestStatus } from "../schemas";
+import { AccessRequestDrawer } from "./AccessRequestDrawer";
+
+const APPROVE_UNDO_MS = 5000;
+
+const STATUS_TABS = [
+  { value: "pending", label: "Pending" },
+  { value: "approved", label: "Approved" },
+  { value: "denied", label: "Denied" },
+  { value: "all", label: "All" },
+] as const;
+
+type StatusTab = (typeof STATUS_TABS)[number]["value"];
+
+function serverStatusFor(tab: StatusTab): string | undefined {
+  return tab === "all" ? undefined : tab.toUpperCase();
+}
+
+export function formatDate(iso: string): string {
+  try {
+    return new Date(iso).toLocaleDateString(undefined, {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+    });
+  } catch {
+    return iso;
+  }
+}
+
+export function statusBadgeFor(status: AccessRequestStatus) {
+  if (status === "APPROVED") return <StatusBadge variant="approved" />;
+  if (status === "DENIED") return <StatusBadge variant="rejected" label="Denied" />;
+  return <StatusBadge variant="pending" />;
+}
+
+function DenyPopover({
+  requestName,
+  disabled,
+  onDeny,
+}: {
+  requestName: string;
+  disabled?: boolean;
+  onDeny: (reason: string) => void;
+}) {
+  const [open, setOpen] = React.useState(false);
+  const [reason, setReason] = React.useState("");
+  return (
+    <PopoverPrimitive.Root
+      open={open}
+      onOpenChange={(next) => {
+        setOpen(next);
+        if (next) setReason("");
+      }}
+    >
+      <PopoverPrimitive.Trigger
+        render={
+          <Button variant="outline" size="sm" disabled={disabled}>
+            Deny
+          </Button>
+        }
+      />
+      <PopoverPrimitive.Portal>
+        <PopoverPrimitive.Positioner sideOffset={4} className="z-50 outline-none">
+          <PopoverPrimitive.Popup
+            className={cn(
+              "z-50 w-72 rounded-lg bg-popover p-3 text-popover-foreground shadow-md ring-1 ring-foreground/10",
+              "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+            )}
+          >
+            <label
+              className="mb-1 block text-xs font-medium text-muted-foreground"
+              htmlFor={`deny-reason-${requestName}`}
+            >
+              Reason (optional)
+            </label>
+            <textarea
+              id={`deny-reason-${requestName}`}
+              value={reason}
+              onChange={(e) => setReason(e.target.value)}
+              rows={3}
+              className="w-full rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground"
+            />
+            <div className="mt-2 flex justify-end gap-2">
+              <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
+                Cancel
+              </Button>
+              <Button
+                variant="destructive"
+                size="sm"
+                onClick={() => {
+                  setOpen(false);
+                  onDeny(reason.trim());
+                }}
+              >
+                Deny request
+              </Button>
+            </div>
+          </PopoverPrimitive.Popup>
+        </PopoverPrimitive.Positioner>
+      </PopoverPrimitive.Portal>
+    </PopoverPrimitive.Root>
+  );
+}
+
+export function AccessRequestsQueue() {
+  const searchParams = useShallowSearchParams();
+  const statusParam = searchParams.get("status") ?? "pending";
+  const statusTab: StatusTab = STATUS_TABS.some((t) => t.value === statusParam)
+    ? (statusParam as StatusTab)
+    : "pending";
+  const search = searchParams.get("q") ?? "";
+  const eventFilter = searchParams.get("event") ?? "all";
+
+  function updateParam(key: string, value: string | null) {
+    const params = new URLSearchParams(searchParams.toString());
+    if (!value || value === "all") params.delete(key);
+    else params.set(key, value);
+    replaceShallowSearchParams(params);
+  }
+
+  const { data, isLoading, error, refetch } = useAccessRequests(
+    { status: serverStatusFor(statusTab) },
+    { refetchInterval: 20_000 },
+  );
+  const decide = useDecideAccessRequest();
+
+  // Rows the admin just decided, shown in place until the next refetch swaps
+  // the server data out from under them.
+  const [optimistic, setOptimistic] = React.useState<Record<string, AccessRequest>>({});
+  const [selected, setSelected] = React.useState<Set<string>>(new Set());
+  const [drawerId, setDrawerId] = React.useState<string | null>(null);
+  const [bulkProgress, setBulkProgress] = React.useState<string | null>(null);
+  const undoTimers = React.useRef(new Map<string, ReturnType<typeof setTimeout>>());
+
+  const rows = React.useMemo(() => {
+    const base = (data ?? []).map((r) => optimistic[r.id] ?? r);
+    const needle = search.trim().toLowerCase();
+    return base.filter((r) => {
+      if (needle) {
+        const hay = `${r.name} ${r.email} ${r.institution}`.toLowerCase();
+        if (!hay.includes(needle)) return false;
+      }
+      if (eventFilter !== "all" && r.event_code !== eventFilter) return false;
+      return true;
+    });
+  }, [data, optimistic, search, eventFilter]);
+
+  const eventCodes = React.useMemo(
+    () => [...new Set((data ?? []).map((r) => r.event_code))].sort(),
+    [data],
+  );
+
+  const pendingRows = rows.filter((r) => r.status === "PENDING");
+  const allPendingSelected = pendingRows.length > 0 && pendingRows.every((r) => selected.has(r.id));
+
+  function toggleAll() {
+    setSelected(allPendingSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
+  }
+
+  function toggleOne(id: string) {
+    setSelected((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }
+
+  function deselect(id: string) {
+    setSelected((prev) => {
+      if (!prev.has(id)) return prev;
+      const next = new Set(prev);
+      next.delete(id);
+      return next;
+    });
+  }
+
+  function revertOptimistic(id: string) {
+    setOptimistic((prev) => {
+      const next = { ...prev };
+      delete next[id];
+      return next;
+    });
+  }
+
+  // Single-click approve is optimistic: the row flips to APPROVED at once and
+  // the PUT fires only after the undo window closes. Closing the tab or
+  // leaving the app inside the window drops the approval — accepted PoC risk.
+  function approveWithUndo(row: AccessRequest) {
+    setOptimistic((prev) => ({ ...prev, [row.id]: { ...row, status: "APPROVED" } }));
+    deselect(row.id);
+    const timer = setTimeout(() => {
+      undoTimers.current.delete(row.id);
+      decide.mutate(
+        { id: row.id, body: { status: "APPROVED" } },
+        {
+          onError: (err) => {
+            revertOptimistic(row.id);
+            toast.error(`Approving ${row.name} failed: ${err.message}`);
+          },
+        },
+      );
+    }, APPROVE_UNDO_MS);
+    undoTimers.current.set(row.id, timer);
+    toast(`Approved ${row.name}`, {
+      duration: APPROVE_UNDO_MS,
+      action: {
+        label: "Undo",
+        onClick: () => {
+          const pending = undoTimers.current.get(row.id);
+          if (pending) {
+            clearTimeout(pending);
+            undoTimers.current.delete(row.id);
+          }
+          revertOptimistic(row.id);
+        },
+      },
+    });
+  }
+
+  function deny(row: AccessRequest, reason: string) {
+    decide.mutate(
+      { id: row.id, body: { status: "DENIED", deny_reason: reason || undefined } },
+      {
+        onSuccess: (updated) => {
+          setOptimistic((prev) => ({ ...prev, [row.id]: updated }));
+          deselect(row.id);
+        },
+        onError: (err) => toast.error(`Denying ${row.name} failed: ${err.message}`),
+      },
+    );
+  }
+
+  // Bulk approve is immediate (no undo): N sequential PUTs with per-row errors.
+  async function bulkApprove() {
+    const targets = pendingRows.filter((r) => selected.has(r.id));
+    for (const [i, row] of targets.entries()) {
+      setBulkProgress(`Approving ${i + 1} of ${targets.length}…`);
+      try {
+        const updated = await decide.mutateAsync({ id: row.id, body: { status: "APPROVED" } });
+        setOptimistic((prev) => ({ ...prev, [row.id]: updated }));
+        deselect(row.id);
+      } catch (err) {
+        toast.error(
+          `Approving ${row.name} failed: ${err instanceof Error ? err.message : String(err)}`,
+        );
+      }
+    }
+    setBulkProgress(null);
+  }
+
+  const columns: Array<DataTableColumn<AccessRequest>> = [
+    {
+      key: "select",
+      header: (
+        <input
+          type="checkbox"
+          aria-label="Select all pending"
+          checked={allPendingSelected}
+          onChange={toggleAll}
+        />
+      ),
+      width: "32px",
+      interactive: true,
+      cell: (row) =>
+        row.status === "PENDING" ? (
+          <input
+            type="checkbox"
+            aria-label={`Select ${row.name}`}
+            checked={selected.has(row.id)}
+            onChange={() => toggleOne(row.id)}
+            onClick={(e) => e.stopPropagation()}
+          />
+        ) : null,
+    },
+    {
+      key: "name",
+      header: "Name",
+      cell: (row) => <span className="font-medium text-foreground">{row.name}</span>,
+    },
+    {
+      key: "email",
+      header: "Email",
+      cell: (row) => <span className="text-muted-foreground">{row.email}</span>,
+    },
+    {
+      key: "institution",
+      header: "Institution",
+      cell: (row) => <span className="text-muted-foreground">{row.institution}</span>,
+    },
+    {
+      key: "event",
+      header: "Event",
+      cell: (row) => <span className="font-mono text-xs">{row.event_code}</span>,
+    },
+    {
+      key: "submitted",
+      header: "Submitted",
+      sortable: true,
+      sortValue: (row) => new Date(row.timestamp),
+      cell: (row) => (
+        <span className="text-xs tabular-nums text-muted-foreground">
+          {formatDate(row.timestamp)}
+        </span>
+      ),
+    },
+    {
+      key: "status",
+      header: "Status",
+      cell: (row) => statusBadgeFor(row.status),
+    },
+    {
+      key: "actions",
+      header: "",
+      align: "right",
+      interactive: true,
+      cell: (row) =>
+        row.status === "PENDING" ? (
+          <span className="flex justify-end gap-2">
+            <Button
+              type="button"
+              size="sm"
+              disabled={bulkProgress !== null}
+              onClick={(e) => {
+                e.stopPropagation();
+                approveWithUndo(row);
+              }}
+            >
+              Approve
+            </Button>
+            <DenyPopover
+              requestName={row.name}
+              disabled={bulkProgress !== null}
+              onDeny={(reason) => deny(row, reason)}
+            />
+          </span>
+        ) : null,
+    },
+  ];
+
+  const drawerRow = rows.find((r) => r.id === drawerId) ?? null;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex flex-col gap-3 rounded-md border bg-card p-4 sm:flex-row sm:items-center">
+        <fieldset aria-label="Filter by status" className="flex gap-1">
+          {STATUS_TABS.map((tab) => (
+            <Button
+              key={tab.value}
+              variant={statusTab === tab.value ? "default" : "ghost"}
+              size="sm"
+              onClick={() => updateParam("status", tab.value === "pending" ? null : tab.value)}
+            >
+              {tab.label}
+            </Button>
+          ))}
+        </fieldset>
+        <Input
+          type="search"
+          placeholder="Search by name, email, or institution"
+          value={search}
+          onChange={(e) => updateParam("q", e.target.value)}
+          aria-label="Search requests"
+          className="sm:ml-2 sm:w-72"
+        />
+        <select
+          value={eventFilter}
+          onChange={(e) => updateParam("event", e.target.value)}
+          aria-label="Filter by event"
+          className="h-9 rounded-md border bg-background px-3 text-sm"
+        >
+          <option value="all">All events</option>
+          {eventCodes.map((code) => (
+            <option key={code} value={code}>
+              {code}
+            </option>
+          ))}
+        </select>
+      </div>
+
+      {selected.size > 0 ? (
+        <div className="flex items-center justify-between rounded-md border bg-card p-4">
+          <span className="text-sm text-muted-foreground">
+            {bulkProgress ?? `${selected.size} selected`}
+          </span>
+          <Button disabled={bulkProgress !== null} onClick={bulkApprove}>
+            Approve {selected.size} selected
+          </Button>
+        </div>
+      ) : null}
+
+      {error ? (
+        <ErrorState message={error.message ?? "Failed to load requests"} onRetry={refetch} />
+      ) : isLoading ? (
+        <TableSkeleton />
+      ) : (
+        <DataTable
+          columns={columns}
+          rows={rows}
+          rowKey={(row) => row.id}
+          onRowClick={(row) => setDrawerId((prev) => (prev === row.id ? null : row.id))}
+          rowClassName={(row) =>
+            row.id === drawerId
+              ? "bg-[color:var(--brand-tint)] hover:bg-[color:var(--brand-tint)]"
+              : undefined
+          }
+          caption="Access requests"
+          empty={
+            <span className="text-sm text-muted-foreground">
+              No access requests match the current filters.
+            </span>
+          }
+        />
+      )}
+
+      <AccessRequestDrawer request={drawerRow} onClose={() => setDrawerId(null)} />
+    </div>
+  );
+}
diff --git a/web/src/features/core/access-requests/queries.ts b/web/src/features/core/access-requests/queries.ts
index f2522c76d..363b72a41 100644
--- a/web/src/features/core/access-requests/queries.ts
+++ b/web/src/features/core/access-requests/queries.ts
@@ -19,6 +19,7 @@
 
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useSession } from "next-auth/react";
+import * as React from "react";
 import {
   type AccessRequestListFilter,
   createAccessRequest,
@@ -80,6 +81,19 @@ export function useAccessRequests(
   });
 }
 
+// Trial users are the ones an approved access request created; the map keys
+// their user id to the trial end date. Admin-gated pages only.
+export function useTrialExpiryByUserId(): Map<string, string> {
+  const { data } = useAccessRequests({ status: "APPROVED" });
+  return React.useMemo(() => {
+    const map = new Map<string, string>();
+    for (const r of data ?? []) {
+      if (r.created_user_id && r.expires_at) map.set(r.created_user_id, r.expires_at);
+    }
+    return map;
+  }, [data]);
+}
+
 export function useDecideAccessRequest() {
   const queryClient = useQueryClient();
   return useMutation({
diff --git a/web/src/features/core/access-requests/schemas.ts b/web/src/features/core/access-requests/schemas.ts
index 5fee017fb..fa96ca087 100644
--- a/web/src/features/core/access-requests/schemas.ts
+++ b/web/src/features/core/access-requests/schemas.ts
@@ -46,6 +46,9 @@ export const accessRequestSchema = z.object({
     .nullish()
     .transform((v) => v ?? ""),
   timestamp: z.string(),
+  // Present only on the privileged list; /me responses omit both.
+  decided_at: z.string().nullish(),
+  allocation_id: z.string().nullish(),
 });
 export type AccessRequest = z.infer<typeof accessRequestSchema>;
 
diff --git a/web/src/mocks/handlers/access-requests.ts b/web/src/mocks/handlers/access-requests.ts
index 158b1f69c..e10666ff9 100644
--- a/web/src/mocks/handlers/access-requests.ts
+++ b/web/src/mocks/handlers/access-requests.ts
@@ -22,6 +22,10 @@ const EVENT_NAMES: Record<string, string> = {
   PEARC26: "pearc26-tutorial",
 };
 
+const EVENT_ALLOCATIONS: Record<string, string> = {
+  PEARC26: "alloc-pearc26-001",
+};
+
 function seedRequests(): AccessRequest[] {
   return [
     {
@@ -38,6 +42,7 @@ function seedRequests(): AccessRequest[] {
       expires_at: null,
       created_user_id: "",
       timestamp: "2026-07-10T14:05:00Z",
+      decided_at: null,
     },
     {
       id: "areq-002",
@@ -53,6 +58,23 @@ function seedRequests(): AccessRequest[] {
       expires_at: null,
       created_user_id: "",
       timestamp: "2026-07-11T09:30:00Z",
+      decided_at: null,
+    },
+    {
+      id: "areq-003",
+      oidc_sub: "sub-queue-003",
+      email: "marcus.rivera@utexas.edu",
+      name: "Marcus Rivera",
+      institution: "University of Texas",
+      event_code: "PEARC26",
+      reason: "Tutorial follow-up experiments.",
+      status: "APPROVED",
+      approver_id: "user-admin-001",
+      deny_reason: "",
+      expires_at: "2026-08-09T00:00:00Z",
+      created_user_id: "u2",
+      timestamp: "2026-07-09T11:00:00Z",
+      decided_at: "2026-07-10T08:15:00Z",
     },
   ];
 }
@@ -68,8 +90,16 @@ export function resetAccessRequests() {
 
 const notFound = () => HttpResponse.json({ error: "not found" }, { status: 404 });
 
+// The privileged list carries decision context; /me stays the bare model.
+function bare(row: AccessRequest): Omit<AccessRequest, "decided_at" | "allocation_id"> {
+  const { decided_at: _decidedAt, allocation_id: _allocationId, ...rest } = row;
+  return rest;
+}
+
 export const accessRequestsHandlers = [
-  http.get("*/api/v1/access-requests/me", () => (mine ? HttpResponse.json(mine) : notFound())),
+  http.get("*/api/v1/access-requests/me", () =>
+    mine ? HttpResponse.json(bare(mine)) : notFound(),
+  ),
 
   http.get("*/api/v1/access-requests/events/:code", ({ params }) => {
     const code = String(params.code);
@@ -119,7 +149,9 @@ export const accessRequestsHandlers = [
     let rows = requests;
     if (status) rows = rows.filter((r) => r.status === status);
     if (event) rows = rows.filter((r) => r.event_code === event);
-    return HttpResponse.json(rows);
+    return HttpResponse.json(
+      rows.map((r) => ({ ...r, allocation_id: EVENT_ALLOCATIONS[r.event_code] ?? null })),
+    );
   }),
 
   http.put("*/api/v1/access-requests/:id", async ({ params, request }) => {
@@ -138,6 +170,7 @@ export const accessRequestsHandlers = [
     }
     row.status = body.status;
     row.approver_id = "user-admin-001";
+    row.decided_at = new Date().toISOString();
     if (body.status === "APPROVED") {
       row.expires_at =
         body.expires_at ?? new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString();