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. diff --git a/.mise-tasks/seed-challenges.mts b/.mise-tasks/seed-challenges.mts new file mode 100755 index 0000000000..a4e4cac97f --- /dev/null +++ b/.mise-tasks/seed-challenges.mts @@ -0,0 +1,410 @@ +#!/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"; + 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; +}; + +function buildScenarios(args: { + organizationId: string; + projectId: string; + userId: string; + userEmail: string; +}): 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", + 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", + outcome: "deny", + 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", + outcome: "deny", + 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", + outcome: "deny", + 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, + }, + // 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, + }, + ]; +} + +function scenarioToRows( + s: Scenario, + organizationId: string, + projectId: string, +): 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)); + 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: roleSlugs, + operation: s.operation, + outcome: s.outcome, + 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": 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, + }); + } + return rows; +} + +async function main(): Promise { + intro("Seeding synthetic authz challenges (deny + allow) 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)); + + 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 (${denyBuckets} deny, ${allowBuckets} allow)`, + ); + 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/.mise-tasks/seed-team-members.mts b/.mise-tasks/seed-team-members.mts new file mode 100755 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(); 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..03b79b3360 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"; } @@ -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; @@ -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"; @@ -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 a052458b81..6146e4eff8 100644 --- a/client/dashboard/src/pages/org/OrgHome.tsx +++ b/client/dashboard/src/pages/org/OrgHome.tsx @@ -1,29 +1,77 @@ 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 { useLocalStorageState } from "@/hooks/useLocalStorageState"; +import { useProjectFavorites } from "@/hooks/useProjectFavorites"; import { useRBAC } from "@/hooks/useRBAC"; +import { dateTimeFormatters } from "@/lib/dates"; +import { cn } from "@/lib/utils"; +import { ChallengesEmptyState } from "@/pages/access/ChallengesTab"; +import { + getInitials, + isDisplayableBucket, +} from "@/pages/access/challengeHelpers"; +import { useOrgRoutes } from "@/routes"; +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"; -import { ChallengesEmptyState } from "@/pages/access/ChallengesTab"; -import { 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 { useMembers } from "@gram/client/react-query/members.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@speakeasy-api/moonshine"; +import { + ChevronDown, + ChevronUp, + Copy, + History, + KeyRound, + LayoutGrid, + List, + MoreHorizontal, + Plus, + Settings, + ShieldCheck, + Star, + UserPlus, +} from "lucide-react"; import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router"; -const PROJECT_LIMIT = 5; +import { + ActionBadge, + ActionDot, + 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 +97,68 @@ 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 [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">( + "gram:org-home-view", + "list", + ); + + 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]); - const projects = useMemo( + // 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 +173,191 @@ 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 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 ( <> - - 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" - /> - -
+
+
+ + + {canAdmin && ( + setCreateDialogOpen(true)} + onInviteMember={() => orgRoutes.team.goTo()} + onManageRoles={() => orgRoutes.access.roles.goTo()} + /> + )}
- ) : ( - <> -
- {!isSearching && ( - - setCreateDialogOpen(true)} - title="New Project" - description="Create a new project for your organization" - /> - - )} - {visibleProjects.map((project) => ( - - - } - > - + + +
+ Projects + + {filteredProjects.length === 0 && isSearching ? ( +
+ No projects matching “{search}” + + + +
+ ) : ( + <> + {favoriteProjects.length > 0 && ( + <> +
+
+ + + Your favorites + +
+ {renderProjectContainer( + favoriteProjects.map(renderProjectItem), + )} +
+ {visibleOtherProjects.length > 0 && ( +
+
+
{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,46 +381,602 @@ 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 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, + facepile, + isFavorite, + onToggleFavorite, +}: { + project: OrgProject; + latestLog: AuditLog | undefined; + facepile: AccessMember[]; + isFavorite: boolean; + onToggleFavorite: () => void; +}) { + const { orgSlug } = useSlugs(); + + return ( +
+ {/* Decorative content: pointer-events-none routes clicks through to the + Link overlay below, while the actions region opts back in. */} + + +
+
+ + {project.name} + + + {project.slug} + +
+ +
+ +
+
+ +
+ +
+ + + + {/* 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} + +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+ ); +} + +function ProjectRowActions({ + project, + isFavorite, + onToggleFavorite, +}: { + project: OrgProject; + isFavorite: boolean; + onToggleFavorite: () => void; +}) { + const { orgSlug } = useSlugs(); + const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + + const closeAnd = (cb: () => void) => () => { + setMenuOpen(false); + cb(); + }; + + return ( +
{ + // Stop the absolute overlay from receiving clicks inside this region. + e.preventDefault(); + e.stopPropagation(); + }} + > + + + + + + + + + {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 + + + +
+ ); +} + +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"); - const { actionsColumn, grantFlowPortals } = useGrantFlow(); const { data, isLoading } = useChallengeBuckets({ outcome: Outcome.Deny, resolved: false, - limit: 5, + limit: CHALLENGE_PREVIEW_LIMIT, }); const buckets = useMemo( - () => (data?.buckets ?? []).filter(isDisplayableBucket), - [data?.buckets], - ); - - const challengeRowColumns = useChallengeRowColumns(); - - const columns = useMemo( () => - canAdmin ? [...challengeRowColumns, actionsColumn] : challengeRowColumns, - [canAdmin, challengeRowColumns, actionsColumn], + (data?.buckets ?? []) + .filter(isDisplayableBucket) + .slice(0, CHALLENGE_PREVIEW_LIMIT), + [data?.buckets], ); if (isLoading) return null; return ( -
-
- Recent Challenges - - Show more +
+
+ Recent challenges + + View all
{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 })} + +
+
); }