From 4eb3ec91397f4be42b2eecca5cbe288120354df3 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 16:23:34 -0700 Subject: [PATCH 01/13] feat(dashboard): redesign org home with two-column layout, facepile, and favorites Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/hooks/useProjectFavorites.ts | 32 + .../dashboard/src/pages/org/OrgAuditLogs.tsx | 4 +- client/dashboard/src/pages/org/OrgHome.tsx | 705 ++++++++++++++---- 3 files changed, 598 insertions(+), 143 deletions(-) create mode 100644 client/dashboard/src/hooks/useProjectFavorites.ts diff --git a/client/dashboard/src/hooks/useProjectFavorites.ts b/client/dashboard/src/hooks/useProjectFavorites.ts new file mode 100644 index 0000000000..eeb3380794 --- /dev/null +++ b/client/dashboard/src/hooks/useProjectFavorites.ts @@ -0,0 +1,32 @@ +import { useCallback, useMemo } from "react"; + +import { useLocalStorageState } from "@/hooks/useLocalStorageState"; + +const STORAGE_PREFIX = "gram:org-favorites:"; + +export function useProjectFavorites(orgId: string) { + const [favoriteIds, setFavoriteIds] = useLocalStorageState( + `${STORAGE_PREFIX}${orgId}`, + [], + ); + + const favoriteSet = useMemo(() => new Set(favoriteIds), [favoriteIds]); + + const isFavorite = useCallback( + (projectId: string) => favoriteSet.has(projectId), + [favoriteSet], + ); + + const toggleFavorite = useCallback( + (projectId: string) => { + setFavoriteIds((prev) => + prev.includes(projectId) + ? prev.filter((id) => id !== projectId) + : [...prev, projectId], + ); + }, + [setFavoriteIds], + ); + + return { favoriteIds, favoriteSet, isFavorite, toggleFavorite }; +} diff --git a/client/dashboard/src/pages/org/OrgAuditLogs.tsx b/client/dashboard/src/pages/org/OrgAuditLogs.tsx index c7f9122b19..d741c8d8ef 100644 --- a/client/dashboard/src/pages/org/OrgAuditLogs.tsx +++ b/client/dashboard/src/pages/org/OrgAuditLogs.tsx @@ -84,7 +84,7 @@ function StrongName({ children }: { children: ReactNode }) { return {children}; } -function getActorLabel(log: AuditLog) { +export function getActorLabel(log: AuditLog) { return log.actorDisplayName || log.actorSlug || "Someone"; } @@ -385,7 +385,7 @@ function describeToolsetUpdate(log: AuditLog): string { return "updated MCP server"; } -function renderVerb(log: AuditLog): string { +export function renderVerb(log: AuditLog): string { switch (log.action) { case "project:create": return "created project"; diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index a052458b81..410872f8de 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -1,29 +1,64 @@ import { InputDialog } from "@/components/input-dialog"; import { Page } from "@/components/page-layout"; import { ProjectAvatar } from "@/components/project-menu"; -import { CreateResourceCard } from "@/components/create-resource-card"; -import { DotCard } from "@/components/ui/dot-card"; +import { RequireScope } from "@/components/require-scope"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; import { Heading } from "@/components/ui/heading"; import { SearchBar } from "@/components/ui/search-bar"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Type } from "@/components/ui/type"; import { useOrganization } from "@/contexts/Auth"; import { useSdkClient, useSlugs } from "@/contexts/Sdk"; -import { RequireScope } from "@/components/require-scope"; import { useTelemetry } from "@/contexts/Telemetry"; -import { useOrgRoutes } from "@/routes"; +import { useProjectFavorites } from "@/hooks/useProjectFavorites"; import { useRBAC } from "@/hooks/useRBAC"; -import { Outcome } from "@gram/client/models/operations/listchallengebuckets.js"; -import { useChallengeBuckets } from "@gram/client/react-query/challengeBuckets.js"; +import { dateTimeFormatters } from "@/lib/dates"; +import { cn } from "@/lib/utils"; import { ChallengesEmptyState } from "@/pages/access/ChallengesTab"; -import { isDisplayableBucket } from "@/pages/access/challengeHelpers"; +import { + getInitials, + isDisplayableBucket, +} from "@/pages/access/challengeHelpers"; import { useChallengeRowColumns } from "@/pages/access/useChallengeRowColumns"; import { useGrantFlow } from "@/pages/access/useGrantFlow"; -import { Table } from "@speakeasy-api/moonshine"; -import { ArrowRight, ChevronDown, ChevronUp } from "lucide-react"; +import { useOrgRoutes } from "@/routes"; +import type { AccessMember, AuditLog } from "@gram/client/models/components"; +import { Outcome } from "@gram/client/models/operations/listchallengebuckets.js"; +import { useAuditLogs } from "@gram/client/react-query"; +import { useChallengeBuckets } from "@gram/client/react-query/challengeBuckets.js"; +import { useMembers } from "@gram/client/react-query/members.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Table, +} from "@speakeasy-api/moonshine"; +import { + ChevronDown, + ChevronUp, + MoreHorizontal, + Plus, + ShieldCheck, + Star, + UserPlus, +} from "lucide-react"; import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router"; -const PROJECT_LIMIT = 5; +import { getActorLabel, renderVerb } from "./OrgAuditLogs"; + +const PROJECT_LIMIT = 6; +const AUDIT_PREVIEW_LIMIT = 8; +const CHALLENGE_PREVIEW_LIMIT = 3; +const FACEPILE_LIMIT = 10; + +type OrgProject = ReturnType["projects"][number]; export default function OrgHome() { return ( @@ -49,13 +84,64 @@ export function OrgHomeInner() { const client = useSdkClient(); const navigate = useNavigate(); const telemetry = useTelemetry(); + const { hasScope } = useRBAC(); const isRbacEnabled = telemetry.isFeatureEnabled("gram-rbac") ?? false; + const canAdmin = hasScope("org:admin"); + const orgRoutes = useOrgRoutes(); + const [search, setSearch] = useState(""); const [expanded, setExpanded] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(""); - const projects = useMemo( + const { favoriteSet, isFavorite, toggleFavorite } = useProjectFavorites( + organization.id, + ); + + // Fetch org-wide audit log once. We use it to drive (a) the left rail + // preview, (b) each project's "most recent action", and (c) the facepile + // of active actors per project — all from one network call. + const { data: auditData } = useAuditLogs(); + const auditLogs = useMemo(() => auditData?.result.logs ?? [], [auditData]); + + const { data: membersData } = useMembers(); + const memberById = useMemo(() => { + const map = new Map(); + for (const m of membersData?.members ?? []) map.set(m.id, m); + return map; + }, [membersData]); + + const { latestActionByProjectSlug, activeActorsByProjectSlug } = + useMemo(() => { + const latest = new Map(); + const actors = new Map(); + for (const log of auditLogs) { + if (!log.projectSlug) continue; + if (!latest.has(log.projectSlug)) latest.set(log.projectSlug, log); + if (log.actorType !== "user") continue; + const list = actors.get(log.projectSlug) ?? []; + // Preserve recency order; dedupe. + if (!list.includes(log.actorId)) { + list.push(log.actorId); + actors.set(log.projectSlug, list); + } + } + return { + latestActionByProjectSlug: latest, + activeActorsByProjectSlug: actors, + }; + }, [auditLogs]); + + // Fallback facepile when a project has no audit activity yet — show a + // stable, deterministic slice of org members so a fresh project still feels + // populated. Sorted by joinedAt so the choice is reproducible across loads. + const fallbackMembers = useMemo(() => { + return [...(membersData?.members ?? [])] + .sort((a, b) => a.joinedAt.getTime() - b.joinedAt.getTime()) + .slice(0, FACEPILE_LIMIT); + }, [membersData]); + + const filteredProjects = useMemo( () => [...organization.projects] .filter((project) => { @@ -70,125 +156,168 @@ export function OrgHomeInner() { [organization.projects, search], ); - // When searching, show all results; otherwise cap at limit const isSearching = search.length > 0; - const hasMore = !isSearching && projects.length > PROJECT_LIMIT; - const visibleProjects = - expanded || isSearching ? projects : projects.slice(0, PROJECT_LIMIT); + + const { favoriteProjects, otherProjects } = useMemo(() => { + if (isSearching) { + return { favoriteProjects: [], otherProjects: filteredProjects }; + } + const favs: OrgProject[] = []; + const rest: OrgProject[] = []; + for (const p of filteredProjects) { + if (favoriteSet.has(p.id)) favs.push(p); + else rest.push(p); + } + return { favoriteProjects: favs, otherProjects: rest }; + }, [filteredProjects, favoriteSet, isSearching]); + + const hasMore = !isSearching && otherProjects.length > PROJECT_LIMIT; + const visibleOtherProjects = + expanded || isSearching + ? otherProjects + : otherProjects.slice(0, PROJECT_LIMIT); + + const createProject = async (name: string) => { + const result = await client.projects.create({ + createProjectRequestBody: { + name, + organizationId: organization.id, + }, + }); + setNewProjectName(""); + navigate(`/${orgSlug}/projects/${result.project.slug}`); + }; + + const getFacepileMembers = (projectSlug: string): AccessMember[] => { + const actorIds = activeActorsByProjectSlug.get(projectSlug) ?? []; + const resolved: AccessMember[] = []; + for (const id of actorIds) { + const m = memberById.get(id); + if (m) resolved.push(m); + if (resolved.length >= FACEPILE_LIMIT) break; + } + if (resolved.length > 0) return resolved; + return fallbackMembers; + }; + + const renderProjectRow = (project: OrgProject) => ( + toggleFavorite(project.id)} + /> + ); return ( <> - - Projects - - - Projects organize your MCP servers, skills, assistants, and other tools - into separate workspaces. Use them to permission and scope access to - different products, teams or environments within your organization. - - - {projects.length === 0 && search ? ( -
- - No projects matching “{search}” - -
- - { - setNewProjectName(search); - setCreateDialogOpen(true); - }} - title={<>Create “{search}”} - description="Create a new project with this name" - /> - +
+ + +
+
+ + Projects + + + Projects organize your MCP servers, skills, assistants, and other + tools into separate workspaces. Use them to permission and scope + access to different products, teams or environments within your + organization. +
-
- ) : ( - <> -
- {!isSearching && ( - - setCreateDialogOpen(true)} - title="New Project" - description="Create a new project for your organization" - /> - + +
+ + {canAdmin && ( + setCreateDialogOpen(true)} + onInviteMember={() => orgRoutes.team.goTo()} + onManageRoles={() => orgRoutes.access.roles.goTo()} + /> )} - {visibleProjects.map((project) => ( - - - } +
+ + {filteredProjects.length === 0 && isSearching ? ( +
+ No projects matching “{search}” + + + +
+ ) : ( + <> + {favoriteProjects.length > 0 && ( +
+
+ + + Your favorites +
- - - ))} -
- {hasMore && ( - - )} - - )} - {isRbacEnabled && } + + {visibleOtherProjects.map(renderProjectRow)} + {hasMore && ( + + )} + + + {otherProjects.length === 0 && favoriteProjects.length === 0 && ( +
+ No projects yet + + + +
+ )} + + )} + +
{createDialogOpen && ( { - const result = await client.projects.create({ - createProjectRequestBody: { - name: newProjectName, - organizationId: organization.id, - }, - }); - setNewProjectName(""); - navigate(`/${orgSlug}/projects/${result.project.slug}`); - }} + onSubmit={() => createProject(newProjectName)} inputs={[ { label: "Name", @@ -221,7 +341,306 @@ export function OrgHomeInner() { ); } -function RecentChallenges() { +function AddNewMenu({ + onCreateProject, + onInviteMember, + onManageRoles, +}: { + onCreateProject: () => void; + onInviteMember: () => void; + onManageRoles: () => void; +}) { + const [open, setOpen] = useState(false); + const handle = (cb: () => void) => () => { + setOpen(false); + cb(); + }; + return ( + + + + + + + + Project + + + + Team member + + + + Role + + + + ); +} + +function ProjectList({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ProjectRow({ + project, + latestLog, + facepile, + isFavorite, + onToggleFavorite, +}: { + project: OrgProject; + latestLog: AuditLog | undefined; + facepile: AccessMember[]; + isFavorite: boolean; + onToggleFavorite: () => void; +}) { + const { orgSlug } = useSlugs(); + + return ( +
+ + + +
+
+ + {project.name} + + + {project.slug} + +
+ +
+ +
+
+ + + +
+ + +
+
+ ); +} + +function RecentActionBlock({ log }: { log: AuditLog | undefined }) { + if (!log) { + return ( + + No recent activity + + ); + } + const actor = getActorLabel(log); + const verb = renderVerb(log); + + return ( +
+ + {verb} + + + + + + {dateTimeFormatters.humanize(log.createdAt, { + includeTime: false, + })} + · + {actor} + + + + + + + +
+ ); +} + +function TimestampDetail({ date }: { date: Date }) { + const utc = date.toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const local = date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const tzAbbr = + new Intl.DateTimeFormat(undefined, { + timeZoneName: "short", + }) + .formatToParts(date) + .find((p) => p.type === "timeZoneName")?.value ?? "Local"; + return ( +
+
+ + UTC + + {utc} +
+
+ + {tzAbbr} + + {local} +
+
+ ); +} + +function Facepile({ members }: { members: AccessMember[] }) { + if (members.length === 0) return null; + const visible = members.slice(0, FACEPILE_LIMIT); + const overflow = Math.max(0, members.length - FACEPILE_LIMIT); + + return ( +
+
+ {visible.map((member) => ( + + + + {member.photoUrl ? ( + + ) : null} + + {getInitials(member.email)} + + + + {member.name || member.email} + + ))} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+
+ ); +} + +function RecentActivityCompact({ logs }: { logs: AuditLog[] }) { + const orgRoutes = useOrgRoutes(); + const preview = logs.slice(0, AUDIT_PREVIEW_LIMIT); + + return ( +
+
+ Recent activity + + View all + +
+ {preview.length === 0 ? ( +
+ + Activity will appear here as your team makes changes. + +
+ ) : ( +
    + {preview.map((log) => ( +
  1. + + + {getActorLabel(log)} + {" "} + {renderVerb(log)} + + + {log.projectSlug ? `${log.projectSlug} · ` : ""} + {dateTimeFormatters.humanize(log.createdAt, { + includeTime: false, + })} + +
  2. + ))} +
+ )} +
+ ); +} + +function RecentChallengesCompact() { const orgRoutes = useOrgRoutes(); const { hasScope } = useRBAC(); const canAdmin = hasScope("org:admin"); @@ -229,16 +648,18 @@ function RecentChallenges() { const { data, isLoading } = useChallengeBuckets({ outcome: Outcome.Deny, resolved: false, - limit: 5, + limit: CHALLENGE_PREVIEW_LIMIT, }); const buckets = useMemo( - () => (data?.buckets ?? []).filter(isDisplayableBucket), + () => + (data?.buckets ?? []) + .filter(isDisplayableBucket) + .slice(0, CHALLENGE_PREVIEW_LIMIT), [data?.buckets], ); const challengeRowColumns = useChallengeRowColumns(); - const columns = useMemo( () => canAdmin ? [...challengeRowColumns, actionsColumn] : challengeRowColumns, @@ -248,19 +669,21 @@ function RecentChallenges() { if (isLoading) return null; return ( -
-
- Recent Challenges - - Show more +
+
+ Recent challenges + + View all
{buckets.length === 0 ? ( ) : ( - row.id} /> +
+
row.id} /> + )} {grantFlowPortals} - + ); } From e5c91c616499e3f7e204af1d5eff8cc8374c6400 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 16:29:51 -0700 Subject: [PATCH 02/13] fix(dashboard): stabilize project row affordances and wire kebab menu Star and kebab buttons no longer hide when the row isn't hovered. Kebab now opens a MoreActions menu with favorite toggle, project settings, audit-log link, and copy-slug. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/dashboard/src/pages/org/OrgHome.tsx | 110 ++++++++++++++------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 410872f8de..57cf2867a6 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -5,6 +5,7 @@ import { RequireScope } from "@/components/require-scope"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Heading } from "@/components/ui/heading"; +import { MoreActions, type Action } from "@/components/ui/more-actions"; import { SearchBar } from "@/components/ui/search-bar"; import { Tooltip, @@ -42,7 +43,6 @@ import { import { ChevronDown, ChevronUp, - MoreHorizontal, Plus, ShieldCheck, Star, @@ -438,39 +438,81 @@ function ProjectRow({ -
- - -
+ + + ); +} + +function ProjectRowActions({ + project, + isFavorite, + onToggleFavorite, +}: { + project: OrgProject; + isFavorite: boolean; + onToggleFavorite: () => void; +}) { + const { orgSlug } = useSlugs(); + const navigate = useNavigate(); + + const actions: Action[] = [ + { + icon: "star", + label: isFavorite ? "Remove from favorites" : "Add to favorites", + onClick: onToggleFavorite, + }, + { + icon: "settings", + label: "Project settings", + onClick: () => navigate(`/${orgSlug}/projects/${project.slug}/settings`), + }, + { + icon: "history", + label: "View audit logs", + onClick: () => navigate(`/${orgSlug}/audit-logs?project=${project.slug}`), + }, + { + icon: "copy", + label: "Copy slug", + onClick: () => { + void navigator.clipboard?.writeText(project.slug); + }, + }, + ]; + + return ( +
{ + // Stop the absolute overlay from receiving clicks inside this region. + e.preventDefault(); + e.stopPropagation(); + }} + > + +
); } From 4b4a82d4ea33e96d1dd5bd4e6c355697d804eea3 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 16:30:30 -0700 Subject: [PATCH 03/13] fix(dashboard): align Add new button height with search bar Co-Authored-By: Claude Opus 4.7 (1M context) --- client/dashboard/src/pages/org/OrgHome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 57cf2867a6..6ad5b74790 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -358,7 +358,7 @@ function AddNewMenu({ return ( - - + + + + + + + + {isFavorite ? "Remove from favorites" : "Add to favorites"} + + + navigate(`/${orgSlug}/projects/${project.slug}/settings`), + )} + > + + Project settings + + + navigate(`/${orgSlug}/audit-logs?project=${project.slug}`), + )} + > + + View audit logs + + { + void navigator.clipboard?.writeText(project.slug); + })} + > + + Copy slug + + + ); } From 61df3d7b4baff5b304b1b69af06b20ede08bf3a2 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 16:58:38 -0700 Subject: [PATCH 05/13] feat(dashboard): add list/grid view toggle and fix row click-through - Row navigates to project home on click via pointer-events overlay pattern - New ViewModeToggle (grid / list) next to search; persisted in localStorage - ProjectCard component for grid view; ProjectGrid 2/3-column responsive layout - Show more / show less moved outside the list container for visual separation Co-Authored-By: Claude Opus 4.7 (1M context) --- client/dashboard/src/pages/org/OrgHome.tsx | 238 +++++++++++++++++---- 1 file changed, 195 insertions(+), 43 deletions(-) diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 668b45b56d..7c5d1192b9 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -15,6 +15,7 @@ import { Type } from "@/components/ui/type"; import { useOrganization } from "@/contexts/Auth"; import { useSdkClient, useSlugs } from "@/contexts/Sdk"; import { useTelemetry } from "@/contexts/Telemetry"; +import { useLocalStorageState } from "@/hooks/useLocalStorageState"; import { useProjectFavorites } from "@/hooks/useProjectFavorites"; import { useRBAC } from "@/hooks/useRBAC"; import { dateTimeFormatters } from "@/lib/dates"; @@ -44,6 +45,8 @@ import { ChevronUp, Copy, History, + LayoutGrid, + List, MoreHorizontal, Plus, Settings, @@ -96,6 +99,10 @@ export function OrgHomeInner() { const [expanded, setExpanded] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(""); + const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">( + "gram:org-home-view", + "list", + ); const { favoriteSet, isFavorite, toggleFavorite } = useProjectFavorites( organization.id, @@ -203,16 +210,28 @@ export function OrgHomeInner() { return fallbackMembers; }; - const renderProjectRow = (project: OrgProject) => ( - toggleFavorite(project.id)} - /> - ); + const renderProjectItem = (project: OrgProject) => { + const props = { + key: project.id, + project, + latestLog: latestActionByProjectSlug.get(project.slug), + facepile: getFacepileMembers(project.slug), + isFavorite: isFavorite(project.id), + onToggleFavorite: () => toggleFavorite(project.id), + }; + return viewMode === "grid" ? ( + + ) : ( + + ); + }; + + const renderProjectContainer = (children: React.ReactNode) => + viewMode === "grid" ? ( + {children} + ) : ( + {children} + ); return ( <> @@ -242,6 +261,7 @@ export function OrgHomeInner() { placeholder="Search projects..." className="flex-1" /> + {canAdmin && ( setCreateDialogOpen(true)} @@ -277,34 +297,34 @@ export function OrgHomeInner() { Your favorites - - {favoriteProjects.map(renderProjectRow)} - + {renderProjectContainer( + favoriteProjects.map(renderProjectItem), + )} )} - - {visibleOtherProjects.map(renderProjectRow)} - {hasMore && ( - - )} - + {renderProjectContainer( + visibleOtherProjects.map(renderProjectItem), + )} + {hasMore && ( + + )} {otherProjects.length === 0 && favoriteProjects.length === 0 && (
@@ -393,6 +413,70 @@ function ProjectList({ children }: { children: React.ReactNode }) { ); } +function ProjectGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ViewModeToggle({ + value, + onChange, +}: { + value: "list" | "grid"; + onChange: (mode: "list" | "grid") => void; +}) { + return ( +
+ onChange("grid")} + ariaLabel="Grid view" + > + + + onChange("list")} + ariaLabel="List view" + > + + +
+ ); +} + +function ViewModeButton({ + active, + onClick, + ariaLabel, + children, +}: { + active: boolean; + onClick: () => void; + ariaLabel: string; + children: React.ReactNode; +}) { + return ( + + ); +} + function ProjectRow({ project, latestLog, @@ -410,17 +494,14 @@ function ProjectRow({ return (
- + {/* Decorative content: pointer-events-none routes clicks through to the + Link overlay below, while the actions region opts back in. */} -
+
- +
+ +
+ + {/* Anchor overlay sits on top of pointer-events-none children, so the + entire row is one navigation target — interactive controls above + opt in via pointer-events-auto. */} + +
+ ); +} + +function ProjectCard({ + project, + latestLog, + facepile, + isFavorite, + onToggleFavorite, +}: { + project: OrgProject; + latestLog: AuditLog | undefined; + facepile: AccessMember[]; + isFavorite: boolean; + onToggleFavorite: () => void; +}) { + const { orgSlug } = useSlugs(); + + return ( +
+
+ +
+ + {project.name} + + + {project.slug} + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
); } From fed055ca7a847c3971180419f87dc89acc940eac Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:03:08 -0700 Subject: [PATCH 06/13] feat(dashboard): lift search bar above two-column layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search + view toggle + Add New now span the full content width across the top, so left-rail section labels align with the Projects label on the right — matching the Vercel reference layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/dashboard/src/pages/org/OrgHome.tsx | 200 ++++++++++----------- 1 file changed, 99 insertions(+), 101 deletions(-) diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 7c5d1192b9..7a54bdb9de 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -235,111 +235,109 @@ export function OrgHomeInner() { return ( <> -
- - -
-
- +
+
+ + + {canAdmin && ( + setCreateDialogOpen(true)} + onInviteMember={() => orgRoutes.team.goTo()} + onManageRoles={() => orgRoutes.access.roles.goTo()} + /> + )} +
+ +
+ + +
+ Projects - - - Projects organize your MCP servers, skills, assistants, and other - tools into separate workspaces. Use them to permission and scope - access to different products, teams or environments within your - organization. -
- -
- - - {canAdmin && ( - setCreateDialogOpen(true)} - onInviteMember={() => orgRoutes.team.goTo()} - onManageRoles={() => orgRoutes.access.roles.goTo()} - /> - )} -
- {filteredProjects.length === 0 && isSearching ? ( -
- No projects matching “{search}” - - - -
- ) : ( - <> - {favoriteProjects.length > 0 && ( -
-
- - - Your favorites - -
- {renderProjectContainer( - favoriteProjects.map(renderProjectItem), - )} -
- )} - - {renderProjectContainer( - visibleOtherProjects.map(renderProjectItem), - )} - {hasMore && ( - + +
+ ) : ( + <> + {favoriteProjects.length > 0 && ( +
+
+ + + Your favorites + +
+ {renderProjectContainer( + favoriteProjects.map(renderProjectItem), + )} +
+ )} + + {renderProjectContainer( + visibleOtherProjects.map(renderProjectItem), + )} + {hasMore && ( + + )} + + {otherProjects.length === 0 && + favoriteProjects.length === 0 && ( +
+ No projects yet + + + +
)} - - )} - - {otherProjects.length === 0 && favoriteProjects.length === 0 && ( -
- No projects yet - - - -
- )} - - )} -
+ + )} + +
{createDialogOpen && ( From 667e977dd214e7e388536aa05b8e78b47e29094b Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:18:44 -0700 Subject: [PATCH 07/13] fix(dashboard): match audit log color scheme in recent activity preview - Recent activity preview now uses ActionDot + ActionBadge (shared with /audit-logs) so categories are color-coded identically - "Projects" header bumped to h4 to match Recent challenges/activity - Capitalize N in "Add New" Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/src/pages/org/OrgAuditLogs.tsx | 6 +- client/dashboard/src/pages/org/OrgHome.tsx | 55 +++++++++++-------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/client/dashboard/src/pages/org/OrgAuditLogs.tsx b/client/dashboard/src/pages/org/OrgAuditLogs.tsx index d741c8d8ef..03b79b3360 100644 --- a/client/dashboard/src/pages/org/OrgAuditLogs.tsx +++ b/client/dashboard/src/pages/org/OrgAuditLogs.tsx @@ -189,7 +189,7 @@ function getResourceLabel(resource: string) { } } -function formatAuditAction(action: string) { +export function formatAuditAction(action: string) { const [resource, verb] = action.split(":"); if (!resource || !verb) { return action; @@ -485,7 +485,7 @@ function hasDiff(log: AuditLog): boolean { return log.beforeSnapshot != null || log.afterSnapshot != null; } -function ActionBadge({ action }: { action: string }) { +export function ActionBadge({ action }: { action: string }) { const category = getActionCategory(action); const colors = getActionColorConfig(category); return ( @@ -501,7 +501,7 @@ function ActionBadge({ action }: { action: string }) { ); } -function ActionDot({ action }: { action: string }) { +export function ActionDot({ action }: { action: string }) { const category = getActionCategory(action); const colors = getActionColorConfig(category); return ( diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 7a54bdb9de..22c9416562 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -57,7 +57,12 @@ import { import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router"; -import { getActorLabel, renderVerb } from "./OrgAuditLogs"; +import { + ActionBadge, + ActionDot, + getActorLabel, + renderVerb, +} from "./OrgAuditLogs"; const PROJECT_LIMIT = 6; const AUDIT_PREVIEW_LIMIT = 8; @@ -260,9 +265,7 @@ export function OrgHomeInner() {
- - Projects - + Projects {filteredProjects.length === 0 && isSearching ? (
@@ -381,7 +384,7 @@ function AddNewMenu({ @@ -830,24 +833,32 @@ function RecentActivityCompact({ logs }: { logs: AuditLog[] }) { {preview.map((log) => (
  • - - - {getActorLabel(log)} - {" "} - {renderVerb(log)} - - - {log.projectSlug ? `${log.projectSlug} · ` : ""} - {dateTimeFormatters.humanize(log.createdAt, { - includeTime: false, - })} - + +
    +
    + + + + {getActorLabel(log)} + {" "} + + {renderVerb(log)} + + +
    + + {log.projectSlug ? `${log.projectSlug} · ` : ""} + {dateTimeFormatters.humanize(log.createdAt, { + includeTime: false, + })} + +
  • ))} From 508a88040245bb940f01bb170e9d7fcbe71375e8 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:23:17 -0700 Subject: [PATCH 08/13] chore: seed-challenges task and visual divider between favorites and projects - New mise task seed-challenges authenticates via dev-IDP and inserts synthetic deny challenges directly into ClickHouse so the org home Recent challenges box has data to render in local dev - Add a "All projects" hairline divider between the favorites section and the rest of the project list when both are populated Co-Authored-By: Claude Opus 4.7 (1M context) --- .mise-tasks/seed-challenges.mts | 320 +++++++++++++++++++++ client/dashboard/src/pages/org/OrgHome.tsx | 34 ++- 2 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 .mise-tasks/seed-challenges.mts diff --git a/.mise-tasks/seed-challenges.mts b/.mise-tasks/seed-challenges.mts new file mode 100644 index 0000000000..dedde8c726 --- /dev/null +++ b/.mise-tasks/seed-challenges.mts @@ -0,0 +1,320 @@ +#!/usr/bin/env -S node + +//MISE description="Seed the local ClickHouse with synthetic authz deny challenges so the org home page has data to render" + +import crypto from "node:crypto"; + +import { intro, log, outro } from "@clack/prompts"; +import { GramCore } from "@gram/client/core.js"; +import { authInfo } from "@gram/client/funcs/authInfo.js"; + +const CLICKHOUSE_URL = `http://${process.env.CLICKHOUSE_HOST ?? "127.0.0.1"}:${ + process.env.CLICKHOUSE_HTTP_PORT ?? "8123" +}`; +const CLICKHOUSE_USER = process.env.CLICKHOUSE_USERNAME ?? "gram"; +const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD ?? "gram"; +const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE ?? "default"; + +type ChallengeRow = { + timestamp: string; + organization_id: string; + project_id: string; + trace_id: string; + span_id: string; + request_id: string; + principal_urn: string; + principal_type: string; + user_id: string | null; + user_external_id: string | null; + user_email: string | null; + api_key_id: string | null; + session_id: string | null; + role_slugs: string[]; + operation: string; + outcome: string; + reason: string; + scope: string; + resource_kind: string; + resource_id: string; + selector: string; + expanded_scopes: string[]; + "requested_checks.scope": string[]; + "requested_checks.resource_kind": string[]; + "requested_checks.resource_id": string[]; + "requested_checks.selector": string[]; + "matched_grants.principal_urn": string[]; + "matched_grants.scope": string[]; + "matched_grants.selector": string[]; + "matched_grants.matched_via_check_scope": string[]; + evaluated_grant_count: number; + filter_candidate_count: number; + filter_allowed_count: number; +}; + +function hex(len: number): string { + return crypto.randomBytes(len / 2).toString("hex"); +} + +function formatTs(date: Date): string { + // ClickHouse DateTime64(9) accepts "YYYY-MM-DD HH:mm:ss.SSSSSSSSS". + // JSONEachRow tolerates ISO-ish strings too — millisecond precision is enough here. + return date.toISOString().replace("T", " ").replace("Z", ""); +} + +async function clickhouseInsert(rows: ChallengeRow[]): Promise { + const body = rows.map((r) => JSON.stringify(r)).join("\n"); + const url = new URL(CLICKHOUSE_URL); + url.searchParams.set("database", CLICKHOUSE_DATABASE); + url.searchParams.set( + "query", + "INSERT INTO authz_challenges FORMAT JSONEachRow", + ); + + const auth = Buffer.from( + `${CLICKHOUSE_USER}:${CLICKHOUSE_PASSWORD}`, + ).toString("base64"); + + const res = await fetch(url.toString(), { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/x-ndjson", + }, + body, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`ClickHouse insert failed: ${res.status} ${text}`); + } +} + +async function authenticateViaDevIDP(serverURL: string): Promise { + const loginRes = await fetch(`${serverURL}/rpc/auth.login`, { + redirect: "manual", + }); + const authorizeURL = loginRes.headers.get("location"); + if (!authorizeURL) { + throw new Error("auth.login did not return a redirect"); + } + const nonceCookie = loginRes.headers + .getSetCookie() + .find((c) => c.startsWith("gram_auth_nonce=")); + if (!nonceCookie) { + throw new Error("auth.login did not set gram_auth_nonce cookie"); + } + const nonceCookieValue = nonceCookie.split(";")[0]; + const authorizeRes = await fetch(authorizeURL, { redirect: "manual" }); + const callbackLocation = authorizeRes.headers.get("location"); + if (!callbackLocation) { + throw new Error("dev-idp authorize did not return a redirect"); + } + const callbackRes = await fetch(callbackLocation, { + redirect: "manual", + headers: { cookie: nonceCookieValue }, + }); + const sessionToken = callbackRes.headers.get("gram-session"); + if (!sessionToken) { + throw new Error( + `auth.callback did not return a session (status=${callbackRes.status})`, + ); + } + return sessionToken; +} + +type Scenario = { + principalUrn: string; + principalType: "user" | "api_key"; + userId?: string; + userEmail?: string; + apiKeyId?: string; + operation: "require" | "require_any" | "filter"; + reason: string; + scope: string; + resourceKind: string; + resourceId: string; + selector: string; + expandedScopes: string[]; + copies: number; +}; + +function buildScenarios(args: { + organizationId: string; + projectId: string; + userId: string; + userEmail: string; +}): Scenario[] { + const { projectId } = args; + return [ + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + reason: "scope_unsatisfied", + scope: "toolset:admin", + resourceKind: "toolset", + resourceId: `tst_${hex(16)}`, + selector: JSON.stringify({ project: projectId }), + expandedScopes: ["toolset:admin", "toolset:write", "toolset:read"], + copies: 4, + }, + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + reason: "no_grants", + scope: "project:admin", + resourceKind: "project", + resourceId: projectId, + selector: JSON.stringify({ id: projectId }), + expandedScopes: ["project:admin"], + copies: 2, + }, + { + principalUrn: `api_key:akey_${hex(12)}`, + principalType: "api_key", + apiKeyId: `akey_${hex(12)}`, + operation: "require", + reason: "scope_unsatisfied", + scope: "mcp:invoke", + resourceKind: "mcp", + resourceId: `mcp_${hex(16)}`, + selector: JSON.stringify({ project: projectId }), + expandedScopes: ["mcp:invoke", "mcp:read"], + copies: 7, + }, + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + reason: "deny_grant", + scope: "environment:write", + resourceKind: "environment", + resourceId: `env_${hex(16)}`, + selector: JSON.stringify({ project: projectId, name: "production" }), + expandedScopes: ["environment:write", "environment:read"], + copies: 1, + }, + ]; +} + +function scenarioToRows( + s: Scenario, + organizationId: string, + projectId: string, +): ChallengeRow[] { + const rows: ChallengeRow[] = []; + const now = Date.now(); + for (let i = 0; i < s.copies; i++) { + // Spread challenges over the last 6 hours so timestamps look real. + const ts = new Date(now - Math.floor(Math.random() * 6 * 60 * 60 * 1000)); + rows.push({ + timestamp: formatTs(ts), + organization_id: organizationId, + project_id: projectId, + trace_id: hex(32), + span_id: hex(16), + request_id: `req_${hex(16)}`, + principal_urn: s.principalUrn, + principal_type: s.principalType, + user_id: s.userId ?? null, + user_external_id: null, + user_email: s.userEmail ?? null, + api_key_id: s.apiKeyId ?? null, + session_id: null, + role_slugs: [], + operation: s.operation, + outcome: "deny", + reason: s.reason, + scope: s.scope, + resource_kind: s.resourceKind, + resource_id: s.resourceId, + selector: s.selector, + expanded_scopes: s.expandedScopes, + "requested_checks.scope": [s.scope], + "requested_checks.resource_kind": [s.resourceKind], + "requested_checks.resource_id": [s.resourceId], + "requested_checks.selector": [s.selector], + "matched_grants.principal_urn": [], + "matched_grants.scope": [], + "matched_grants.selector": [], + "matched_grants.matched_via_check_scope": [], + evaluated_grant_count: 0, + filter_candidate_count: 0, + filter_allowed_count: 0, + }); + } + return rows; +} + +async function main(): Promise { + intro("Seeding synthetic authz deny challenges into ClickHouse..."); + let success = false; + using _ = { + [Symbol.dispose]() { + outro(success ? "Seeding complete!" : "Seeding failed."); + }, + }; + + const serverURL = process.env["GRAM_SERVER_URL"]; + if (!serverURL) { + throw new Error( + "GRAM_SERVER_URL is not set — run via `mise run seed-challenges`", + ); + } + + const gram = new GramCore({ serverURL }); + const sessionId = await authenticateViaDevIDP(serverURL); + log.info("Authenticated via dev-idp"); + + const res = await authInfo(gram, undefined, { + sessionHeaderGramSession: sessionId, + }); + if (!res.ok) { + throw new Error(`authInfo failed: ${JSON.stringify(res.error)}`); + } + const session = res.value.result; + const orgId = session.activeOrganizationId; + if (!orgId) { + throw new Error("No active organization on session"); + } + const org = session.organizations.find((o) => o.id === orgId); + if (!org || org.projects.length === 0) { + throw new Error( + "Active org has no projects — run `mise run seed` first so there are projects to scope challenges to", + ); + } + const userId = session.userId; + const userEmail = session.userEmail; + const project = org.projects[0]!; + + log.info( + `Org=${orgId} project=${project.slug} user=${userEmail} — building scenarios`, + ); + + const scenarios = buildScenarios({ + organizationId: orgId, + projectId: project.id, + userId, + userEmail, + }); + const rows = scenarios.flatMap((s) => scenarioToRows(s, orgId, project.id)); + + log.info( + `Inserting ${rows.length} challenge rows across ${scenarios.length} buckets`, + ); + await clickhouseInsert(rows); + + log.info( + "Done. Refresh the org home page — Recent challenges should now render the buckets.", + ); + success = true; +} + +await main(); diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index 22c9416562..acd69f5980 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -286,17 +286,31 @@ export function OrgHomeInner() { ) : ( <> {favoriteProjects.length > 0 && ( -
    -
    - - - Your favorites - -
    - {renderProjectContainer( - favoriteProjects.map(renderProjectItem), + <> +
    +
    + + + Your favorites + +
    + {renderProjectContainer( + favoriteProjects.map(renderProjectItem), + )} +
    + {visibleOtherProjects.length > 0 && ( +
    + )} {renderProjectContainer( From bbe22f209064a10005b6390e9a12b68b1c908060 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:25:00 -0700 Subject: [PATCH 09/13] chore: seed-team-members task for facepile preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts ~10 fake org members with pravatar photo URLs so the project-card facepile renders with visible stacking on local dev. Idempotent — workos_id and email are derived from a hash of the seed email so reruns upsert in place. Co-Authored-By: Claude Opus 4.7 (1M context) --- .mise-tasks/seed-team-members.mts | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .mise-tasks/seed-team-members.mts diff --git a/.mise-tasks/seed-team-members.mts b/.mise-tasks/seed-team-members.mts new file mode 100644 index 0000000000..28c436d56d --- /dev/null +++ b/.mise-tasks/seed-team-members.mts @@ -0,0 +1,138 @@ +#!/usr/bin/env -S node + +//MISE description="Seed a handful of fake org members locally so the project-card facepile renders with visible stacking" + +import crypto from "node:crypto"; + +import { intro, log, outro } from "@clack/prompts"; +import { GramCore } from "@gram/client/core.js"; +import { authInfo } from "@gram/client/funcs/authInfo.js"; +import { $ } from "zx"; + +const FAKE_MEMBERS: { name: string; email: string; pravatarId: number }[] = [ + { name: "Ava Martinez", email: "ava.martinez@example.com", pravatarId: 12 }, + { name: "Leo Tanaka", email: "leo.tanaka@example.com", pravatarId: 13 }, + { name: "Priya Shah", email: "priya.shah@example.com", pravatarId: 14 }, + { name: "Noah Becker", email: "noah.becker@example.com", pravatarId: 15 }, + { name: "Yuki Watanabe", email: "yuki.watanabe@example.com", pravatarId: 16 }, + { name: "Sofia Rossi", email: "sofia.rossi@example.com", pravatarId: 17 }, + { name: "Jamal Carter", email: "jamal.carter@example.com", pravatarId: 18 }, + { name: "Mei Chen", email: "mei.chen@example.com", pravatarId: 19 }, + { name: "Ravi Iyer", email: "ravi.iyer@example.com", pravatarId: 20 }, + { name: "Hannah Olsen", email: "hannah.olsen@example.com", pravatarId: 21 }, +]; + +async function authenticateViaDevIDP(serverURL: string): Promise { + const loginRes = await fetch(`${serverURL}/rpc/auth.login`, { + redirect: "manual", + }); + const authorizeURL = loginRes.headers.get("location"); + if (!authorizeURL) throw new Error("auth.login did not return a redirect"); + const nonceCookie = loginRes.headers + .getSetCookie() + .find((c) => c.startsWith("gram_auth_nonce=")); + if (!nonceCookie) throw new Error("auth.login did not set gram_auth_nonce"); + const nonceCookieValue = nonceCookie.split(";")[0]; + const authorizeRes = await fetch(authorizeURL, { redirect: "manual" }); + const callbackLocation = authorizeRes.headers.get("location"); + if (!callbackLocation) { + throw new Error("dev-idp authorize did not return a redirect"); + } + const callbackRes = await fetch(callbackLocation, { + redirect: "manual", + headers: { cookie: nonceCookieValue }, + }); + const sessionToken = callbackRes.headers.get("gram-session"); + if (!sessionToken) { + throw new Error( + `auth.callback did not return a session (status=${callbackRes.status})`, + ); + } + return sessionToken; +} + +function sqlString(value: string | null): string { + if (value === null) return "NULL"; + return `'${value.replace(/'/g, "''")}'`; +} + +async function psql(sql: string): Promise { + const dbUser = process.env.DB_USER ?? "gram"; + const dbName = process.env.DB_NAME ?? "gram"; + await $`docker compose exec -T gram-db psql -U ${dbUser} -d ${dbName} -v ON_ERROR_STOP=1 -c ${sql}`.quiet(); +} + +async function main(): Promise { + intro("Seeding fake team members for facepile preview..."); + let success = false; + using _ = { + [Symbol.dispose]() { + outro(success ? "Done." : "Seeding failed."); + }, + }; + + const serverURL = process.env["GRAM_SERVER_URL"]; + if (!serverURL) { + throw new Error( + "GRAM_SERVER_URL is not set — run via `mise run seed-team-members`", + ); + } + + const gram = new GramCore({ serverURL }); + const sessionId = await authenticateViaDevIDP(serverURL); + const res = await authInfo(gram, undefined, { + sessionHeaderGramSession: sessionId, + }); + if (!res.ok) { + throw new Error(`authInfo failed: ${JSON.stringify(res.error)}`); + } + const orgId = res.value.result.activeOrganizationId; + if (!orgId) throw new Error("No active organization on session"); + log.info(`Active org: ${orgId}`); + + // INSERT users with a stable id and workos_id derived from email so the task + // is idempotent — rerunning won't duplicate or collide. + const valueRows = FAKE_MEMBERS.map((m) => { + const userId = `usr_seed_${crypto + .createHash("sha1") + .update(m.email) + .digest("hex") + .slice(0, 16)}`; + const workosId = `seed_workos_${crypto + .createHash("sha1") + .update(m.email) + .digest("hex") + .slice(0, 16)}`; + const photo = `https://i.pravatar.cc/150?img=${m.pravatarId}`; + return { userId, workosId, photo, ...m }; + }); + + const usersValues = valueRows + .map( + (r) => + `(${sqlString(r.userId)}, ${sqlString(r.email)}, ${sqlString(r.name)}, ${sqlString(r.photo)}, ${sqlString(r.workosId)})`, + ) + .join(",\n"); + + await psql( + `INSERT INTO users (id, email, display_name, photo_url, workos_id) VALUES\n${usersValues}\nON CONFLICT (email) DO UPDATE SET display_name = EXCLUDED.display_name, photo_url = EXCLUDED.photo_url, workos_id = EXCLUDED.workos_id;`, + ); + log.info(`Upserted ${valueRows.length} users`); + + const ourValues = valueRows + .map( + (r) => + `(${sqlString(orgId)}, ${sqlString(r.userId)}, ${sqlString(r.workosId)}, ${sqlString(`seed_mem_${r.userId}`)})`, + ) + .join(",\n"); + + await psql( + `INSERT INTO organization_user_relationships (organization_id, user_id, workos_user_id, workos_membership_id) VALUES\n${ourValues}\nON CONFLICT (organization_id, user_id) DO NOTHING;`, + ); + log.info( + `Linked ${valueRows.length} members to org ${orgId}. Refresh the dashboard.`, + ); + success = true; +} + +await main(); From 7c0b678d8fb0606e586af3e8d5b63d99b231a073 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:26:59 -0700 Subject: [PATCH 10/13] chore: chmod +x the new seed-* mise tasks mise discovers tasks under .mise-tasks only when the file is executable. Without this, `mise run seed-challenges` etc. would fail with "no task found". Co-Authored-By: Claude Opus 4.7 (1M context) --- .mise-tasks/seed-challenges.mts | 0 .mise-tasks/seed-team-members.mts | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .mise-tasks/seed-challenges.mts mode change 100644 => 100755 .mise-tasks/seed-team-members.mts diff --git a/.mise-tasks/seed-challenges.mts b/.mise-tasks/seed-challenges.mts old mode 100644 new mode 100755 diff --git a/.mise-tasks/seed-team-members.mts b/.mise-tasks/seed-team-members.mts old mode 100644 new mode 100755 From e561b7f40a45d15c87789f7d9506010e07fd2eb6 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:30:48 -0700 Subject: [PATCH 11/13] fix(dashboard): compact Recent challenges list to fit sidebar width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full Table from /access/challenges had 6 columns and was horizontally overflowing the 320px sidebar. Replaced with a stacked compact row (avatar + principal + deny pill, then scope · attempts · time on a second line) — same divided-list pattern as Recent activity. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/dashboard/src/pages/org/OrgHome.tsx | 88 +++++++++++++++++----- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/client/dashboard/src/pages/org/OrgHome.tsx b/client/dashboard/src/pages/org/OrgHome.tsx index acd69f5980..6146e4eff8 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -25,10 +25,12 @@ import { getInitials, isDisplayableBucket, } from "@/pages/access/challengeHelpers"; -import { useChallengeRowColumns } from "@/pages/access/useChallengeRowColumns"; -import { useGrantFlow } from "@/pages/access/useGrantFlow"; import { useOrgRoutes } from "@/routes"; -import type { AccessMember, AuditLog } from "@gram/client/models/components"; +import type { + AccessMember, + AuditLog, + ChallengeBucket, +} from "@gram/client/models/components"; import { Outcome } from "@gram/client/models/operations/listchallengebuckets.js"; import { useAuditLogs } from "@gram/client/react-query"; import { useChallengeBuckets } from "@gram/client/react-query/challengeBuckets.js"; @@ -38,13 +40,13 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Table, } from "@speakeasy-api/moonshine"; import { ChevronDown, ChevronUp, Copy, History, + KeyRound, LayoutGrid, List, MoreHorizontal, @@ -883,9 +885,6 @@ function RecentActivityCompact({ logs }: { logs: AuditLog[] }) { function RecentChallengesCompact() { const orgRoutes = useOrgRoutes(); - const { hasScope } = useRBAC(); - const canAdmin = hasScope("org:admin"); - const { actionsColumn, grantFlowPortals } = useGrantFlow(); const { data, isLoading } = useChallengeBuckets({ outcome: Outcome.Deny, resolved: false, @@ -900,13 +899,6 @@ function RecentChallengesCompact() { [data?.buckets], ); - const challengeRowColumns = useChallengeRowColumns(); - const columns = useMemo( - () => - canAdmin ? [...challengeRowColumns, actionsColumn] : challengeRowColumns, - [canAdmin, challengeRowColumns, actionsColumn], - ); - if (isLoading) return null; return ( @@ -920,11 +912,71 @@ function RecentChallengesCompact() { {buckets.length === 0 ? ( ) : ( -
    -
    row.id} /> - +
      + {buckets.map((bucket) => ( +
    1. + +
    2. + ))} +
    )} - {grantFlowPortals} ); } + +function shortenPrincipal(bucket: ChallengeBucket): string { + if (bucket.userEmail) return bucket.userEmail; + if (bucket.principalType === "api_key") { + // "api_key:akey_6a0dcca03eb1abcd" → "akey_6a0d…" + const id = bucket.principalUrn.replace(/^api_key:/, ""); + return id.length > 14 ? `${id.slice(0, 10)}…` : id; + } + return bucket.principalUrn; +} + +function CompactChallengeRow({ bucket }: { bucket: ChallengeBucket }) { + const orgRoutes = useOrgRoutes(); + const label = shortenPrincipal(bucket); + const isApiKey = bucket.principalType === "api_key"; + const lastSeen = new Date(bucket.lastSeen); + const count = Number(bucket.challengeCount); + + return ( + +
    + {isApiKey || !bucket.userEmail ? ( + + ) : ( + + {bucket.photoUrl ? ( + + ) : null} + + {getInitials(bucket.userEmail)} + + + )} +
    +
    +
    + + deny + + + {label} + +
    + + {bucket.scope} + · + {count} attempt{count === 1 ? "" : "s"} + · + {dateTimeFormatters.humanize(lastSeen, { includeTime: false })} + +
    +
    + ); +} From bb8aa7556fe034c3b0a0e7bfd2e8f55e43f29578 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Fri, 22 May 2026 17:33:19 -0700 Subject: [PATCH 12/13] chore: include allow outcomes in seed-challenges Adds 4 allow buckets (toolset:read, project:read, mcp:read, environment:read) alongside the existing deny buckets, each with a populated matched_grants entry so the /access/challenges page shows realistic data when filtered to approvals. Co-Authored-By: Claude Opus 4.7 (1M context) --- .mise-tasks/seed-challenges.mts | 108 +++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/.mise-tasks/seed-challenges.mts b/.mise-tasks/seed-challenges.mts index dedde8c726..a4e4cac97f 100755 --- a/.mise-tasks/seed-challenges.mts +++ b/.mise-tasks/seed-challenges.mts @@ -129,12 +129,16 @@ type Scenario = { userEmail?: string; apiKeyId?: string; operation: "require" | "require_any" | "filter"; + outcome: "deny" | "allow"; reason: string; scope: string; resourceKind: string; resourceId: string; selector: string; expandedScopes: string[]; + // Role principal URN that granted access; only used for allow outcomes. + grantedByRoleUrn?: string; + grantedByRoleSlug?: string; copies: number; }; @@ -146,12 +150,14 @@ function buildScenarios(args: { }): Scenario[] { const { projectId } = args; return [ + // Deny scenarios — what the Recent challenges box surfaces. { principalUrn: `user:${args.userId}`, principalType: "user", userId: args.userId, userEmail: args.userEmail, operation: "require", + outcome: "deny", reason: "scope_unsatisfied", scope: "toolset:admin", resourceKind: "toolset", @@ -166,6 +172,7 @@ function buildScenarios(args: { userId: args.userId, userEmail: args.userEmail, operation: "require", + outcome: "deny", reason: "no_grants", scope: "project:admin", resourceKind: "project", @@ -179,6 +186,7 @@ function buildScenarios(args: { principalType: "api_key", apiKeyId: `akey_${hex(12)}`, operation: "require", + outcome: "deny", reason: "scope_unsatisfied", scope: "mcp:invoke", resourceKind: "mcp", @@ -193,6 +201,7 @@ function buildScenarios(args: { userId: args.userId, userEmail: args.userEmail, operation: "require", + outcome: "deny", reason: "deny_grant", scope: "environment:write", resourceKind: "environment", @@ -201,6 +210,75 @@ function buildScenarios(args: { expandedScopes: ["environment:write", "environment:read"], copies: 1, }, + // Allow scenarios — for the full /access/challenges page when filtering + // to "approvals". Each has a matched grant so the bucket is interpretable. + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + outcome: "allow", + reason: "grant_matched", + scope: "toolset:read", + resourceKind: "toolset", + resourceId: `tst_${hex(16)}`, + selector: JSON.stringify({ project: projectId }), + expandedScopes: ["toolset:read"], + grantedByRoleUrn: "role:organization:admin", + grantedByRoleSlug: "admin", + copies: 12, + }, + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + outcome: "allow", + reason: "grant_matched", + scope: "project:read", + resourceKind: "project", + resourceId: projectId, + selector: JSON.stringify({ id: projectId }), + expandedScopes: ["project:read"], + grantedByRoleUrn: "role:organization:admin", + grantedByRoleSlug: "admin", + copies: 9, + }, + { + principalUrn: `api_key:akey_${hex(12)}`, + principalType: "api_key", + apiKeyId: `akey_${hex(12)}`, + operation: "require", + outcome: "allow", + reason: "grant_matched", + scope: "mcp:read", + resourceKind: "mcp", + resourceId: `mcp_${hex(16)}`, + selector: JSON.stringify({ project: projectId }), + expandedScopes: ["mcp:read"], + grantedByRoleUrn: "role:organization:viewer", + grantedByRoleSlug: "viewer", + copies: 18, + }, + { + principalUrn: `user:${args.userId}`, + principalType: "user", + userId: args.userId, + userEmail: args.userEmail, + operation: "require", + outcome: "allow", + reason: "grant_matched", + scope: "environment:read", + resourceKind: "environment", + resourceId: `env_${hex(16)}`, + selector: JSON.stringify({ project: projectId, name: "staging" }), + expandedScopes: ["environment:read"], + grantedByRoleUrn: "role:organization:editor", + grantedByRoleSlug: "editor", + copies: 5, + }, ]; } @@ -211,6 +289,16 @@ function scenarioToRows( ): ChallengeRow[] { const rows: ChallengeRow[] = []; const now = Date.now(); + const isAllow = s.outcome === "allow"; + // Allow rows must carry a non-empty matched_grants entry — that's the + // signal the bucket query uses to compute matched_grant_count. + const matchedPrincipal = + isAllow && s.grantedByRoleUrn ? [s.grantedByRoleUrn] : []; + const matchedScope = isAllow ? [s.scope] : []; + const matchedSelector = isAllow ? [s.selector] : []; + const matchedVia = isAllow ? [s.scope] : []; + const roleSlugs = isAllow && s.grantedByRoleSlug ? [s.grantedByRoleSlug] : []; + for (let i = 0; i < s.copies; i++) { // Spread challenges over the last 6 hours so timestamps look real. const ts = new Date(now - Math.floor(Math.random() * 6 * 60 * 60 * 1000)); @@ -228,9 +316,9 @@ function scenarioToRows( user_email: s.userEmail ?? null, api_key_id: s.apiKeyId ?? null, session_id: null, - role_slugs: [], + role_slugs: roleSlugs, operation: s.operation, - outcome: "deny", + outcome: s.outcome, reason: s.reason, scope: s.scope, resource_kind: s.resourceKind, @@ -241,11 +329,11 @@ function scenarioToRows( "requested_checks.resource_kind": [s.resourceKind], "requested_checks.resource_id": [s.resourceId], "requested_checks.selector": [s.selector], - "matched_grants.principal_urn": [], - "matched_grants.scope": [], - "matched_grants.selector": [], - "matched_grants.matched_via_check_scope": [], - evaluated_grant_count: 0, + "matched_grants.principal_urn": matchedPrincipal, + "matched_grants.scope": matchedScope, + "matched_grants.selector": matchedSelector, + "matched_grants.matched_via_check_scope": matchedVia, + evaluated_grant_count: isAllow ? 3 : 0, filter_candidate_count: 0, filter_allowed_count: 0, }); @@ -254,7 +342,7 @@ function scenarioToRows( } async function main(): Promise { - intro("Seeding synthetic authz deny challenges into ClickHouse..."); + intro("Seeding synthetic authz challenges (deny + allow) into ClickHouse..."); let success = false; using _ = { [Symbol.dispose]() { @@ -306,8 +394,10 @@ async function main(): Promise { }); const rows = scenarios.flatMap((s) => scenarioToRows(s, orgId, project.id)); + const denyBuckets = scenarios.filter((s) => s.outcome === "deny").length; + const allowBuckets = scenarios.filter((s) => s.outcome === "allow").length; log.info( - `Inserting ${rows.length} challenge rows across ${scenarios.length} buckets`, + `Inserting ${rows.length} challenge rows across ${scenarios.length} buckets (${denyBuckets} deny, ${allowBuckets} allow)`, ); await clickhouseInsert(rows); From bd1c0bcc2c804e30f5c065030821039dbfe72ba5 Mon Sep 17 00:00:00 2001 From: Sagar Batchu Date: Sat, 23 May 2026 16:22:36 -0700 Subject: [PATCH 13/13] chore: changeset for org home redesign Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/org-home-redesign.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/org-home-redesign.md diff --git a/.changeset/org-home-redesign.md b/.changeset/org-home-redesign.md new file mode 100644 index 0000000000..9f9bf57c24 --- /dev/null +++ b/.changeset/org-home-redesign.md @@ -0,0 +1,5 @@ +--- +"dashboard": patch +--- + +Redesign the org home page with a two-column layout. Left rail shows compressed Recent challenges (color-coded deny pills, sidebar-friendly width) and Recent activity (sharing the audit-log action color scheme). Right column shows projects as a thin rectangular stack (list view) or a 1/2/3-column card grid (grid view, toggle persisted in localStorage). Each project row/card shows the most recent audit-log action with a hover tooltip for full UTC + local timestamps, a facepile of active contributors (up to 10, sourced from audit-log actors with a deterministic earliest-by-joinedAt fallback), a star to favorite/unfavorite (stored client-side per org), and a kebab menu (favorite toggle, project settings, view audit logs, copy slug). New "Add New" dropdown next to the search bar offers Project / Team member / Role, gated by `org:admin`. Favorites surface in their own section above an "All projects" divider when present.