+
+
+
{
+ void navigate(detailHref);
+ }}
+ icon={
+
+ }
+ >
+
+
+
+
+ {plugin.name}
+
+ {isDefault && (
+
+ Default
+
+ )}
+ {publishStatus?.upToDate === false && (
+
+ Needs syncing
+
+ )}
+
- {plugin.name}
+ {plugin.slug}
- {isDefault && (
-
- Default
-
- )}
- {publishStatus?.upToDate === false && (
-
- Needs syncing
-
- )}
-
- {plugin.slug}
-
+
+
+
+
+
+ {serverCount} {serverCount === 1 ? "server" : "servers"}
+
+
-
-
-
-
-
- {serverCount} {serverCount === 1 ? "server" : "servers"}
-
-
-
- {description && (
-
- {description}
-
- )}
-
- {publishStatus?.lastPublishedAt ? (
- <>
- Published{" "}
-
- >
- ) : (
- <>
- Updated
- >
+ {description && (
+
+ {description}
+
)}
-
+
+ {publishStatus?.lastPublishedAt ? (
+ <>
+ Published{" "}
+
+ >
+ ) : (
+ <>
+ Updated
+ >
+ )}
+
-
-
-
e.stopPropagation()}>
-
-
-
- Install
-
-
-
-
-
-
-
- {
- // Defer until after the dropdown has fully closed to
- // avoid a Radix focus-trap/body-lock conflict between
- // the closing menu and the opening sheet (same pattern
- // as MCPDetails.tsx).
- setTimeout(() => setIsInstallOpen(true), 0);
- }}
- >
-
- GitHub installation (preferred)
- {!installTarget && (
-
- Requires marketplace setup
-
- )}
-
-
-
- {
- void handleDownload("claude");
- }}
- >
- Download as zip — Claude
-
- {
- void handleDownload("cursor");
- }}
- >
- Download as zip — Cursor
-
- {
- void handleDownload("codex");
- }}
- >
- Download as zip — Codex
-
-
-
+
+
+
e.stopPropagation()}>
+
+
+
+ Install
+
+
+
+
+
+
+
+ {installActions.map((action) => (
+
+ {action.separatorBefore && }
+
+ {action.description ? (
+
+ {action.label}
+
+ {action.description}
+
+
+ ) : (
+ action.label
+ )}
+
+
+ ))}
+
+
+
+
e.stopPropagation()}>
+
+ View
+
+
+
+
+
-
e.stopPropagation()}>
-
- View
-
-
-
-
-
-
-
- {installTarget && (
-
e.stopPropagation()}>
-
-
- )}
-
+
+ {installTarget && (
+
e.stopPropagation()}>
+
+
+ )}
+
+
);
}
diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx
index 6dc58d302d5..afd7d41fce8 100644
--- a/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx
+++ b/client/dashboard/src/pages/remote-identity-providers/tabs/client/McpServersTab.tsx
@@ -1,8 +1,11 @@
import { RequireScope } from "@/components/require-scope";
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { DotRow } from "@/components/ui/dot-row";
import { DotTable } from "@/components/ui/dot-table";
+import type { Action } from "@/components/ui/more-actions";
import { Type } from "@/components/ui/type";
import { useSlugs } from "@/contexts/Sdk";
+import { useRBAC } from "@/hooks/useRBAC";
import { cn } from "@/lib/utils";
import { formatRemoteMcpDisplay } from "@/lib/sources";
import type { OrganizationMcpServer } from "@gram/client/models/components/organizationmcpserver.js";
@@ -40,6 +43,8 @@ function mcpServerHref(
export function McpServersTab({ clientId }: { clientId: string }): JSX.Element {
const { orgSlug } = useSlugs();
const queryClient = useQueryClient();
+ const { hasAnyScope } = useRBAC();
+ const canManage = hasAnyScope(["org:admin"]);
const { data, isLoading, isError } =
useOrganizationRemoteSessionClientMcpServers({
clientId,
@@ -87,64 +92,81 @@ export function McpServersTab({ clientId }: { clientId: string }): JSX.Element {
name: server.name,
url: server.url ?? "",
});
+ const actions: Action[] = [
+ {
+ label: "Remove from server",
+ destructive: true,
+ disabled: remove.isPending,
+ onClick: () =>
+ remove.mutate({
+ request: {
+ removeClientFromMcpServerRequestBody: {
+ clientId,
+ mcpServerId: server.id,
+ },
+ },
+ }),
+ },
+ ];
return (
-
- }
- href={href ?? undefined}
- ariaLabel={href ? `View MCP server ${label}` : undefined}
+ actions={canManage ? actions : []}
>
-
-
- {label}
-
-
-
-
- e.stopPropagation()}
+
+ }
+ href={href ?? undefined}
+ ariaLabel={href ? `View MCP server ${label}` : undefined}
+ >
+
+
-
-
-
-
-
-
-
-
-
-
- remove.mutate({
- request: {
- removeClientFromMcpServerRequestBody: {
- clientId,
- mcpServerId: server.id,
- },
- },
- })
- }
- >
- Remove from server
-
-
-
-
-
-
-
+ {label}
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+
+ {actions.map((action, index) => (
+ action.onClick()}
+ >
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+
);
})}
diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx
index 4a98eb0f433..479b1f9e8f6 100644
--- a/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx
+++ b/client/dashboard/src/pages/remote-identity-providers/tabs/client/SessionsTab.tsx
@@ -1,7 +1,10 @@
import { RequireScope } from "@/components/require-scope";
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { DotRow } from "@/components/ui/dot-row";
import { DotTable } from "@/components/ui/dot-table";
+import type { Action } from "@/components/ui/more-actions";
import { Type } from "@/components/ui/type";
+import { useRBAC } from "@/hooks/useRBAC";
import type { RemoteSession } from "@gram/client/models/components/remotesession.js";
import {
invalidateAllOrganizationRemoteSessionClientSessions,
@@ -26,6 +29,8 @@ import { formatTimestamp } from "./formatTimestamp";
export function SessionsTab({ clientId }: { clientId: string }): JSX.Element {
const queryClient = useQueryClient();
+ const { hasAnyScope } = useRBAC();
+ const canManage = hasAnyScope(["org:admin"]);
const { data, isLoading, isError } =
useOrganizationRemoteSessionClientSessions({
clientId,
@@ -94,79 +99,89 @@ export function SessionsTab({ clientId }: { clientId: string }): JSX.Element {
{ label: "" },
]}
>
- {sessionItems.map((session: RemoteSession) => (
-
- }
- >
-
-
- {session.subjectDisplayName ??
- session.subjectEmail ??
- session.subjectUrn}
-
-
-
-
- {formatTimestamp(session.createdAt)}
-
-
-
-
- {formatTimestamp(session.refreshExpiresAt)}
-
-
-
-
- {formatTimestamp(session.accessExpiresAt)}
-
-
-
-
- e.stopPropagation()}>
-
-
-
-
-
-
-
-
-
- {session.hasRefreshToken && (
-
- refresh.mutate({
- request: {
- id: session.id,
- },
- })
- }
- >
- Refresh now
-
- )}
-
- revoke.mutate({
- request: {
- id: session.id,
- },
- })
- }
- >
- Revoke session
-
-
-
-
-
-
-
- ))}
+ {sessionItems.map((session: RemoteSession) => {
+ const actions: Action[] = [
+ ...(session.hasRefreshToken
+ ? [
+ {
+ label: "Refresh now",
+ disabled: refresh.isPending,
+ onClick: () =>
+ refresh.mutate({ request: { id: session.id } }),
+ },
+ ]
+ : []),
+ {
+ label: "Revoke session",
+ destructive: true,
+ onClick: () => revoke.mutate({ request: { id: session.id } }),
+ },
+ ];
+ return (
+
+
+ }
+ >
+
+
+ {session.subjectDisplayName ??
+ session.subjectEmail ??
+ session.subjectUrn}
+
+
+
+
+ {formatTimestamp(session.createdAt)}
+
+
+
+
+ {formatTimestamp(session.refreshExpiresAt)}
+
+
+
+
+ {formatTimestamp(session.accessExpiresAt)}
+
+
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+ {actions.map((action, index) => (
+ action.onClick()}
+ >
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+ })}
)}
diff --git a/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx b/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx
index 1a8a4d83e76..bfc6c95d6b0 100644
--- a/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx
+++ b/client/dashboard/src/pages/remote-identity-providers/tabs/issuer/ClientsTab.tsx
@@ -1,7 +1,10 @@
import { RequireScope } from "@/components/require-scope";
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { DotRow } from "@/components/ui/dot-row";
import { DotTable } from "@/components/ui/dot-table";
+import type { Action } from "@/components/ui/more-actions";
import { Type } from "@/components/ui/type";
+import { useRBAC } from "@/hooks/useRBAC";
import { useOrgRoutes } from "@/routes";
import type { OrganizationRemoteSessionClient } from "@gram/client/models/components/organizationremotesessionclient.js";
import type { RemoteSessionIssuer } from "@gram/client/models/components/remotesessionissuer.js";
@@ -26,6 +29,8 @@ export function ClientsTab({
issuer: RemoteSessionIssuer;
}): JSX.Element {
const orgRoutes = useOrgRoutes();
+ const { hasAnyScope } = useRBAC();
+ const canManage = hasAnyScope(["org:admin"]);
const { data, isLoading, isError } = useOrganizationRemoteSessionClients({
issuerId: issuer.id,
});
@@ -58,62 +63,83 @@ export function ClientsTab({
{ label: "" },
]}
>
- {items.map((item) => (
-
}
- href={orgRoutes.remoteIdentityProviders.clientDetail.href(
- issuer.id,
- item.client.id,
- )}
- ariaLabel={`View client ${remoteSessionClientDisplayName(item.client)}`}
- >
-
- {
+ const actions: Action[] = [
+ {
+ label: "Delete client",
+ destructive: true,
+ onClick: () => setDeleteTarget(item),
+ },
+ ];
+ return (
+
+
+ }
+ href={orgRoutes.remoteIdentityProviders.clientDetail.href(
+ issuer.id,
+ item.client.id,
+ )}
+ ariaLabel={`View client ${remoteSessionClientDisplayName(item.client)}`}
>
- {remoteSessionClientDisplayName(item.client)}
-
-
-
-
- {item.mcpServerCount}{" "}
- {item.mcpServerCount === 1 ? "server" : "servers"}
-
-
-
-
- {item.activeSessionCount}{" "}
- {item.activeSessionCount === 1 ? "session" : "sessions"}
-
-
-
-
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
-
- setDeleteTarget(item)}>
- Delete client
-
-
-
-
-
-
-
- ))}
+
+
+ {remoteSessionClientDisplayName(item.client)}
+
+
+
+
+ {item.mcpServerCount}{" "}
+ {item.mcpServerCount === 1 ? "server" : "servers"}
+
+
+
+
+ {item.activeSessionCount}{" "}
+ {item.activeSessionCount === 1 ? "session" : "sessions"}
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+
+ {actions.map((action, index) => (
+ action.onClick()}
+ >
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+ })}
);
}
diff --git a/client/dashboard/src/pages/security/ExclusionsTab.tsx b/client/dashboard/src/pages/security/ExclusionsTab.tsx
index d5b07c1895e..7e461fea70d 100644
--- a/client/dashboard/src/pages/security/ExclusionsTab.tsx
+++ b/client/dashboard/src/pages/security/ExclusionsTab.tsx
@@ -1,4 +1,6 @@
import { Badge } from "@/components/ui/badge";
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
+import type { Action } from "@/components/ui/more-actions";
import { Type } from "@/components/ui/type";
import { Switch } from "@/components/ui/switch";
import {
@@ -73,6 +75,20 @@ export function ExclusionsTab({
});
};
+ const exclusionActions = (exclusion: RiskExclusion): Action[] => [
+ {
+ label: "Edit",
+ onClick: () => onSheetChange({ mode: "edit", exclusion }),
+ },
+ {
+ label: "Delete",
+ destructive: true,
+ onClick: () => {
+ deleteMutation.mutate({ request: { id: exclusion.id } });
+ },
+ },
+ ];
+
const columns: Column
[] = [
{
key: "criteria",
@@ -131,12 +147,7 @@ export function ExclusionsTab({
width: "0.3fr",
render: (exclusion) => (
e.stopPropagation()}>
- onSheetChange({ mode: "edit", exclusion })}
- onDelete={() => {
- deleteMutation.mutate({ request: { id: exclusion.id } });
- }}
- />
+
),
},
@@ -158,6 +169,14 @@ export function ExclusionsTab({
data={exclusions}
rowKey={(exclusion) => exclusion.id}
onRowClick={(exclusion) => onSheetChange({ mode: "edit", exclusion })}
+ renderRow={(exclusion, rowElement) => (
+
+ {rowElement}
+
+ )}
/>
);
}
@@ -176,13 +195,7 @@ export function ExclusionsTab({
);
}
-function ExclusionActionsMenu({
- onEdit,
- onDelete,
-}: {
- onEdit: () => void;
- onDelete: () => void;
-}): JSX.Element {
+function ExclusionActionsMenu({ actions }: { actions: Action[] }): JSX.Element {
return (
@@ -193,22 +206,22 @@ function ExclusionActionsMenu({
- {
- setTimeout(onEdit, 0);
- }}
- >
- Edit
-
- {
- setTimeout(onDelete, 0);
- }}
- >
- Delete
-
+ {actions.map((action) => (
+ {
+ setTimeout(action.onClick, 0);
+ }}
+ >
+ {action.label}
+
+ ))}
);
diff --git a/client/dashboard/src/pages/security/PolicyCenter.tsx b/client/dashboard/src/pages/security/PolicyCenter.tsx
index 5295e7700a2..f2e20325db0 100644
--- a/client/dashboard/src/pages/security/PolicyCenter.tsx
+++ b/client/dashboard/src/pages/security/PolicyCenter.tsx
@@ -2,6 +2,8 @@ import { InsightsConfig } from "@/components/insights-dock";
import { INSIGHTS_SUGGESTIONS } from "@/lib/insights-suggestions";
import { Page } from "@/components/page-layout";
import { RequireScope } from "@/components/require-scope";
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
+import type { Action } from "@/components/ui/more-actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -683,6 +685,34 @@ function PolicyCenterContent() {
setPolicyToDelete(row);
};
+ const policyActions = (row: PolicyRow): Action[] => [
+ {
+ label: "Edit",
+ onClick: () => {
+ // Both prompt and standard policies now edit on their
+ // dedicated detail page.
+ routes.policyCenter.detail.goTo(row.policy.id);
+ },
+ },
+ ...(row.kind === "risk"
+ ? [
+ {
+ label: "View Progress",
+ onClick: () => {
+ setTimeout(() => setRunPanelPolicy(row.policy), 0);
+ },
+ },
+ ]
+ : []),
+ {
+ label: "Delete",
+ destructive: true,
+ onClick: () => {
+ setTimeout(() => handleDelete(row), 0);
+ },
+ },
+ ];
+
const confirmDelete = () => {
if (!policyToDelete) return;
deleteMutation.mutate({ request: { id: policyToDelete.policy.id } });
@@ -901,34 +931,19 @@ function PolicyCenterContent() {
- {
- // Both prompt and standard policies now edit on their
- // dedicated detail page.
- routes.policyCenter.detail.goTo(row.policy.id);
- }}
- >
- Edit
-
- {row.kind === "risk" && (
+ {policyActions(row).map((action) => (
{
- setTimeout(() => setRunPanelPolicy(row.policy), 0);
- }}
+ key={action.label}
+ className={cn(
+ "cursor-pointer",
+ action.destructive &&
+ "text-destructive focus:text-destructive",
+ )}
+ onSelect={() => action.onClick()}
>
- View Progress
+ {action.label}
- )}
- {
- setTimeout(() => handleDelete(row), 0);
- }}
- >
- Delete
-
+ ))}
@@ -965,6 +980,11 @@ function PolicyCenterContent() {
// (eval workbench for prompt, on-page editor for standard).
routes.policyCenter.detail.goTo(row.policy.id)
}
+ renderRow={(row, rowElement) => (
+
+ )}
/>
);
if (isLoading) {
diff --git a/client/dashboard/src/pages/sources/AnnotationToggle.tsx b/client/dashboard/src/pages/sources/AnnotationToggle.tsx
new file mode 100644
index 00000000000..f65f1facb55
--- /dev/null
+++ b/client/dashboard/src/pages/sources/AnnotationToggle.tsx
@@ -0,0 +1,33 @@
+import { Switch } from "@/components/ui/switch";
+import { useId } from "react";
+
+export function AnnotationToggle({
+ label,
+ description,
+ checked,
+ onCheckedChange,
+}: {
+ label: string;
+ description: string;
+ checked: boolean;
+ onCheckedChange: (value: boolean) => void;
+}): JSX.Element {
+ const descriptionId = useId();
+
+ return (
+
+ );
+}
diff --git a/client/dashboard/src/pages/sources/SourceToolsTab.tsx b/client/dashboard/src/pages/sources/SourceToolsTab.tsx
index 2273bc24999..c435ffc59d3 100644
--- a/client/dashboard/src/pages/sources/SourceToolsTab.tsx
+++ b/client/dashboard/src/pages/sources/SourceToolsTab.tsx
@@ -1,11 +1,13 @@
+import { TableRowContextMenu } from "@/components/table-row-context-menu";
import { ToolVariationBadge } from "@/components/tool-variation-badge";
+import { MoreActions } from "@/components/ui/more-actions";
import { SearchBar } from "@/components/ui/search-bar";
import { Type } from "@/components/ui/type";
import { ToolUpdateFields } from "@/hooks/useToolUpdate";
import type { Tool } from "@/lib/toolTypes";
import { Badge } from "@speakeasy-api/moonshine";
import { useState } from "react";
-import { SourceToolActions } from "./SourceToolActions";
+import { useSourceToolActions } from "./useSourceToolActions";
type HttpTool = Extract