diff --git a/apps/web/package.json b/apps/web/package.json index cbd10bc4..206b8e34 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "@tripwire/env": "workspace:*", "@tripwire/feedback": "workspace:*", "@tripwire/github": "workspace:*", + "@tripwire/logger": "workspace:*", "@tripwire/mcp": "workspace:*", "@tripwire/ratelimit": "workspace:*", "@tripwire/research": "workspace:*", diff --git a/apps/web/public/og.jpg b/apps/web/public/og.jpg index 8e2c149a..598db554 100644 Binary files a/apps/web/public/og.jpg and b/apps/web/public/og.jpg differ diff --git a/apps/web/src/components/layout/admin/admin-shell.tsx b/apps/web/src/components/layout/admin/admin-shell.tsx new file mode 100644 index 00000000..c73e24e7 --- /dev/null +++ b/apps/web/src/components/layout/admin/admin-shell.tsx @@ -0,0 +1,178 @@ +import { useEffect } from "react" +import { + Link, + Outlet, + useNavigate, + useRouterState, +} from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" +import { FlaskConical, LayoutDashboard, ShieldUser } from "lucide-react" +import { AuthProvider } from "@tripwire/auth/components" +import { authClient } from "@tripwire/auth/client" +import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" +import { useTRPC } from "#/integrations/trpc/react" + +/** + * Layout for every /admin/* page. Wraps in AuthProvider + AdminGuard + * so role enforcement happens client-side with the user's actual + * cookies (a `beforeLoad` check would run with the SSR trpc client + * which has no cookie context). + */ +export function AdminShell() { + return ( + + +
+ +
+ +
+
+
+
+ ) +} + +function AdminGuard({ children }: { children: React.ReactNode }) { + const trpc = useTRPC() + const navigate = useNavigate() + const { data: orgs } = authClient.useListOrganizations() + const homeOrgSlug = orgs?.[0]?.slug + const me = useQuery({ + ...trpc.auth.me.queryOptions(), + staleTime: 60_000, + }) + + useEffect(() => { + if (me.isPending) return + if (!me.data) { + navigate({ to: "/login" }) + return + } + if (!me.data.isAdmin) { + // Non-admins who hit /admin get punted to their active org's home. + // If the user has no org (race during onboarding) fall back to landing. + if (homeOrgSlug) { + navigate({ + to: "/$orgHandle/home", + params: { orgHandle: homeOrgSlug }, + }) + } else { + navigate({ to: "/" }) + } + } + }, [me.isPending, me.data, navigate, homeOrgSlug]) + + if (me.isPending || !me.data?.isAdmin) { + return ( +
+ Checking access… +
+ ) + } + + return <>{children} +} + +function AdminTopNav() { + const currentPath = useRouterState({ select: (s) => s.location.pathname }) + + return ( +
+
+ + + + + Admin + + +
+ + +
+ ) +} + +function ExitAdminLink() { + const { data: orgs } = authClient.useListOrganizations() + const homeOrgSlug = orgs?.[0]?.slug + const linkClass = + "flex h-8 items-center rounded-lg px-2.5 text-[13px] font-medium text-tw-text-muted transition-colors hover:bg-tw-hover hover:text-tw-text-primary" + if (homeOrgSlug) { + return ( + + Exit admin + + ) + } + return ( + + Exit admin + + ) +} + +function tabClass(isActive: boolean): string { + return `group flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 transition-colors ${ + isActive ? "bg-tw-card" : "hover:bg-tw-hover" + }` +} + +function iconClass(isActive: boolean): string { + return `size-3.5 ${ + isActive + ? "text-[#FAFAFA]" + : "text-tw-text-tertiary group-hover:text-tw-text-secondary" + }` +} + +function labelClass(isActive: boolean): string { + return `text-[13px] leading-none font-medium ${ + isActive + ? "text-[#FAFAFA]" + : "text-tw-text-muted group-hover:text-tw-text-primary" + }` +} diff --git a/apps/web/src/components/layout/admin/dashboard-page.tsx b/apps/web/src/components/layout/admin/dashboard-page.tsx new file mode 100644 index 00000000..86f7880d --- /dev/null +++ b/apps/web/src/components/layout/admin/dashboard-page.tsx @@ -0,0 +1,309 @@ +import { Link } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" +import { + AlertTriangle, + Building2, + CheckCircle2, + FolderGit2, + Loader2, + ShieldAlert, + Users, +} from "lucide-react" +import { useTRPC } from "#/integrations/trpc/react" +import { formatRelativeTime } from "#/lib/format" + +interface StatTileProps { + label: string + value: number | string + Icon: typeof Users + hint?: string + tone?: "default" | "warning" | "error" | "success" +} + +interface PanelProps { + title: string + hint?: string + children: React.ReactNode +} + +/** + * Cross-org admin overview: signups, installations, recent flagged + * events, in-flight syncs, and the lowest-reputation contributors + * active this week. All queries are admin-procedure scoped — gating + * happens at the tRPC layer. + */ +export function AdminOverviewPage() { + const trpc = useTRPC() + const overview = useQuery(trpc.adminOverview.overview.queryOptions()) + const recent = useQuery(trpc.adminOverview.recentBlocks.queryOptions()) + + const data = overview.data + + return ( +
+
+

+ Overview +

+

+ What's happening across every repo running Tripwire. +

+
+ +
+ + + + +
+ +
+ 0 ? "error" : "default"} + /> + 0 ? "warning" : "default"} + /> + + +
+ +
+ + {!data ? ( + + ) : data.activeSyncs.length === 0 ? ( + No syncs in flight right now. + ) : ( +
+ {data.activeSyncs.map((s) => ( +
+
+ + + {s.repoFullName} + +
+ + {s.status} + +
+ ))} +
+ )} +
+ + + {!data ? ( + + ) : data.lowScoreContributors.length === 0 ? ( + No risky contributors active this week. + ) : ( +
+ {data.lowScoreContributors.map((c) => ( + +
+ {c.githubUserId ? ( + + ) : null} + + @{c.githubUsername} + + + {c.repoFullName ?? ""} + +
+
+ + {c.totalBlocks}B + {c.totalAllows}A +
+ + ))} +
+ )} +
+
+ + + {recent.isLoading ? ( + + ) : !recent.data || recent.data.length === 0 ? ( + Nothing flagged this week. Quiet times. + ) : ( +
+ {recent.data.map((e) => ( +
+ + {formatRelativeTime(e.createdAt)} + + + + @{e.targetGithubUsername ?? "?"} + + + {e.repoFullName ?? "?"} + + + {e.githubRef ?? "—"} + + + {e.description ?? "—"} + +
+ ))} +
+ )} +
+
+ ) +} + +function StatTile({ + label, + value, + Icon, + hint, + tone = "default", +}: StatTileProps) { + const valueTone = + tone === "error" + ? "text-tw-error" + : tone === "warning" + ? "text-tw-warning" + : tone === "success" + ? "text-tw-success" + : "text-tw-text-primary" + return ( +
+
+ {label} + +
+
+ {value} +
+ {hint ? ( + {hint} + ) : null} +
+ ) +} + +function Panel({ title, hint, children }: PanelProps) { + return ( +
+
+ + {title} + + {hint ? ( + {hint} + ) : null} +
+
{children}
+
+ ) +} + +function PanelEmpty({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PanelLoading() { + return ( +
+ Loading… +
+ ) +} + +function ScoreChip({ score }: { score: number }) { + const tone = + score >= 75 + ? "text-tw-success" + : score >= 41 + ? "text-tw-warning" + : "text-tw-error" + return {score} +} + +function ActionTag({ action }: { action: string }) { + const tone = + action === "pipeline_blocked" || action === "blacklist_blocked" + ? "text-tw-error" + : action === "rule_near_miss" + ? "text-tw-warning" + : "text-tw-text-tertiary" + return ( + + {action} + + ) +} diff --git a/apps/web/src/components/layout/admin/reputation-page.tsx b/apps/web/src/components/layout/admin/reputation-page.tsx new file mode 100644 index 00000000..99bda9de --- /dev/null +++ b/apps/web/src/components/layout/admin/reputation-page.tsx @@ -0,0 +1,433 @@ +import { useMemo, useState } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Button } from "@tripwire/ui/button" +import { Checkbox } from "@tripwire/ui/checkbox" +import { toastManager } from "@tripwire/ui/toast" +import { eventScoreImpact } from "@tripwire/core/contributor-score" +import { useTRPC } from "#/integrations/trpc/react" +import { formatRelativeTime } from "#/lib/format" +import { toastFromError } from "#/lib/toast-error" + +interface ReputationRow { + repoId: string | null + repoFullName: string | null + githubUsername: string + githubUserId: number | null + score: number + totalAllows: number + totalBlocks: number + totalNearMisses: number + firstSeenAt: Date + lastSeenAt: Date + scoreResetAt: Date | null + updatedAt: Date +} + +interface ReputationRowWithRepo extends ReputationRow { + repoId: string +} + +interface EventRow { + id: string + repoId: string + repoFullName: string | null + action: string + severity: string | null + description: string | null + githubRef: string | null + ruleName: string | null + metadata: Record | null + createdAt: Date +} + +interface ReputationCardProps { + row: ReputationRowWithRepo + onChanged: () => void +} + +interface EventsCardProps { + events: EventRow[] + username: string + onChanged: () => void +} + +interface NumberFieldProps { + label: string + value: string + onChange: (v: string) => void +} + +interface ImpactCellProps { + action: string + createdAt: Date +} + +interface SeverityBadgeProps { + severity: string | null +} + +/** + * Admin tool to look up a contributor's reputation across every repo, + * edit counters / score directly, surgically delete events, and + * re-trigger the score-user Inngest job. + */ +export function AdminReputationPage() { + const trpc = useTRPC() + const [input, setInput] = useState("") + const [submitted, setSubmitted] = useState("") + + const lookup = useQuery({ + ...trpc.adminReputation.lookup.queryOptions({ username: submitted }), + enabled: submitted.length > 0, + }) + + const data = lookup.data + const eventsByRepo = useMemo(() => { + if (!data) return new Map() + const out = new Map() + for (const e of data.events as EventRow[]) { + const list = out.get(e.repoId) ?? [] + list.push(e) + out.set(e.repoId, list) + } + return out + }, [data]) + + return ( +
+
+

+ Reputation +

+

+ Look up a contributor across repos, edit their counters or score, and + delete events surgically. +

+
+ +
{ + e.preventDefault() + setSubmitted(input.trim()) + }} + className="flex items-center gap-2" + > + setInput(e.target.value)} + placeholder="github-handle" + autoComplete="off" + spellCheck={false} + aria-label="GitHub username" + className="w-full rounded-lg border border-tw-border bg-tw-surface p-2.5 font-mono text-[13px] text-tw-text-primary transition-colors outline-none placeholder:text-tw-text-tertiary focus:border-tw-accent" + /> + +
+ + {lookup.isLoading && submitted ? ( +
Loading…
+ ) : null} + + {data && submitted ? ( + data.reputations.length === 0 ? ( +
+ No reputation rows for{" "} + + @{submitted} + + . The user has never been seen in any repo. +
+ ) : ( +
+ {(data.reputations as ReputationRow[]) + .filter( + (row): row is ReputationRowWithRepo => row.repoId !== null + ) + .map((row) => ( +
+ lookup.refetch()} + /> + lookup.refetch()} + /> +
+ ))} +
+ ) + ) : null} +
+ ) +} + +function ReputationCard({ row, onChanged }: ReputationCardProps) { + const trpc = useTRPC() + const queryClient = useQueryClient() + const [score, setScore] = useState(String(row.score)) + const [totalAllows, setTotalAllows] = useState( + String(row.totalAllows) + ) + const [totalBlocks, setTotalBlocks] = useState( + String(row.totalBlocks) + ) + const [totalNearMisses, setTotalNearMisses] = useState( + String(row.totalNearMisses) + ) + + const setMutation = useMutation( + trpc.adminReputation.setReputation.mutationOptions({ + onSuccess: () => { + toastManager.add({ type: "success", title: "Reputation updated" }) + queryClient.invalidateQueries({ + queryKey: trpc.adminReputation.lookup.queryKey(), + }) + onChanged() + }, + onError: (err) => + toastFromError(err, { fallbackTitle: "Couldn't update" }), + }) + ) + + const rescoreMutation = useMutation( + trpc.adminReputation.triggerRescore.mutationOptions({ + onSuccess: () => + toastManager.add({ + type: "success", + title: "Rescore enqueued", + description: + "score-user Inngest event sent. Refresh in a few seconds.", + }), + onError: (err) => + toastFromError(err, { fallbackTitle: "Couldn't trigger rescore" }), + }) + ) + + const dirty = + Number(score) !== row.score || + Number(totalAllows) !== row.totalAllows || + Number(totalBlocks) !== row.totalBlocks || + Number(totalNearMisses) !== row.totalNearMisses + + const onSave = () => { + setMutation.mutate({ + repoId: row.repoId, + username: row.githubUsername, + score: Number(score), + totalAllows: Number(totalAllows), + totalBlocks: Number(totalBlocks), + totalNearMisses: Number(totalNearMisses), + }) + } + + return ( +
+
+
+ + {row.repoFullName ?? row.repoId} + + + last seen {formatRelativeTime(row.lastSeenAt)} + +
+ +
+ +
+ + + + +
+ +
+ +
+
+ ) +} + +function NumberField({ label, value, onChange }: NumberFieldProps) { + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-lg border border-tw-border bg-tw-surface p-2.5 text-[13px] text-tw-text-primary tabular-nums transition-colors outline-none focus:border-tw-accent" + /> +
+ ) +} + +function EventsCard({ events, username, onChanged }: EventsCardProps) { + const trpc = useTRPC() + const queryClient = useQueryClient() + const [selected, setSelected] = useState>(new Set()) + + const deleteMutation = useMutation( + trpc.adminReputation.deleteEvents.mutationOptions({ + onSuccess: (data) => { + toastManager.add({ + type: "success", + title: `Deleted ${data.deletedCount} event${data.deletedCount === 1 ? "" : "s"}`, + }) + setSelected(new Set()) + queryClient.invalidateQueries({ + queryKey: trpc.adminReputation.lookup.queryKey(), + }) + onChanged() + }, + onError: (err) => + toastFromError(err, { fallbackTitle: "Couldn't delete events" }), + }) + ) + + if (events.length === 0) { + return ( +
+ No events on this repo for{" "} + @{username}. +
+ ) + } + + const toggle = (id: string) => { + const next = new Set(selected) + if (next.has(id)) next.delete(id) + else next.add(id) + setSelected(next) + } + + return ( +
+
+ + Recent events ({events.length}) + + +
+ {events.map((e) => ( +
+ toggle(e.id)} + aria-label={`Select event ${e.id}`} + /> + + {formatRelativeTime(e.createdAt)} + + + {e.action} + + + + + + + {e.githubRef ?? "—"} + + + {e.description ?? "—"} + +
+ ))} +
+ ) +} + +function ImpactCell({ action, createdAt }: ImpactCellProps) { + const impact = eventScoreImpact({ action, createdAt: new Date(createdAt) }) + if (!impact) return + const sign = impact.delta > 0 ? "+" : "" + const tone = + impact.delta > 0 + ? "text-tw-success" + : impact.delta < 0 + ? "text-tw-error" + : "text-tw-text-tertiary" + return ( + + {sign} + {impact.delta.toFixed(1)} + + ) +} + +function SeverityBadge({ severity }: SeverityBadgeProps) { + if (!severity) + return ( + + — + + ) + const tone = + severity === "error" + ? "text-tw-error" + : severity === "warning" + ? "text-tw-warning" + : severity === "success" + ? "text-tw-success" + : "text-tw-text-tertiary" + return ( + + {severity} + + ) +} diff --git a/apps/web/src/components/layout/admin/research/new-run-page.tsx b/apps/web/src/components/layout/admin/research/new-run-page.tsx new file mode 100644 index 00000000..5f1e1111 --- /dev/null +++ b/apps/web/src/components/layout/admin/research/new-run-page.tsx @@ -0,0 +1,182 @@ +import { Link, useNavigate } from "@tanstack/react-router" +import { useState, type FormEvent } from "react" +import { useMutation } from "@tanstack/react-query" +import { Button } from "@tripwire/ui/button" +import { useTRPC } from "#/integrations/trpc/react" +import { toastFromError } from "#/lib/toast-error" + +interface FieldProps { + label: string + hint?: React.ReactNode + children: React.ReactNode +} + +export function NewResearchRunPage() { + const trpc = useTRPC() + const navigate = useNavigate() + + const [name, setName] = useState("") + const [usernamesText, setUsernamesText] = useState("") + const [cutoffDate, setCutoffDate] = useState("2022-11-30") + const [prLimit, setPrLimit] = useState(100) + const [repoFullName, setRepoFullName] = useState("") + + const kickoff = useMutation({ + ...trpc.research.kickoff.mutationOptions(), + onSuccess: ({ runId }) => { + navigate({ to: "/admin/research/$runId", params: { runId } }) + }, + onError: (err) => { + toastFromError(err, { fallbackTitle: "Couldn't start research run" }) + }, + }) + + function handleSubmit(e: FormEvent) { + e.preventDefault() + const usernames = usernamesText + .split(/[\s,]+/) + .map((u) => u.trim()) + .filter((u) => u.length > 0) + if (usernames.length === 0) return + + kickoff.mutate({ + name, + usernames, + cutoffDate: new Date(cutoffDate).toISOString(), + prLimitPerUser: prLimit, + repoFullName: repoFullName || undefined, + }) + } + + const usernameCount = usernamesText + .split(/[\s,]+/) + .filter((u) => u.trim().length > 0).length + + return ( +
+
+ + ← All runs + +
+

+ New Research Run +

+

+ Bulk-evaluate a contributor cohort against the rule pipeline. +

+
+
+ +
+ + setName(e.target.value)} + placeholder="e.g. 200-user pilot" + required + className="w-full rounded-lg border border-tw-border bg-tw-surface p-2.5 text-[13px] text-tw-text-primary transition-colors outline-none placeholder:text-tw-text-tertiary focus:border-tw-accent" + /> + + + +