diff --git a/apps/gittensory-ui/src/components/site/app-shell.tsx b/apps/gittensory-ui/src/components/site/app-shell.tsx index b714bd474e..281596a7a8 100644 --- a/apps/gittensory-ui/src/components/site/app-shell.tsx +++ b/apps/gittensory-ui/src/components/site/app-shell.tsx @@ -7,6 +7,7 @@ import { LayoutGrid, Loader2, LogOut, + ScrollText, TerminalSquare, Wrench, Workflow, @@ -68,6 +69,12 @@ const GROUPS: NavGroup[] = [ icon: Activity, roles: ["miner", "maintainer", "owner", "operator"], }, + { + to: "/app/audit", + label: "Skip audit", + icon: ScrollText, + roles: ["maintainer", "owner", "operator"], + }, ], }, { diff --git a/apps/gittensory-ui/src/components/site/audit-feed-model.ts b/apps/gittensory-ui/src/components/site/audit-feed-model.ts new file mode 100644 index 0000000000..065ab331b6 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/audit-feed-model.ts @@ -0,0 +1,124 @@ +export type SkippedPrAuditReason = + | "surface_off" + | "missing_author" + | "bot_author" + | "maintainer_author" + | "miner_detection_unavailable" + | "not_official_gittensor_miner"; + +export type SkippedPrAuditItem = { + repoFullName: string; + pullNumber: number; + reason: string; + timestamp: string; + remediation: string; +}; + +export type SkippedPrAuditExport = { + generatedAt: string; + limit: number; + hasMore: boolean; + filters: { + repoFullName: string | null; + reason: SkippedPrAuditReason | null; + since: string | null; + }; + items: SkippedPrAuditItem[]; +}; + +export const SKIP_REASON_OPTIONS: Array<{ value: "" | SkippedPrAuditReason; label: string }> = [ + { value: "", label: "All reasons" }, + { value: "surface_off", label: "Surface off" }, + { value: "missing_author", label: "Missing author" }, + { value: "bot_author", label: "Bot author" }, + { value: "maintainer_author", label: "Maintainer author" }, + { value: "miner_detection_unavailable", label: "Miner detection unavailable" }, + { value: "not_official_gittensor_miner", label: "Not official Gittensor miner" }, +]; + +export function buildSkippedPrAuditPath(options: { + limit: number; + repoFullName?: string; + reason?: SkippedPrAuditReason; + since?: string; +}): string { + const params = new URLSearchParams(); + params.set("limit", String(options.limit)); + if (options.repoFullName?.trim()) params.set("repoFullName", options.repoFullName.trim()); + if (options.reason) params.set("reason", options.reason); + if (options.since?.trim()) params.set("since", options.since.trim()); + return `/v1/app/skipped-pr-audit?${params.toString()}`; +} + +/** Parse a datetime-local or ISO-ish value without throwing from Apply Filters. */ +export function normalizeSinceInput(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + const parsed = Date.parse(trimmed); + if (!Number.isFinite(parsed)) return ""; + const date = new Date(parsed); + if (Number.isNaN(date.getTime())) return ""; + try { + return date.toISOString(); + } catch { + return ""; + } +} + +export function normalizeSkippedPrAuditExport(data: unknown): SkippedPrAuditExport | null { + if (!data || typeof data !== "object") return null; + const raw = data as Partial; + if (typeof raw.generatedAt !== "string" || !Array.isArray(raw.items)) return null; + const items = raw.items.filter( + (item): item is SkippedPrAuditItem => + item != null && + typeof item === "object" && + typeof item.repoFullName === "string" && + typeof item.pullNumber === "number" && + typeof item.reason === "string" && + typeof item.timestamp === "string" && + typeof item.remediation === "string", + ); + const filters = raw.filters; + return { + generatedAt: raw.generatedAt, + limit: typeof raw.limit === "number" ? raw.limit : items.length, + hasMore: Boolean(raw.hasMore), + filters: { + repoFullName: + filters && typeof filters.repoFullName === "string" ? filters.repoFullName : null, + reason: + filters && typeof filters.reason === "string" + ? (filters.reason as SkippedPrAuditReason) + : null, + since: filters && typeof filters.since === "string" ? filters.since : null, + }, + items, + }; +} + +export function formatSkipReason(reason: string): string { + const match = SKIP_REASON_OPTIONS.find((option) => option.value === reason); + if (match && match.value) return match.label; + return reason.replaceAll("_", " "); +} + +export function formatAuditTimestamp(timestamp: string): string { + const parsed = Date.parse(timestamp); + if (!Number.isFinite(parsed)) return timestamp; + return new Date(parsed).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +export function pullRequestHref(repoFullName: string, pullNumber: number): string { + return `https://github.com/${repoFullName}/pull/${pullNumber}`; +} + +export function skipReasonTone(reason: string): "ready" | "info" | "warn" | "degraded" { + if (reason === "bot_author" || reason === "not_official_gittensor_miner") return "info"; + if (reason === "surface_off" || reason === "maintainer_author") return "warn"; + if (reason === "miner_detection_unavailable" || reason === "missing_author") return "degraded"; + return "ready"; +} diff --git a/apps/gittensory-ui/src/components/site/audit-feed.test.tsx b/apps/gittensory-ui/src/components/site/audit-feed.test.tsx new file mode 100644 index 0000000000..5d61023de4 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/audit-feed.test.tsx @@ -0,0 +1,211 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); +vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); +vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" })); + +import { + buildSkippedPrAuditPath, + formatSkipReason, + normalizeSinceInput, + normalizeSkippedPrAuditExport, + pullRequestHref, +} from "@/components/site/audit-feed-model"; +import { AuditFeed } from "@/components/site/audit-feed"; + +const SAMPLE: { + generatedAt: string; + limit: number; + hasMore: boolean; + filters: { repoFullName: null; reason: null; since: null }; + items: Array<{ + repoFullName: string; + pullNumber: number; + reason: string; + timestamp: string; + remediation: string; + }>; +} = { + generatedAt: "2026-05-28T00:00:05.000Z", + limit: 50, + hasMore: false, + filters: { repoFullName: null, reason: null, since: null }, + items: [ + { + repoFullName: "repo-owner/owned-repo", + pullNumber: 6, + reason: "surface_off", + timestamp: "2026-05-28T00:00:04.000Z", + remediation: "Enable a PR public surface in repository settings.", + }, + ], +}; + +describe("audit feed helpers", () => { + it("builds query paths for skipped PR audit filters", () => { + expect(buildSkippedPrAuditPath({ limit: 25 })).toBe("/v1/app/skipped-pr-audit?limit=25"); + expect( + buildSkippedPrAuditPath({ + limit: 50, + repoFullName: "repo-owner/owned-repo", + reason: "bot_author", + since: "2026-05-28T00:00:00.000Z", + }), + ).toBe( + "/v1/app/skipped-pr-audit?limit=50&repoFullName=repo-owner%2Fowned-repo&reason=bot_author&since=2026-05-28T00%3A00%3A00.000Z", + ); + }); + + it("formats skip reasons and pull request links", () => { + expect(formatSkipReason("surface_off")).toBe("Surface off"); + expect(formatSkipReason("legacy_skip_reason")).toBe("legacy skip reason"); + expect(pullRequestHref("repo-owner/owned-repo", 6)).toBe( + "https://github.com/repo-owner/owned-repo/pull/6", + ); + }); + + it("normalizes since input without throwing on invalid dates", () => { + expect(normalizeSinceInput("")).toBe(""); + expect(normalizeSinceInput(" ")).toBe(""); + expect(normalizeSinceInput("not-a-date")).toBe(""); + expect(normalizeSinceInput("2026-05-28T00:00:00.000Z")).toBe("2026-05-28T00:00:00.000Z"); + expect(() => normalizeSinceInput("definitely-not-a-date")).not.toThrow(); + }); + + it("normalizes skipped-pr audit exports and rejects malformed payloads", () => { + expect(normalizeSkippedPrAuditExport(SAMPLE)).toEqual(SAMPLE); + expect(normalizeSkippedPrAuditExport({ ...SAMPLE, items: [] })).toMatchObject({ items: [] }); + expect(normalizeSkippedPrAuditExport(null)).toBeNull(); + expect(normalizeSkippedPrAuditExport({ generatedAt: "2026-05-28T00:00:05.000Z" })).toBeNull(); + expect( + normalizeSkippedPrAuditExport({ + ...SAMPLE, + items: [ + { + repoFullName: "x/y", + pullNumber: 1, + reason: "bot_author", + timestamp: "t", + remediation: "r", + }, + null, + "bad", + ], + }), + ).toMatchObject({ items: [{ repoFullName: "x/y", pullNumber: 1 }] }); + }); +}); + +describe("AuditFeed", () => { + beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue({ ok: true, data: SAMPLE }); + }); + + it("renders populated audit rows from the skipped-pr-audit API", async () => { + render(); + expect(await screen.findByText("repo-owner/owned-repo")).toBeTruthy(); + expect(screen.getByText("Enable a PR public surface in repository settings.")).toBeTruthy(); + expect(screen.getByRole("link", { name: /#6/i }).getAttribute("href")).toBe( + "https://github.com/repo-owner/owned-repo/pull/6", + ); + expect(apiFetch).toHaveBeenCalledWith( + "https://api.test/v1/app/skipped-pr-audit?limit=50", + expect.objectContaining({ credentials: "include" }), + ); + }); + + it("shows an empty state when the audit export has no items", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, items: [] } }); + render(); + expect(await screen.findByText("No skipped PR events")).toBeTruthy(); + }); + + it("shows an error state when the audit request fails", async () => { + apiFetch.mockResolvedValue({ ok: false, message: "insufficient_role" }); + render(); + expect(await screen.findByText("Couldn't load skip audit")).toBeTruthy(); + expect(screen.getByText("insufficient_role")).toBeTruthy(); + }); + + it("applies repository filters to subsequent audit requests", async () => { + render(); + await screen.findByText("repo-owner/owned-repo"); + apiFetch.mockClear(); + apiFetch.mockResolvedValue({ ok: true, data: SAMPLE }); + + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { + target: { value: "repo-owner/owned-repo" }, + }); + fireEvent.click(screen.getByRole("button", { name: /apply filters/i })); + + await waitFor(() => + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("repoFullName=repo-owner%2Fowned-repo"), + expect.any(Object), + ), + ); + }); + + it("ignores invalid since values when applying filters", async () => { + render(); + await screen.findByText("repo-owner/owned-repo"); + apiFetch.mockClear(); + apiFetch.mockResolvedValue({ ok: true, data: SAMPLE }); + + fireEvent.change(screen.getByLabelText(/^since$/i), { + target: { value: "definitely-not-a-date" }, + }); + fireEvent.change(screen.getByPlaceholderText("owner/repo"), { + target: { value: "repo-owner/owned-repo" }, + }); + expect(() => + fireEvent.click(screen.getByRole("button", { name: /apply filters/i })), + ).not.toThrow(); + + await waitFor(() => + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("repoFullName=repo-owner%2Fowned-repo"), + expect.any(Object), + ), + ); + expect(apiFetch.mock.calls.some(([url]) => String(url).includes("since="))).toBe(false); + }); + + it("shows a role error when the feed is disabled", async () => { + render(); + expect(await screen.findByText("Couldn't load skip audit")).toBeTruthy(); + expect(screen.getByText("This audit feed is unavailable for your current role.")).toBeTruthy(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it("loads more rows until the maximum page size", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, hasMore: true } }); + render(); + await screen.findByText("repo-owner/owned-repo"); + apiFetch.mockClear(); + apiFetch.mockResolvedValue({ ok: true, data: { ...SAMPLE, hasMore: true, limit: 100 } }); + + fireEvent.click(screen.getByRole("button", { name: /load more/i })); + + await waitFor(() => + expect(apiFetch).toHaveBeenCalledWith( + "https://api.test/v1/app/skipped-pr-audit?limit=100", + expect.any(Object), + ), + ); + + expect(screen.getByText(/maximum page size \(100\)/i)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /load more/i })).toBeNull(); + }); + + it("shows an error state when the audit response is malformed", async () => { + apiFetch.mockResolvedValue({ ok: true, data: { generatedAt: "2026-05-28T00:00:05.000Z" } }); + render(); + expect(await screen.findByText("Couldn't load skip audit")).toBeTruthy(); + expect( + screen.getByText("The skipped PR audit endpoint returned an unexpected response."), + ).toBeTruthy(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/audit-feed.tsx b/apps/gittensory-ui/src/components/site/audit-feed.tsx new file mode 100644 index 0000000000..145d2e914e --- /dev/null +++ b/apps/gittensory-ui/src/components/site/audit-feed.tsx @@ -0,0 +1,321 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ExternalLink } from "lucide-react"; + +import { + buildSkippedPrAuditPath, + formatAuditTimestamp, + formatSkipReason, + normalizeSinceInput, + normalizeSkippedPrAuditExport, + pullRequestHref, + SKIP_REASON_OPTIONS, + skipReasonTone, + type SkippedPrAuditExport, + type SkippedPrAuditReason, +} from "@/components/site/audit-feed-model"; +import { BoundaryBadge, StatusPill } from "@/components/site/control-primitives"; +import { + EmptyState, + ErrorState, + LoadingState, + StateActionButton, +} from "@/components/site/state-views"; +import { Input } from "@/components/ui/input"; +import { getApiOrigin } from "@/lib/api/origin"; +import { apiFetch } from "@/lib/api/request"; + +const fieldClass = + "mt-1 w-full rounded-token border border-border bg-background/40 px-3 py-2 text-token-sm text-foreground focus-ring"; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +type AuditFeedProps = { + enabled?: boolean; +}; + +export function AuditFeed({ enabled = true }: AuditFeedProps) { + const [reason, setReason] = useState<"" | SkippedPrAuditReason>(""); + const [repoDraft, setRepoDraft] = useState(""); + const [repoFullName, setRepoFullName] = useState(""); + const [sinceInput, setSinceInput] = useState(""); + const [sinceIso, setSinceIso] = useState(""); + const [limit, setLimit] = useState(DEFAULT_LIMIT); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const queryPath = useMemo( + () => + buildSkippedPrAuditPath({ + limit, + repoFullName: repoFullName || undefined, + reason: reason || undefined, + since: sinceIso || undefined, + }), + [limit, reason, repoFullName, sinceIso], + ); + + const load = useCallback(async () => { + if (!enabled) { + setStatus("error"); + setError("This audit feed is unavailable for your current role."); + setData(null); + return; + } + setStatus("loading"); + setError(null); + const origin = getApiOrigin().replace(/\/$/, ""); + const result = await apiFetch(`${origin}${queryPath}`, { + label: "Skipped PR audit", + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (result.ok) { + const normalized = normalizeSkippedPrAuditExport(result.data); + if (!normalized) { + setData(null); + setError("The skipped PR audit endpoint returned an unexpected response."); + setStatus("error"); + return; + } + setData(normalized); + setStatus("ready"); + return; + } + setData(null); + setError(result.message); + setStatus("error"); + }, [enabled, queryPath]); + + useEffect(() => { + void load(); + }, [load]); + + const applyFilters = () => { + setSinceIso(normalizeSinceInput(sinceInput)); + setRepoFullName(repoDraft.trim()); + setLimit(DEFAULT_LIMIT); + }; + + const resetFilters = () => { + setReason(""); + setRepoDraft(""); + setRepoFullName(""); + setSinceInput(""); + setSinceIso(""); + setLimit(DEFAULT_LIMIT); + }; + + const loadMore = () => { + setLimit((current) => Math.min(current + DEFAULT_LIMIT, MAX_LIMIT)); + }; + + if (status === "loading" && !data) { + return ( + + ); + } + + if (status === "error" && !data) { + return ( + void load()} + /> + ); + } + + if (status === "ready" && data && data.items.length === 0) { + return ( +
+ { + setReason(value); + setLimit(DEFAULT_LIMIT); + }} + onRepoDraftChange={setRepoDraft} + onSinceInputChange={setSinceInput} + onApply={applyFilters} + onReset={resetFilters} + /> + void load()}>Refresh} + /> +
+ ); + } + + if (!data) return null; + + return ( +
+
+
+ {data.items.length} event(s) + {data.hasMore ? More available : null} + +
+
+ Updated {formatAuditTimestamp(data.generatedAt)} +
+
+ + { + setReason(value); + setLimit(DEFAULT_LIMIT); + }} + onRepoDraftChange={setRepoDraft} + onSinceInputChange={setSinceInput} + onApply={applyFilters} + onReset={resetFilters} + /> + +
+ + + + + + + + + + + + {data.items.map((item) => ( + + + + + + + + ))} + +
TimeRepositoryPull requestReasonRemediation
+ {formatAuditTimestamp(item.timestamp)} + {item.repoFullName} + + #{item.pullNumber} + + + + + {formatSkipReason(item.reason)} + + + {item.remediation} +
+
+ +
+ {data.hasMore && limit < MAX_LIMIT ? ( + Load more + ) : null} + {data.hasMore && limit >= MAX_LIMIT ? ( +

+ Showing the maximum page size ({MAX_LIMIT}). Narrow filters to inspect older events. +

+ ) : null} + void load()}>Refresh +
+
+ ); +} + +function AuditFilters({ + reason, + repoDraft, + sinceInput, + onReasonChange, + onRepoDraftChange, + onSinceInputChange, + onApply, + onReset, +}: { + reason: "" | SkippedPrAuditReason; + repoDraft: string; + sinceInput: string; + onReasonChange: (value: "" | SkippedPrAuditReason) => void; + onRepoDraftChange: (value: string) => void; + onSinceInputChange: (value: string) => void; + onApply: () => void; + onReset: () => void; +}) { + return ( +
+

Filters

+

+ Filter skip decisions by reason, repository, or events after a timestamp. +

+
+ + + +
+
+ + Apply filters + + Reset +
+
+ ); +} diff --git a/apps/gittensory-ui/src/components/site/command-palette.tsx b/apps/gittensory-ui/src/components/site/command-palette.tsx index 4b428873f2..0e426c6865 100644 --- a/apps/gittensory-ui/src/components/site/command-palette.tsx +++ b/apps/gittensory-ui/src/components/site/command-palette.tsx @@ -26,6 +26,7 @@ const DEFAULT_ITEMS: PaletteItem[] = [ { label: "@gittensory command simulator", to: "/app/commands", group: "App" }, { label: "Product analytics", to: "/app/analytics", group: "App" }, { label: "Maintainer digest", to: "/app/digest", group: "App" }, + { label: "Skipped PR audit", to: "/app/audit", group: "App" }, { label: "Operator dashboard", to: "/app/operator", group: "App" }, { label: "Beta onboarding", to: "/docs/beta-onboarding", group: "Docs" }, { label: "Quickstart", to: "/docs/quickstart", group: "Docs" }, diff --git a/apps/gittensory-ui/src/routeTree.gen.ts b/apps/gittensory-ui/src/routeTree.gen.ts index 5730353049..c5e3cff079 100644 --- a/apps/gittensory-ui/src/routeTree.gen.ts +++ b/apps/gittensory-ui/src/routeTree.gen.ts @@ -45,6 +45,7 @@ import { Route as AppMinerRouteImport } from './routes/app.miner' import { Route as AppMaintainerRouteImport } from './routes/app.maintainer' import { Route as AppDigestRouteImport } from './routes/app.digest' import { Route as AppCommandsRouteImport } from './routes/app.commands' +import { Route as AppAuditRouteImport } from './routes/app.audit' import { Route as AppAnalyticsRouteImport } from './routes/app.analytics' import { Route as ApiOpRouteImport } from './routes/api.$op' @@ -229,6 +230,11 @@ const AppCommandsRoute = AppCommandsRouteImport.update({ path: '/commands', getParentRoute: () => AppRoute, } as any) +const AppAuditRoute = AppAuditRouteImport.update({ + id: '/audit', + path: '/audit', + getParentRoute: () => AppRoute, +} as any) const AppAnalyticsRoute = AppAnalyticsRouteImport.update({ id: '/analytics', path: '/analytics', @@ -253,6 +259,7 @@ export interface FileRoutesByFullPath { '/roadmap': typeof RoadmapRoute '/api/$op': typeof ApiOpRoute '/app/analytics': typeof AppAnalyticsRoute + '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute @@ -290,6 +297,7 @@ export interface FileRoutesByTo { '/roadmap': typeof RoadmapRoute '/api/$op': typeof ApiOpRoute '/app/analytics': typeof AppAnalyticsRoute + '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute @@ -331,6 +339,7 @@ export interface FileRoutesById { '/roadmap': typeof RoadmapRoute '/api/$op': typeof ApiOpRoute '/app/analytics': typeof AppAnalyticsRoute + '/app/audit': typeof AppAuditRoute '/app/commands': typeof AppCommandsRoute '/app/digest': typeof AppDigestRoute '/app/maintainer': typeof AppMaintainerRoute @@ -373,6 +382,7 @@ export interface FileRouteTypes { | '/roadmap' | '/api/$op' | '/app/analytics' + | '/app/audit' | '/app/commands' | '/app/digest' | '/app/maintainer' @@ -410,6 +420,7 @@ export interface FileRouteTypes { | '/roadmap' | '/api/$op' | '/app/analytics' + | '/app/audit' | '/app/commands' | '/app/digest' | '/app/maintainer' @@ -450,6 +461,7 @@ export interface FileRouteTypes { | '/roadmap' | '/api/$op' | '/app/analytics' + | '/app/audit' | '/app/commands' | '/app/digest' | '/app/maintainer' @@ -745,6 +757,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppCommandsRouteImport parentRoute: typeof AppRoute } + '/app/audit': { + id: '/app/audit' + path: '/audit' + fullPath: '/app/audit' + preLoaderRoute: typeof AppAuditRouteImport + parentRoute: typeof AppRoute + } '/app/analytics': { id: '/app/analytics' path: '/analytics' @@ -776,6 +795,7 @@ const ApiRouteWithChildren = ApiRoute._addFileChildren(ApiRouteChildren) interface AppRouteChildren { AppAnalyticsRoute: typeof AppAnalyticsRoute + AppAuditRoute: typeof AppAuditRoute AppCommandsRoute: typeof AppCommandsRoute AppDigestRoute: typeof AppDigestRoute AppMaintainerRoute: typeof AppMaintainerRoute @@ -791,6 +811,7 @@ interface AppRouteChildren { const AppRouteChildren: AppRouteChildren = { AppAnalyticsRoute: AppAnalyticsRoute, + AppAuditRoute: AppAuditRoute, AppCommandsRoute: AppCommandsRoute, AppDigestRoute: AppDigestRoute, AppMaintainerRoute: AppMaintainerRoute, diff --git a/apps/gittensory-ui/src/routes/app.audit.tsx b/apps/gittensory-ui/src/routes/app.audit.tsx new file mode 100644 index 0000000000..e540c21920 --- /dev/null +++ b/apps/gittensory-ui/src/routes/app.audit.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AuditFeed } from "@/components/site/audit-feed"; +import { PageHeader } from "@/components/site/primitives"; +import { EmptyState } from "@/components/site/state-views"; +import { type AppRole, useSession } from "@/lib/api/session"; + +export const Route = createFileRoute("/app/audit")({ + component: AuditRoute, +}); + +const AUDIT_ROLES: AppRole[] = ["maintainer", "owner", "operator"]; + +function AuditRoute() { + const { session, hydrated } = useSession(); + const canAccess = session?.roles.some((role) => AUDIT_ROLES.includes(role)) ?? false; + + if (!hydrated) { + return null; + } + + if (!canAccess) { + return ( +
+ + +
+ ); + } + + return ( +
+ + +
+ ); +}