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();