diff --git a/client/dashboard/src/hooks/useDismissedCtaStore.ts b/client/dashboard/src/hooks/useDismissedCtaStore.ts index 5d19074e873..19201f3f2cd 100644 --- a/client/dashboard/src/hooks/useDismissedCtaStore.ts +++ b/client/dashboard/src/hooks/useDismissedCtaStore.ts @@ -2,6 +2,8 @@ import { useSyncExternalStore } from "react"; type ScopedStorageStore = { useValue: (slug: string | undefined) => T; + /** Current value outside React, e.g. to patch state from an async callback. */ + read: (slug: string | undefined) => T; write: (slug: string, value: T) => void; }; @@ -14,12 +16,21 @@ export function createScopedStorageStore( const listeners = new Set<() => void>(); const storageKey = (slug: string) => `${prefix}:${slug}`; const memory = new Map(); + // Decoded snapshots keyed by the raw stored string. `useSyncExternalStore` + // compares snapshots by identity, so an object-valued store must hand back + // the same decoded value until the underlying string actually changes. + const decoded = new Map(); function read(slug: string | undefined): T { if (!slug) return defaultValue; if (memory.has(slug)) return memory.get(slug)!; try { - return decode(localStorage.getItem(storageKey(slug))); + const raw = localStorage.getItem(storageKey(slug)); + const cached = decoded.get(slug); + if (cached && cached.raw === raw) return cached.value; + const value = decode(raw); + decoded.set(slug, { raw, value }); + return value; } catch { return defaultValue; } @@ -70,7 +81,7 @@ export function createScopedStorageStore( ); } - return { useValue, write }; + return { useValue, read, write }; } /** diff --git a/client/dashboard/src/pages/security/PolicyDetail.tsx b/client/dashboard/src/pages/security/PolicyDetail.tsx index d43b581d3b8..41bd0831947 100644 --- a/client/dashboard/src/pages/security/PolicyDetail.tsx +++ b/client/dashboard/src/pages/security/PolicyDetail.tsx @@ -94,7 +94,7 @@ import { type ShadowMCPDisposition, } from "./policy-shadow-mcp-setup"; import { SupersedeDecisionsDialog } from "./SupersedeDecisionsDialog"; -import { type Step } from "@/pages/setup/components/onboarding-stepper"; +import type { Step } from "@/pages/setup/types"; import { DETECTION_RULES, RULE_CATEGORY_META, diff --git a/client/dashboard/src/pages/setup/components/board/assignee-avatar.tsx b/client/dashboard/src/pages/setup/components/board/assignee-avatar.tsx new file mode 100644 index 00000000000..271a0223403 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/assignee-avatar.tsx @@ -0,0 +1,45 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/Avatar"; +import { getIdentityTint } from "@/components/gradient-colors"; +import { getInitials } from "@/lib/initials"; +import { cn } from "@/lib/utils"; +import { type Assignee, assigneeIdentity } from "./board-store"; + +/** + * Two letters for the fallback face. Names go through the shared helper; an + * email uses its local part so "ops-lead@example.com" reads "OL", not "O". + */ +function assigneeInitials(assignee: Assignee): string { + if (assignee.kind === "user") return getInitials(assignee.name) || "?"; + const localPart = assignee.email.split("@")[0] ?? ""; + const words = localPart.split(/[^a-z0-9]+/i).filter(Boolean); + const initials = + words.length > 1 + ? words + .slice(0, 2) + .map((word) => word[0]) + .join("") + : localPart.slice(0, 2); + return initials.toUpperCase() || "?"; +} + +export function AssigneeAvatar({ + assignee, + className, +}: { + assignee: Assignee; + className?: string; +}): JSX.Element { + return ( + + {assignee.kind === "user" && assignee.photoUrl && ( + + )} + + {assigneeInitials(assignee)} + + + ); +} diff --git a/client/dashboard/src/pages/setup/components/board/assignee-picker.tsx b/client/dashboard/src/pages/setup/components/board/assignee-picker.tsx new file mode 100644 index 00000000000..c96c76f039a --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/assignee-picker.tsx @@ -0,0 +1,210 @@ +import { useMemo, useState } from "react"; +import { Check, Mail, UserRoundPlus, X } from "lucide-react"; +import { useListOrganizationUsers } from "@gram/client/react-query/listOrganizationUsers.js"; +import type { OrganizationUser } from "@gram/client/models/components/organizationuser.js"; +import { Button } from "@/components/ui/Button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/Command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/Popover"; +import { AssigneeAvatar } from "./assignee-avatar"; +import { type Assignee, assigneeLabel } from "./board-store"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function looksLikeEmail(value: string): boolean { + return EMAIL_PATTERN.test(value.trim()); +} + +function toAssignee(user: OrganizationUser): Assignee { + return { + kind: "user", + userId: user.userId, + name: user.name, + email: user.email, + photoUrl: user.photoUrl, + }; +} + +function matchesQuery(user: OrganizationUser, query: string): boolean { + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); +} + +interface AssigneePickerProps { + assignee: Assignee | undefined; + onChange: (assignee: Assignee | undefined) => void; + /** Trigger label while nobody is assigned. */ + placeholder?: string; + size?: "xs" | "sm"; +} + +/** + * Hands a task to a member of the organization, or to an email address for + * someone who has not joined yet. Team members come from the organization's + * user list; typing a full address that matches nobody offers it as an + * outside assignee. + */ +export function AssigneePicker({ + assignee, + onChange, + placeholder = "Assign", + size = "xs", +}: AssigneePickerProps): JSX.Element { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const { data, isLoading } = useListOrganizationUsers(undefined, undefined, { + enabled: open, + }); + + const users = useMemo( + () => [...(data?.users ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [data], + ); + const normalizedQuery = query.trim().toLowerCase(); + const matches = useMemo( + () => + normalizedQuery + ? users.filter((user) => matchesQuery(user, normalizedQuery)) + : users, + [users, normalizedQuery], + ); + const outsideEmail = + looksLikeEmail(query) && + !users.some((user) => user.email.toLowerCase() === normalizedQuery) + ? query.trim() + : null; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setQuery(""); + }; + + const select = (next: Assignee | undefined) => { + onChange(next); + handleOpenChange(false); + }; + + const currentIdentity = + assignee?.kind === "user" ? assignee.userId : undefined; + + return ( + + + + + {/* The popover is portaled, so React still bubbles its events up to the + card that owns this picker; stop them here so choosing a person never + doubles as clicking the card. */} + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + + {assignee && ( + + select(undefined)} + className="cursor-pointer" + > + + Unassign + + + )} + + {isLoading && ( + + Loading team… + + )} + {matches.map((user) => ( + select(toAssignee(user))} + className="cursor-pointer" + > + +
+
{user.name}
+
+ {user.email} +
+
+ {currentIdentity === user.userId && ( + + )} +
+ ))} +
+ {outsideEmail && ( + + + select({ kind: "email", email: outsideEmail }) + } + className="cursor-pointer" + > + + Assign {outsideEmail} + + + )} + + No team member matches. Enter a full email address to assign + someone who has not joined yet. + +
+
+
+
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/board/board-column.tsx b/client/dashboard/src/pages/setup/components/board/board-column.tsx new file mode 100644 index 00000000000..252ccae93d4 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/board-column.tsx @@ -0,0 +1,93 @@ +import { type DragEvent, type ReactNode, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { TASK_DRAG_TYPE } from "./task-card"; +import { + isOnboardingTaskId, + type OnboardingTaskId, + TASK_STATUS_META, + type TaskStatus, +} from "./tasks"; + +interface BoardColumnProps { + status: TaskStatus; + count: number; + onDropTask: (id: OnboardingTaskId) => void; + children: ReactNode; +} + +function carriesTask(event: DragEvent): boolean { + return event.dataTransfer.types.includes(TASK_DRAG_TYPE); +} + +/** One status column: a drop target for cards dragged from other columns. */ +export function BoardColumn({ + status, + count, + onDropTask, + children, +}: BoardColumnProps): JSX.Element { + const meta = TASK_STATUS_META[status]; + const [isOver, setIsOver] = useState(false); + // dragenter/dragleave fire for every descendant the pointer crosses, so + // track depth and only clear the highlight once the column itself is left. + const depth = useRef(0); + + const handleDragEnter = (event: DragEvent) => { + if (!carriesTask(event)) return; + depth.current += 1; + setIsOver(true); + }; + + const handleDragLeave = () => { + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setIsOver(false); + }; + + const handleDragOver = (event: DragEvent) => { + if (!carriesTask(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + depth.current = 0; + setIsOver(false); + const id = event.dataTransfer.getData(TASK_DRAG_TYPE); + if (isOnboardingTaskId(id)) onDropTask(id); + }; + + return ( +
+
+
+
+ {count} +
+
{children}
+ {count === 0 && ( +

+ Drag a task here +

+ )} +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/board/board-store.test.ts b/client/dashboard/src/pages/setup/components/board/board-store.test.ts new file mode 100644 index 00000000000..6d77c951a76 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/board-store.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { resolveBoardTasks, verifiedTaskIds } from "./board-store"; +import { ONBOARDING_TASKS } from "./tasks"; + +describe("verifiedTaskIds", () => { + it("is empty until the server confirms something", () => { + expect(verifiedTaskIds(undefined, undefined).size).toBe(0); + }); + + it("locks the tasks the server can vouch for", () => { + const ids = verifiedTaskIds( + { ssoConfigured: true, dsyncConfigured: false }, + { configured: true, connected: true }, + ); + expect([...ids]).toEqual(["connect-idp", "create-marketplace"]); + }); +}); + +describe("resolveBoardTasks", () => { + it("starts every task in To Do", () => { + const tasks = resolveBoardTasks({}, new Set()); + expect(tasks.map((task) => task.id)).toEqual( + ONBOARDING_TASKS.map((task) => task.id), + ); + expect( + tasks.every( + (task) => task.status === "todo" && !task.hidden && !task.verified, + ), + ).toBe(true); + }); + + it("applies the stored status, assignee, hidden flag and reminder", () => { + const [task] = resolveBoardTasks( + { + "connect-idp": { + status: "awaiting_support", + assignee: { kind: "email", email: "it-admin@example.com" }, + hidden: true, + lastRemindedAt: "2026-09-01T10:00:00.000Z", + }, + }, + new Set(), + ); + expect(task).toMatchObject({ + id: "connect-idp", + status: "awaiting_support", + hidden: true, + assignee: { kind: "email", email: "it-admin@example.com" }, + }); + expect(task?.lastRemindedAt?.toISOString()).toBe( + "2026-09-01T10:00:00.000Z", + ); + }); + + it("pins verified tasks to Done whatever the board says", () => { + const [task] = resolveBoardTasks( + { "connect-idp": { status: "todo" } }, + new Set(["connect-idp"]), + ); + expect(task).toMatchObject({ status: "done", verified: true }); + }); +}); diff --git a/client/dashboard/src/pages/setup/components/board/board-store.ts b/client/dashboard/src/pages/setup/components/board/board-store.ts new file mode 100644 index 00000000000..b2165c4a5c4 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/board-store.ts @@ -0,0 +1,121 @@ +import type { OnboardingStatusResult } from "@gram/client/models/components/onboardingstatusresult.js"; +import type { PublishStatusResult } from "@gram/client/models/components/publishstatusresult.js"; +import { createScopedStorageStore } from "@/hooks/useDismissedCtaStore"; +import { + ONBOARDING_TASKS, + type OnboardingTaskDefinition, + type OnboardingTaskId, + type TaskStatus, +} from "./tasks"; + +/** + * Who a task is handed to: a member of the organization, or an email address + * for someone who has not joined yet. + */ +export type Assignee = + | { + kind: "user"; + userId: string; + name: string; + email: string; + photoUrl?: string; + } + | { kind: "email"; email: string }; + +export function assigneeLabel(assignee: Assignee): string { + return assignee.kind === "user" ? assignee.name : assignee.email; +} + +export function assigneeIdentity(assignee: Assignee): string { + return assignee.kind === "user" ? assignee.userId : assignee.email; +} + +/** Everything the board remembers about one task. Absent fields are defaults. */ +export interface TaskRecord { + status?: TaskStatus; + assignee?: Assignee; + /** Platform admins can take a task off the board for this organization. */ + hidden?: boolean; + /** ISO timestamp of the last reminder sent for this task. */ + lastRemindedAt?: string; +} + +export type BoardState = Partial>; + +const EMPTY_STATE: BoardState = {}; + +function decodeBoardState(stored: string | null): BoardState { + if (!stored) return EMPTY_STATE; + try { + const parsed: unknown = JSON.parse(stored); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as BoardState; + } + } catch { + // Corrupt entry — start over rather than wedge the board. + } + return EMPTY_STATE; +} + +function encodeBoardState(value: BoardState): string | null { + return Object.keys(value).length === 0 ? null : JSON.stringify(value); +} + +/** + * Board state is scoped to the organization slug and kept in localStorage for + * now, the same way the wizard remembered its own progress. Moving it behind a + * management API is the follow-up that makes assignments visible to the whole + * team; nothing in the UI depends on where the state lives. + */ +export const onboardingBoardStore = createScopedStorageStore( + "gram-onboarding-board", + EMPTY_STATE, + decodeBoardState, + encodeBoardState, +); + +export interface BoardTask extends OnboardingTaskDefinition { + status: TaskStatus; + /** Completion was confirmed by the server, so the status is locked to Done. */ + verified: boolean; + hidden: boolean; + assignee?: Assignee; + lastRemindedAt?: Date; +} + +/** + * Tasks the server can vouch for. SSO and directory sync come from the + * organization's WorkOS state and the marketplace from the project's GitHub + * connection; the remaining tasks have no server signal and rely on the + * status people set on the board. + */ +export function verifiedTaskIds( + onboardingStatus: OnboardingStatusResult | undefined, + publishStatus: PublishStatusResult | undefined, +): Set { + const ids = new Set(); + if (onboardingStatus?.ssoConfigured) ids.add("connect-idp"); + if (onboardingStatus?.dsyncConfigured) ids.add("directory-sync"); + if (publishStatus?.connected) ids.add("create-marketplace"); + return ids; +} + +export function resolveBoardTasks( + state: BoardState, + verifiedIds: ReadonlySet, +): BoardTask[] { + return ONBOARDING_TASKS.map((definition) => { + const record = state[definition.id] ?? {}; + const verified = verifiedIds.has(definition.id); + return { + ...definition, + status: verified ? "done" : (record.status ?? "todo"), + verified, + hidden: record.hidden === true, + assignee: record.assignee, + lastRemindedAt: record.lastRemindedAt + ? new Date(record.lastRemindedAt) + : undefined, + }; + }); +} diff --git a/client/dashboard/src/pages/setup/components/board/onboarding-board.test.tsx b/client/dashboard/src/pages/setup/components/board/onboarding-board.test.tsx new file mode 100644 index 00000000000..843a45203e2 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/onboarding-board.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; + +import { TooltipProvider } from "@/components/ui/Tooltip"; +import { OnboardingBoard } from "./onboarding-board"; + +vi.mock("react-router", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ orgSlug: "acme" }), + useSearchParams: () => [new URLSearchParams(), vi.fn()], +})); +vi.mock("@gram/client/react-query/onboardingStatus", () => ({ + useOnboardingStatus: () => ({ + data: { ssoConfigured: true, dsyncConfigured: false }, + isLoading: false, + }), +})); +vi.mock("@gram/client/react-query/publishStatus", () => ({ + usePublishStatus: () => ({ data: { connected: false }, isLoading: false }), +})); +vi.mock("@gram/client/react-query/listOrganizationUsers.js", () => ({ + useListOrganizationUsers: () => ({ data: { users: [] }, isLoading: false }), +})); +vi.mock("@/contexts/Auth", () => ({ + useIsPlatformAdmin: () => false, +})); +vi.mock("@/components/ui/MoreActions", () => ({ + MoreActions: () => null, +})); +vi.mock("../onboarding-header", () => ({ + OnboardingHeader: () => null, +})); +vi.mock("../onboarding-footer", () => ({ + OnboardingFooter: () => null, +})); +vi.mock("./task-step", () => ({ + TaskStep: () => null, +})); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +function renderBoard() { + return render( + + + , + ); +} + +describe("OnboardingBoard", () => { + it("records that the setup view was opened for the org", () => { + renderBoard(); + + expect(localStorage.getItem("gram-org-welcome-rollout-started:acme")).toBe( + "true", + ); + }); + + it("puts server-verified tasks in Done and the rest in To Do", () => { + renderBoard(); + + const done = within(screen.getByRole("region", { name: "Done column" })); + expect(done.getByText("Connect identity provider")).toBeTruthy(); + expect(done.getByText("Verified")).toBeTruthy(); + + const todo = within(screen.getByRole("region", { name: "To Do column" })); + expect(todo.getByText("Directory sync")).toBeTruthy(); + expect(todo.getByText("Set up Platform MCP")).toBeTruthy(); + expect(todo.queryByText("Connect identity provider")).toBeNull(); + + expect(screen.getByText("1 of 9 done")).toBeTruthy(); + }); + + it("restores board state saved for the org", () => { + localStorage.setItem( + "gram-onboarding-board:acme", + JSON.stringify({ + "confirm-traffic": { + status: "awaiting_support", + assignee: { kind: "email", email: "security@example.com" }, + }, + }), + ); + + renderBoard(); + + const awaiting = within( + screen.getByRole("region", { name: "Awaiting Support column" }), + ); + expect(awaiting.getByText("Confirm traffic")).toBeTruthy(); + expect(awaiting.getByText("security@example.com")).toBeTruthy(); + }); +}); diff --git a/client/dashboard/src/pages/setup/components/board/onboarding-board.tsx b/client/dashboard/src/pages/setup/components/board/onboarding-board.tsx new file mode 100644 index 00000000000..19878ad78d2 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/onboarding-board.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Switch } from "@/components/ui/Switch"; +import { useOrgSetupStarted } from "@/hooks/useOrgSetupStarted"; +import { OnboardingFooter } from "../onboarding-footer"; +import { OnboardingHeader } from "../onboarding-header"; +import { BoardColumn } from "./board-column"; +import { TaskCard } from "./task-card"; +import { TaskDialog } from "./task-dialog"; +import { + isOnboardingTaskId, + type OnboardingTaskId, + TASK_STATUSES, +} from "./tasks"; +import { useOnboardingBoard } from "./use-onboarding-board"; + +const COLUMN_GRID_CLASS = + "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"; + +function BoardHeader({ + doneCount, + totalCount, + hiddenCount, + canHide, + showHidden, + onShowHiddenChange, +}: { + doneCount: number; + totalCount: number; + hiddenCount: number; + canHide: boolean; + showHidden: boolean; + onShowHiddenChange: (show: boolean) => void; +}): JSX.Element { + const percent = + totalCount === 0 ? 0 : Math.round((doneCount / totalCount) * 100); + return ( +
+
+ Organization +

+ Onboarding +

+

+ Every setup task on one board. Hand each one to an owner, track where + it stands, and send a reminder when it stalls. +

+
+
+
+ Progress +
+
+
+
+ + {doneCount} of {totalCount} done + +
+
+ {canHide && hiddenCount > 0 && ( + + )} +
+
+ ); +} + +function BoardSkeleton(): JSX.Element { + return ( +
+ {TASK_STATUSES.map((status) => ( + +
+
+
+ + ))} +
+ ); +} + +/** + * The organization setup flow as a board: one card per setup task, grouped by + * status. Cards open the original setup step in a dialog; `?task=` deep + * links straight to one, which is what reminder emails will point at. + */ +export function OnboardingBoard(): JSX.Element { + const navigate = useNavigate(); + const { orgSlug } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const { markSetupStarted } = useOrgSetupStarted(orgSlug); + + useEffect(() => { + markSetupStarted(); + }, [markSetupStarted]); + + const projectSlug = searchParams.get("projectSlug") ?? undefined; + const board = useOnboardingBoard(orgSlug); + const [showHidden, setShowHidden] = useState(false); + + const taskParam = searchParams.get("task"); + const openTaskId = + taskParam && isOnboardingTaskId(taskParam) ? taskParam : null; + const openTask = useMemo( + () => board.tasks.find((task) => task.id === openTaskId) ?? null, + [board.tasks, openTaskId], + ); + + const setOpenTask = useCallback( + (id: OnboardingTaskId | null) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (id) { + next.set("task", id); + } else { + next.delete("task"); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const activeTasks = board.tasks.filter((task) => !task.hidden); + const hiddenCount = board.tasks.length - activeTasks.length; + const doneCount = activeTasks.filter((task) => task.status === "done").length; + const visibleTasks = + board.canHideTasks && showHidden ? board.tasks : activeTasks; + + const handleLeave = () => { + void navigate(`/${orgSlug}`); + }; + + return ( +
+ + +
+
+ + + {board.isLoading ? ( + + ) : ( +
+ {TASK_STATUSES.map((status) => { + const columnTasks = visibleTasks.filter( + (task) => task.status === status, + ); + return ( + board.setStatus(id, status)} + > + {columnTasks.map((task) => ( + setOpenTask(task.id)} + onSetStatus={(next) => board.setStatus(task.id, next)} + onAssign={(assignee) => board.assign(task.id, assignee)} + onToggleHidden={() => + board.setHidden(task.id, !task.hidden) + } + onRemind={() => board.remind(task.id)} + /> + ))} + + ); + })} +
+ )} +
+
+ + + + setOpenTask(null)} + onOpenTask={setOpenTask} + onSetStatus={board.setStatus} + onAssign={board.assign} + onRemind={board.remind} + /> +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/board/remind-button.tsx b/client/dashboard/src/pages/setup/components/board/remind-button.tsx new file mode 100644 index 00000000000..132ce305cf0 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/remind-button.tsx @@ -0,0 +1,39 @@ +import { Bell } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import type { BoardTask } from "./board-store"; + +function remindDisabledReason(task: BoardTask): string | undefined { + if (task.status === "done") return "This task is already done"; + if (!task.assignee) return "Assign someone first"; + return undefined; +} + +export function RemindButton({ + task, + isReminding, + onRemind, + size = "xs", +}: { + task: BoardTask; + isReminding: boolean; + onRemind: () => void; + size?: "xs" | "sm"; +}): JSX.Element { + const disabledReason = remindDisabledReason(task); + return ( + + ); +} diff --git a/client/dashboard/src/pages/setup/components/board/reminders.ts b/client/dashboard/src/pages/setup/components/board/reminders.ts new file mode 100644 index 00000000000..0f9a4b84eec --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/reminders.ts @@ -0,0 +1,19 @@ +import { type Assignee, assigneeLabel } from "./board-store"; + +/** + * Sends the assignee an email asking them to finish a task. + * + * Stubbed: the real version will call a management API endpoint that emails + * the assignee a link back to the task on this board. Resolving after a short + * delay lets the UI exercise the pending state it will need once the request + * is real. + */ +export async function sendTaskReminder(input: { + taskTitle: string; + assignee: Assignee; +}): Promise<{ recipient: string }> { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + return { recipient: assigneeLabel(input.assignee) }; +} diff --git a/client/dashboard/src/pages/setup/components/board/task-card.tsx b/client/dashboard/src/pages/setup/components/board/task-card.tsx new file mode 100644 index 00000000000..b2e43319f5e --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/task-card.tsx @@ -0,0 +1,158 @@ +import type { DragEvent, KeyboardEvent, MouseEvent } from "react"; +import { Badge } from "@/components/ui/Badge"; +import { type Action, MoreActions } from "@/components/ui/MoreActions"; +import { formatRelativeTime } from "@/lib/dates"; +import { cn } from "@/lib/utils"; +import { AssigneePicker } from "./assignee-picker"; +import type { Assignee, BoardTask } from "./board-store"; +import { RemindButton } from "./remind-button"; +import { TASK_STATUS_META, TASK_STATUSES, type TaskStatus } from "./tasks"; + +/** Drag payload type: the task id travels as this custom MIME type. */ +export const TASK_DRAG_TYPE = "application/x-gram-onboarding-task"; + +// Inline controls sit inside a card whose own click opens the task dialog. +const stopPropagation = (event: MouseEvent) => event.stopPropagation(); + +interface TaskCardProps { + task: BoardTask; + canHide: boolean; + isReminding: boolean; + onOpen: () => void; + onSetStatus: (status: TaskStatus) => void; + onAssign: (assignee: Assignee | undefined) => void; + onToggleHidden: () => void; + onRemind: () => void; +} + +function buildMenuActions({ + task, + canHide, + onOpen, + onSetStatus, + onToggleHidden, +}: Pick< + TaskCardProps, + "task" | "canHide" | "onOpen" | "onSetStatus" | "onToggleHidden" +>): Action[] { + const actions: Action[] = [ + { icon: "maximize-2", label: "Open task", onClick: onOpen }, + ]; + if (!task.verified) { + for (const status of TASK_STATUSES) { + if (status === task.status) continue; + actions.push({ + label: `Move to ${TASK_STATUS_META[status].label}`, + onClick: () => onSetStatus(status), + separatorBefore: actions.length === 1, + }); + } + } + if (canHide) { + actions.push({ + icon: task.hidden ? "eye" : "eye-off", + label: task.hidden ? "Show on board" : "Hide from board", + onClick: onToggleHidden, + separatorBefore: true, + }); + } + return actions; +} + +export function TaskCard({ + task, + canHide, + isReminding, + onOpen, + onSetStatus, + onAssign, + onToggleHidden, + onRemind, +}: TaskCardProps): JSX.Element { + // Server-verified tasks are pinned to Done, so there is nowhere to drag them. + const draggable = !task.verified; + + const handleDragStart = (event: DragEvent) => { + event.dataTransfer.setData(TASK_DRAG_TYPE, task.id); + event.dataTransfer.effectAllowed = "move"; + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Keys pressed inside the assignee picker or menu belong to them. + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }; + + return ( +
+
+ {task.suggestedOwner} +
+ {task.badge && {task.badge}} + {task.verified && ( + + Verified + + )} + {task.hidden && ( + + Hidden + + )} + +
+
+ +
+

+ {task.title} +

+

+ {task.description} +

+
+ +
+ + +
+ + {task.lastRemindedAt && ( + + Reminded {formatRelativeTime(task.lastRemindedAt)} + + )} +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/board/task-dialog.tsx b/client/dashboard/src/pages/setup/components/board/task-dialog.tsx new file mode 100644 index 00000000000..0330518ba9d --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/task-dialog.tsx @@ -0,0 +1,139 @@ +import { Badge } from "@/components/ui/Badge"; +import { Dialog } from "@/components/ui/Dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/Select"; +import { cn } from "@/lib/utils"; +import { AssigneePicker } from "./assignee-picker"; +import type { Assignee, BoardTask } from "./board-store"; +import { RemindButton } from "./remind-button"; +import { TaskStep } from "./task-step"; +import { + type OnboardingTaskId, + TASK_STATUS_META, + TASK_STATUSES, + type TaskStatus, +} from "./tasks"; + +function StatusSelect({ + value, + disabled, + onChange, +}: { + value: TaskStatus; + disabled: boolean; + onChange: (status: TaskStatus) => void; +}): JSX.Element { + return ( + + ); +} + +interface TaskDialogProps { + task: BoardTask | null; + projectSlug?: string; + isReminding: boolean; + onClose: () => void; + onOpenTask: (id: OnboardingTaskId) => void; + onSetStatus: (id: OnboardingTaskId, status: TaskStatus) => void; + onAssign: (id: OnboardingTaskId, assignee: Assignee | undefined) => void; + onRemind: (id: OnboardingTaskId) => void; +} + +/** + * The task's detail view: its board metadata across the top and the original + * setup step underneath, so completing the step is still done in place. + */ +export function TaskDialog({ + task, + projectSlug, + isReminding, + onClose, + onOpenTask, + onSetStatus, + onAssign, + onRemind, +}: TaskDialogProps): JSX.Element { + return ( + { + if (!open) onClose(); + }} + > + {task && ( + + {task.title} + + {task.description} + + +
+ {task.suggestedOwner} + onSetStatus(task.id, status)} + /> + {task.verified && ( + + Verified + + )} + onAssign(task.id, assignee)} + size="sm" + /> + onRemind(task.id)} + size="sm" + /> +
+ +
+ { + onSetStatus(task.id, "done"); + onClose(); + }} + onClose={onClose} + onOpenTask={onOpenTask} + /> +
+
+ )} +
+ ); +} diff --git a/client/dashboard/src/pages/setup/components/board/task-step.tsx b/client/dashboard/src/pages/setup/components/board/task-step.tsx new file mode 100644 index 00000000000..04f106fe163 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/task-step.tsx @@ -0,0 +1,78 @@ +import { + AdditionalAgentConfigStep, + ConfigurePoliciesStep, + ConfirmTrafficStep, + ConnectIdpStep, + CreateMarketplaceStep, + DirectorySyncStep, + DistributeServersStep, + InstrumentAgentsStep, + PlatformMCPSetupStep, +} from "../steps"; +import type { OnboardingTaskId } from "./tasks"; + +export interface TaskStepProps { + taskId: OnboardingTaskId; + projectSlug?: string; + /** The step's own Continue / Finish control was used. */ + onComplete: () => void; + /** The step's Back or Skip control was used. */ + onClose: () => void; + /** A step wants to hand off to another task's dialog. */ + onOpenTask: (id: OnboardingTaskId) => void; +} + +/** + * Renders the setup step behind a board task. The steps were written for a + * linear wizard, so their Back and Skip controls map to closing the dialog and + * Continue maps to marking the task done. + */ +export function TaskStep({ + taskId, + projectSlug, + onComplete, + onClose, + onOpenTask, +}: TaskStepProps): JSX.Element { + switch (taskId) { + case "connect-idp": + return ; + case "directory-sync": + return ; + case "create-marketplace": + return ; + case "instrument-agents": + return ; + case "additional-agent-config": + return ( + + ); + case "confirm-traffic": + return ; + case "distribute-servers": + return ( + onOpenTask("platform-mcp")} + /> + ); + case "configure-policies": + return ; + case "platform-mcp": + return ( + + ); + } +} diff --git a/client/dashboard/src/pages/setup/components/board/tasks.ts b/client/dashboard/src/pages/setup/components/board/tasks.ts new file mode 100644 index 00000000000..2ad656945c3 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/tasks.ts @@ -0,0 +1,126 @@ +export type TaskStatus = "todo" | "in_progress" | "awaiting_support" | "done"; + +/** Column order on the board. */ +export const TASK_STATUSES: TaskStatus[] = [ + "todo", + "in_progress", + "awaiting_support", + "done", +]; + +export const TASK_STATUS_META: Record< + TaskStatus, + { label: string; hint: string; dotClassName: string } +> = { + todo: { + label: "To Do", + hint: "Not started", + dotClassName: "bg-muted-foreground/40", + }, + in_progress: { + label: "In Progress", + hint: "Being worked on", + dotClassName: "bg-information-default", + }, + awaiting_support: { + label: "Awaiting Support", + hint: "Waiting on Speakeasy", + dotClassName: "bg-warning-default", + }, + done: { + label: "Done", + hint: "Complete", + dotClassName: "bg-success-default", + }, +}; + +export type OnboardingTaskId = + | "connect-idp" + | "directory-sync" + | "create-marketplace" + | "instrument-agents" + | "additional-agent-config" + | "confirm-traffic" + | "distribute-servers" + | "configure-policies" + | "platform-mcp"; + +export interface OnboardingTaskDefinition { + id: OnboardingTaskId; + title: string; + description: string; + /** + * The role in the customer's organization that usually owns this task. + * Shown as the card's eyebrow so the board reads as a checklist of + * responsibilities to hand out, not just a list of steps. + */ + suggestedOwner: string; + /** Optional inline marker after the title, e.g. "Optional". */ + badge?: string; +} + +const IT_ADMIN = "IT admin"; +const ENGINEERING_LEAD = "Engineering lead"; +const SECURITY_LEAD = "Security lead"; + +export const ONBOARDING_TASKS: OnboardingTaskDefinition[] = [ + { + id: "connect-idp", + title: "Connect identity provider", + description: "Link SSO for authentication", + suggestedOwner: IT_ADMIN, + }, + { + id: "directory-sync", + title: "Directory sync", + description: "Confirm users and roles", + suggestedOwner: IT_ADMIN, + }, + { + id: "create-marketplace", + title: "Create plugin marketplace", + description: "For distributing servers to your users", + suggestedOwner: ENGINEERING_LEAD, + }, + { + id: "instrument-agents", + title: "Instrument agents", + description: "Connect AI coding assistants", + suggestedOwner: ENGINEERING_LEAD, + }, + { + id: "additional-agent-config", + title: "Additional agent configuration", + description: "Optional API keys for usage and compliance data", + suggestedOwner: ENGINEERING_LEAD, + }, + { + id: "confirm-traffic", + title: "Confirm traffic", + description: "Verify connectivity and compliance", + suggestedOwner: SECURITY_LEAD, + }, + { + id: "distribute-servers", + title: "Distribute MCP servers", + description: "Choose some MCP Servers to distribute to your organization", + suggestedOwner: ENGINEERING_LEAD, + }, + { + id: "configure-policies", + title: "Configure policies", + description: "Pick the categories to flag in agent traffic", + suggestedOwner: SECURITY_LEAD, + }, + { + id: "platform-mcp", + title: "Set up Platform MCP", + description: "Optional agent-assisted MCP setup", + suggestedOwner: ENGINEERING_LEAD, + badge: "Optional", + }, +]; + +export function isOnboardingTaskId(value: string): value is OnboardingTaskId { + return ONBOARDING_TASKS.some((task) => task.id === value); +} diff --git a/client/dashboard/src/pages/setup/components/board/use-onboarding-board.ts b/client/dashboard/src/pages/setup/components/board/use-onboarding-board.ts new file mode 100644 index 00000000000..f9cda050de3 --- /dev/null +++ b/client/dashboard/src/pages/setup/components/board/use-onboarding-board.ts @@ -0,0 +1,117 @@ +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useOnboardingStatus } from "@gram/client/react-query/onboardingStatus"; +import { usePublishStatus } from "@gram/client/react-query/publishStatus"; +import { useIsPlatformAdmin } from "@/contexts/Auth"; +import { + type Assignee, + type BoardTask, + onboardingBoardStore, + resolveBoardTasks, + type TaskRecord, + verifiedTaskIds, +} from "./board-store"; +import { sendTaskReminder } from "./reminders"; +import type { OnboardingTaskId, TaskStatus } from "./tasks"; + +export interface OnboardingBoardActions { + setStatus: (id: OnboardingTaskId, status: TaskStatus) => void; + assign: (id: OnboardingTaskId, assignee: Assignee | undefined) => void; + setHidden: (id: OnboardingTaskId, hidden: boolean) => void; + remind: (id: OnboardingTaskId) => void; +} + +export interface OnboardingBoard extends OnboardingBoardActions { + tasks: BoardTask[]; + /** True while the server signals that lock verified tasks are loading. */ + isLoading: boolean; + /** Whether the viewer may hide tasks from the board and see hidden ones. */ + canHideTasks: boolean; + /** The task whose reminder is in flight, if any. */ + remindingTaskId: OnboardingTaskId | null; +} + +export function useOnboardingBoard( + orgSlug: string | undefined, +): OnboardingBoard { + const state = onboardingBoardStore.useValue(orgSlug); + const { data: onboardingStatus, isLoading: isOnboardingStatusLoading } = + useOnboardingStatus(); + const { data: publishStatus, isLoading: isPublishStatusLoading } = + usePublishStatus(); + const isPlatformAdmin = useIsPlatformAdmin(); + const [remindingTaskId, setRemindingTaskId] = + useState(null); + + const tasks = useMemo( + () => + resolveBoardTasks( + state, + verifiedTaskIds(onboardingStatus, publishStatus), + ), + [state, onboardingStatus, publishStatus], + ); + + const updateTask = useCallback( + (id: OnboardingTaskId, patch: TaskRecord) => { + if (!orgSlug) return; + // Read at write time rather than closing over `state`: `remind` patches + // after an await, by which point the board may have moved on. + const current = onboardingBoardStore.read(orgSlug); + onboardingBoardStore.write(orgSlug, { + ...current, + [id]: { ...current[id], ...patch }, + }); + }, + [orgSlug], + ); + + const setStatus = useCallback( + (id: OnboardingTaskId, status: TaskStatus) => updateTask(id, { status }), + [updateTask], + ); + + const assign = useCallback( + (id: OnboardingTaskId, assignee: Assignee | undefined) => + updateTask(id, { assignee }), + [updateTask], + ); + + const setHidden = useCallback( + (id: OnboardingTaskId, hidden: boolean) => updateTask(id, { hidden }), + [updateTask], + ); + + const remind = useCallback( + (id: OnboardingTaskId) => { + const task = tasks.find((candidate) => candidate.id === id); + if (!task?.assignee || remindingTaskId) return; + setRemindingTaskId(id); + void sendTaskReminder({ taskTitle: task.title, assignee: task.assignee }) + .then(({ recipient }) => { + updateTask(id, { lastRemindedAt: new Date().toISOString() }); + toast.success(`Reminder sent to ${recipient}`); + }) + .catch((error: unknown) => { + toast.error( + error instanceof Error ? error.message : "Failed to send reminder", + ); + }) + .finally(() => setRemindingTaskId(null)); + }, + [tasks, remindingTaskId, updateTask], + ); + + return { + tasks, + isLoading: isOnboardingStatusLoading || isPublishStatusLoading, + // Mirrors PlatformAdminGate: local dev always unlocks admin affordances so + // the hidden-task flow can be exercised without a platform-admin account. + canHideTasks: import.meta.env.DEV || isPlatformAdmin, + remindingTaskId, + setStatus, + assign, + setHidden, + remind, + }; +} diff --git a/client/dashboard/src/pages/setup/components/onboarding-footer.tsx b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx index eefeae06bd8..1f9e1b93e42 100644 --- a/client/dashboard/src/pages/setup/components/onboarding-footer.tsx +++ b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx @@ -3,7 +3,7 @@ import { ThemeSwitcher } from "@/components/ui/ThemeSwitcher"; export function OnboardingFooter(): JSX.Element { return (