From 7999fa5be0e2c35f8f5ca51ddfa0a544c982f3dd Mon Sep 17 00:00:00 2001 From: Rajat Garga Date: Mon, 1 Jun 2026 15:03:30 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20age-gate=20UI=20=E2=80=94=20admin?= =?UTF-8?q?=20review=20queue=20and=20per-repo=20config=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interactions/operations/age-gate.spec.ts | 35 ++ .../(admin)/age-gate/__tests__/page.test.tsx | 42 +++ src/app/(app)/(admin)/age-gate/page.tsx | 329 ++++++++++++++++++ src/app/(app)/(protected)/webhooks/page.tsx | 3 + .../_components/age-gate-settings.test.tsx | 89 +++++ .../_components/age-gate-settings.tsx | 108 ++++++ .../notifications-tab-content.test.tsx | 4 +- .../_components/notifications-tab-content.tsx | 15 + .../_components/repo-settings-tab.tsx | 7 + .../layout/__tests__/app-sidebar.test.tsx | 1 + src/components/layout/app-sidebar.tsx | 2 + src/lib/api/age-gate.ts | 88 +++++ src/lib/api/index.ts | 1 + src/lib/api/webhooks.ts | 8 +- 14 files changed, 729 insertions(+), 3 deletions(-) create mode 100644 e2e/suites/interactions/operations/age-gate.spec.ts create mode 100644 src/app/(app)/(admin)/age-gate/__tests__/page.test.tsx create mode 100644 src/app/(app)/(admin)/age-gate/page.tsx create mode 100644 src/app/(app)/repositories/_components/age-gate-settings.test.tsx create mode 100644 src/app/(app)/repositories/_components/age-gate-settings.tsx create mode 100644 src/lib/api/age-gate.ts diff --git a/e2e/suites/interactions/operations/age-gate.spec.ts b/e2e/suites/interactions/operations/age-gate.spec.ts new file mode 100644 index 00000000..8f804106 --- /dev/null +++ b/e2e/suites/interactions/operations/age-gate.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Age Gate Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/age-gate'); + }); + + test('page loads with Age Gate heading', async ({ page }) => { + await expect(page.getByRole('heading', { name: /age gate/i })).toBeVisible({ + timeout: 10000, + }); + }); + + test('pending and history tabs are visible', async ({ page }) => { + await expect(page.getByRole('heading', { name: /age gate/i })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole('tab', { name: /pending/i })).toBeVisible(); + await expect(page.getByRole('tab', { name: /history/i })).toBeVisible(); + }); + + test('pending tab shows table or empty state', async ({ page }) => { + await expect(page.getByRole('heading', { name: /age gate/i })).toBeVisible({ + timeout: 10000, + }); + + const tableHeader = page.getByText(/repository|package/i); + const emptyState = page.getByText(/no pending reviews/i); + + const hasTable = await tableHeader.first().isVisible({ timeout: 5000 }).catch(() => false); + const hasEmpty = await emptyState.first().isVisible({ timeout: 5000 }).catch(() => false); + + expect(hasTable || hasEmpty).toBeTruthy(); + }); +}); diff --git a/src/app/(app)/(admin)/age-gate/__tests__/page.test.tsx b/src/app/(app)/(admin)/age-gate/__tests__/page.test.tsx new file mode 100644 index 00000000..22a8e9b0 --- /dev/null +++ b/src/app/(app)/(admin)/age-gate/__tests__/page.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import AgeGatePage from "../page"; +import ageGateApi from "@/lib/api/age-gate"; + +vi.mock("@/providers/auth-provider", () => ({ + useAuth: () => ({ user: { is_admin: true } }), +})); + +vi.mock("@/lib/api/age-gate", () => ({ + default: { + listReviews: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), + }, +})); + +describe("AgeGatePage", () => { + beforeEach(() => { + vi.mocked(ageGateApi.listReviews).mockResolvedValue({ + items: [], + pagination: { page: 1, per_page: 20, total: 0, total_pages: 0 }, + }); + }); + + it("renders pending tab and empty state", async () => { + const client = new QueryClient(); + render( + + + , + ); + + expect(await screen.findByRole("heading", { name: "Age Gate" })).toBeInTheDocument(); + expect(await screen.findByText("No pending reviews")).toBeInTheDocument(); + }); +}); diff --git a/src/app/(app)/(admin)/age-gate/page.tsx b/src/app/(app)/(admin)/age-gate/page.tsx new file mode 100644 index 00000000..daaca869 --- /dev/null +++ b/src/app/(app)/(admin)/age-gate/page.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2, + XCircle, + Clock, + Hourglass, + Loader2, + Inbox, +} from "lucide-react"; +import { toast } from "sonner"; +import { useAuth } from "@/providers/auth-provider"; +import ageGateApi, { type AgeGateReview } from "@/lib/api/age-gate"; +import { mutationErrorToast } from "@/lib/error-utils"; +import { formatDate } from "@/lib/utils"; + +import { PageHeader } from "@/components/common/page-header"; +import { DataTable, type DataTableColumn } from "@/components/common/data-table"; +import { EmptyState } from "@/components/common/empty-state"; +import { StatCard } from "@/components/common/stat-card"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; + +function StatusBadge({ status }: { status: AgeGateReview["status"] }) { + const colors = + status === "pending" + ? "border-amber-500/40 text-amber-700 dark:text-amber-400" + : status === "approved" + ? "border-emerald-500/40 text-emerald-700 dark:text-emerald-400" + : "border-red-500/40 text-red-700 dark:text-red-400"; + return ( + + {status} + + ); +} + +function packageAgeDays(review: AgeGateReview): string { + if (!review.upstream_published_at) return "—"; + const published = new Date(review.upstream_published_at).getTime(); + const days = Math.max(0, Math.floor((Date.now() - published) / 86_400_000)); + return `${days}d`; +} + +export default function AgeGatePage() { + const { user } = useAuth(); + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState<"pending" | "history">("pending"); + const [pendingPage, setPendingPage] = useState(1); + const [historyPage, setHistoryPage] = useState(1); + const perPage = 20; + const [actionDialog, setActionDialog] = useState<{ + type: "approve" | "reject"; + review: AgeGateReview; + } | null>(null); + const [actionReason, setActionReason] = useState(""); + + const { data: pendingData, isLoading: pendingLoading } = useQuery({ + queryKey: ["age-gate", "reviews", "pending", pendingPage], + queryFn: () => + ageGateApi.listReviews({ + status: "pending", + page: pendingPage, + per_page: perPage, + }), + enabled: !!user?.is_admin, + }); + + const { data: historyData, isLoading: historyLoading } = useQuery({ + queryKey: ["age-gate", "reviews", "history", historyPage], + queryFn: () => + ageGateApi.listReviews({ + page: historyPage, + per_page: perPage, + }), + enabled: !!user?.is_admin && activeTab === "history", + }); + + const pendingItems = pendingData?.items ?? []; + const historyItems = + historyData?.items.filter((item) => item.status !== "pending") ?? []; + const pendingTotal = pendingData?.pagination?.total ?? 0; + + const approveMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + ageGateApi.approve(id, reason), + onSuccess: () => { + toast.success("Package version approved"); + queryClient.invalidateQueries({ queryKey: ["age-gate"] }); + setActionDialog(null); + setActionReason(""); + }, + onError: mutationErrorToast("Failed to approve review"), + }); + + const rejectMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason?: string }) => + ageGateApi.reject(id, reason), + onSuccess: () => { + toast.success("Package version rejected"); + queryClient.invalidateQueries({ queryKey: ["age-gate"] }); + setActionDialog(null); + setActionReason(""); + }, + onError: mutationErrorToast("Failed to reject review"), + }); + + const isActioning = approveMutation.isPending || rejectMutation.isPending; + + function handleAction() { + if (!actionDialog) return; + const reason = actionReason.trim() || undefined; + if (actionDialog.type === "approve") { + approveMutation.mutate({ id: actionDialog.review.id, reason }); + } else { + rejectMutation.mutate({ id: actionDialog.review.id, reason }); + } + } + + if (!user?.is_admin) { + return ( + + ); + } + + const pendingColumns: DataTableColumn[] = [ + { + id: "repository", + header: "Repository", + accessor: (row) => row.repository_key, + }, + { + id: "package", + header: "Package", + cell: (row) => ( + + {row.package_name}@{row.package_version} + + ), + }, + { + id: "published", + header: "Published", + cell: (row) => + row.upstream_published_at ? formatDate(row.upstream_published_at) : "—", + }, + { + id: "age", + header: "Age", + cell: (row) => packageAgeDays(row), + }, + { + id: "requests", + header: "Requests", + accessor: (row) => row.request_count, + }, + { + id: "requested", + header: "Last requested", + cell: (row) => formatDate(row.last_requested_at), + }, + { + id: "actions", + header: "", + cell: (row) => ( +
+ + +
+ ), + }, + ]; + + const historyColumns: DataTableColumn[] = [ + ...pendingColumns.filter((c) => c.id !== "actions"), + { + id: "status", + header: "Status", + cell: (row) => , + }, + ]; + + return ( +
+ + +
+ +
+ + setActiveTab(v as "pending" | "history")} + > + + Pending + History + + + + {pendingLoading ? ( + + ) : pendingItems.length === 0 ? ( + + ) : ( + r.id} + /> + )} + + + + {historyLoading ? ( + + ) : historyItems.length === 0 ? ( + + ) : ( + r.id} + /> + )} + + + + setActionDialog(null)}> + + + + {actionDialog?.type === "approve" ? "Approve package" : "Reject package"} + + + {actionDialog?.review.package_name}@{actionDialog?.review.package_version}{" "} + in {actionDialog?.review.repository_key} + + +
+ +