From ec0ac474c4e44305755cc0e0e10533aaae01486f Mon Sep 17 00:00:00 2001 From: hataa <79907651+hata33@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:40:54 +0800 Subject: [PATCH] feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063) (#5294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(authz): gate thread-delete and run-cancel UI on effective permissions (Phase 4, #4063) Consume the effective route permissions surfaced by #5228 so the UI hides actions the caller's role cannot perform: - threads:delete hides the sidebar thread-row Delete menu item and the sidecar panel delete button (every useDeleteThread consumer) - runs:cancel disables the composer stop affordance; all three stop entry points converge on one check inside handleStopStreaming hasPermission treats an absent/null/unresolved permission list as permissive, so a mixed old-backend/new-frontend deploy never hides actions the caller can still perform. The Gateway @require_permission guards remain the single enforcement point. * fix(authz): review follow-ups for stop gating (comment accuracy, a11y, tests) - Correct the defense-in-depth comment: the submit-button click is the only live entry into handleStopStreaming (handleSubmit returns early with the pleaseWaitStreaming toast while streaming, so the kind==="stop" branch is unreachable); the handler gate stays as future-proofing. - Explain the disabled stop affordance with aria-label + title (Radix tooltips don't fire on disabled buttons), with en-US/zh-CN strings. - Add the composer stop-gating DOM tests (disabled + onStop never fires + permissive default) and the sidebar delete-menu gating tests, so all gated surfaces carry wiring tests. * fix(authz): stop conditional aria-label from stripping the submit name The stop-gating follow-up (1612855b) explained the disabled stop affordance with aria-label/title but passed explicitly-undefined values in the non-denied case. PromptInputSubmit declares its default aria-label="Submit" before {...props}, so the undefined key landed in the spread and clobbered the default: React omits the attribute entirely and the submit control lost its accessible name in every state, which broke the sidecar e2e layout helper (it locates the button by its "Submit" label). Spread the attributes conditionally so they only attach when stopDenied, and lock the invariant with a DOM test asserting the base "Submit" name survives when stop is not denied (mutation-verified: reverting the conditional spread turns the new test red). * test(authz): drop unused rerenderWith helper, guard accessible name by role query Address the review nit on the stop-gating DOM tests: the rerenderWith helper was never called, and a second render() would append a composer instead of updating the first one anyway — drop it (the sidecar-delete-gating tests already demonstrate the correct rerender pattern if a granted->denied flip test is ever needed). Also resolve the accessible-name regression guard through getByRole("button", { name: "Submit" }) so it fails exactly the way e2e and assistive tech consume the control (mutation-verified: the explicitly-undefined aria-label form turns it red). --------- Co-authored-by: Willem Jiang --- .../[agent_name]/chats/[thread_id]/page.tsx | 5 + .../components/workspace/chats/chat-page.tsx | 5 + .../src/components/workspace/input-box.tsx | 35 ++++- .../components/workspace/recent-chat-list.tsx | 18 ++- .../workspace/sidecar/sidecar-panel.tsx | 6 +- frontend/src/core/auth/permissions.ts | 30 ++++ frontend/src/core/auth/types.ts | 5 + frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 1 + .../input-box-stop-gating.dom.test.tsx | 135 ++++++++++++++++ .../sidecar-delete-gating.dom.test.tsx | 148 ++++++++++++++++++ ...ad-sidebar-item-delete-gating.dom.test.tsx | 98 ++++++++++++ .../tests/unit/core/auth/permissions.test.ts | 70 +++++++++ 14 files changed, 551 insertions(+), 8 deletions(-) create mode 100644 frontend/src/core/auth/permissions.ts create mode 100644 frontend/tests/unit/components/workspace/input-box-stop-gating.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/sidecar-delete-gating.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx create mode 100644 frontend/tests/unit/core/auth/permissions.test.ts diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 05410c9de..dd00aa769 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -36,6 +36,8 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato import { Tooltip } from "@/components/workspace/tooltip"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; import { useAgent } from "@/core/agents"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useBrowserControlEnabled } from "@/core/features"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -63,6 +65,8 @@ import { cn } from "@/lib/utils"; export default function AgentChatPage() { const { t } = useI18n(); + const { user } = useAuth(); + const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL); const router = useRouter(); const { agent_name } = useParams<{ @@ -457,6 +461,7 @@ export default function AgentChatPage() { onGoalChange={setLocalGoal} onSubmit={handleSubmit} onStop={handleStop} + canStopStreaming={canStopStreaming} /> {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index 2b2a16a86..fd6b1895d 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -36,6 +36,8 @@ import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { useActiveGoal } from "@/components/workspace/use-active-goal"; import { Welcome } from "@/components/workspace/welcome"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useBrowserControlEnabled } from "@/core/features"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -71,6 +73,8 @@ import { useThreadChat } from "./use-thread-chat"; export default function ChatPage() { const { t } = useI18n(); + const { user } = useAuth(); + const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL); const router = useRouter(); const searchParams = useSearchParams(); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -549,6 +553,7 @@ export default function ChatPage() { onPrepareThread={ensureProjectThread} onSubmit={handleSubmit} onStop={handleStop} + canStopStreaming={canStopStreaming} /> ) : (
, "onSubmit"> & { assistantId?: string | null; @@ -356,6 +357,13 @@ export function InputBox({ options?: InputBoxSubmitOptions, ) => void | Promise; onStop?: () => void; + /** + * Whether the caller's role holds `runs:cancel` (RFC #4063 Phase 4). + * Defaults to true so callers that don't resolve permissions (pre-Phase-4 + * backends, storybook) keep today's behavior; the Gateway route guard + * stays the enforcement point. + */ + canStopStreaming?: boolean; }) { const { locale, t } = useI18n(); const queryClient = useQueryClient(); @@ -1163,6 +1171,14 @@ export function InputBox({ ); const handleStopStreaming = useCallback(() => { + // Roles denied runs:cancel must not interrupt the in-progress turn — + // the Gateway would 403 the cancel anyway. The submit-button click is + // the only live entry point today (handleSubmit returns early with the + // pleaseWaitStreaming toast while streaming), but gate in the handler + // as defense-in-depth so any future stop path is covered too. + if (!canStopStreaming) { + return; + } // Mark the in-progress turn as user-interrupted so the next // streaming->ready transition does not suggest follow-ups for it. stoppedByUserRef.current = true; @@ -1170,7 +1186,7 @@ export function InputBox({ setFollowupsHidden(true); setFollowupsLoading(false); onStop?.(); - }, [onStop]); + }, [canStopStreaming, onStop]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -1367,6 +1383,9 @@ export function InputBox({ const isComposerDisabled = disabled === true; const isMockThread = isMock === true; const composerLocked = isComposerDisabled || polishingInput; + // A denied runs:cancel role sees a disabled stop affordance, not a removed + // one — the composer must still show that a turn is in flight. + const stopDenied = status === "streaming" && !canStopStreaming; const inputPolishUndoAvailable = !polishingInput && inputPolishUndo !== null && @@ -2739,9 +2758,21 @@ export function InputBox({ { if (status === "streaming") { e.preventDefault(); diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 15af7cf3e..8bec3278b 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -47,6 +47,8 @@ import { } from "@/components/ui/sidebar"; import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat"; import { getAPIClient } from "@/core/api"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { useProjects } from "@/core/projects"; @@ -98,6 +100,8 @@ export function ThreadSidebarItem({ recentThreadId?: string | undefined; }) { const { t } = useI18n(); + const { user } = useAuth(); + const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE); const router = useRouter(); const pathname = usePathname(); const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } = @@ -372,11 +376,15 @@ export function ThreadSidebarItem({ onNewProject={() => setNewProjectDialogOpen(true)} onMoveProject={handleMoveProject} /> - - - - {t.common.delete} - + {canDeleteThreads && ( + <> + + + + {t.common.delete} + + + )} )} diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx index af271fd15..4dcb85068 100644 --- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -48,6 +48,8 @@ import { DropdownMenuGroup, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { useI18n } from "@/core/i18n/hooks"; import { buildHumanInputResponseText, @@ -146,6 +148,8 @@ function promptMessageFiles(message: PromptInputMessage) { export function SidecarPanel({ className }: { className?: string }) { const { t } = useI18n(); + const { user } = useAuth(); + const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE); const sidecar = useSidecar(); const { thread: parentThread } = useParentThread(); const [localSettings] = useLocalSettings(); @@ -539,7 +543,7 @@ export function SidecarPanel({ className }: { className?: string }) { : t.sidecar.noContext}
- {hasSidecarThread && ( + {hasSidecarThread && canDeleteThreads && (