mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
fix: disable unauthorized session controls
This commit is contained in:
@@ -31,11 +31,33 @@ type TestWebKitWindow = Window & {
|
||||
type MacosTitlebarControlsState = HTMLElement & {
|
||||
navCollapsed: boolean;
|
||||
historyOnly: boolean;
|
||||
newSessionDisabledReason?: string;
|
||||
onOpenPalette?: () => void;
|
||||
onOpenNewSession?: () => void;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
function nativeSessionContext(
|
||||
navigate: ReturnType<typeof vi.fn>,
|
||||
selectedId: string,
|
||||
options: { methods?: string[]; scopes?: string[] } = {},
|
||||
): ApplicationContext {
|
||||
return {
|
||||
navigate,
|
||||
agentSelection: { state: { selectedId } },
|
||||
gateway: {
|
||||
snapshot: {
|
||||
client: {},
|
||||
phase: "connected",
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: options.scopes ?? ["operator.write"] },
|
||||
features: { methods: options.methods ?? ["sessions.create"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAppHostTestGlobals();
|
||||
});
|
||||
@@ -114,10 +136,7 @@ describe("OpenClaw native shell", () => {
|
||||
value: { openPalette, togglePalette },
|
||||
});
|
||||
shell.runtime = {
|
||||
context: {
|
||||
navigate,
|
||||
agentSelection: { state: { selectedId: "agent/a" } },
|
||||
} as unknown as ApplicationContext,
|
||||
context: nativeSessionContext(navigate, "agent/a"),
|
||||
};
|
||||
shell.handleNativeOpenSearch();
|
||||
const toggleEvent = new CustomEvent("openclaw:native-toggle-search", { cancelable: true });
|
||||
@@ -156,6 +175,26 @@ describe("OpenClaw native shell", () => {
|
||||
controls.remove();
|
||||
});
|
||||
|
||||
it("disables the native titlebar new-session control with its access reason", async () => {
|
||||
const onOpenNewSession = vi.fn();
|
||||
const controls = document.createElement(
|
||||
"openclaw-macos-titlebar-controls",
|
||||
) as unknown as MacosTitlebarControlsState;
|
||||
controls.navCollapsed = true;
|
||||
controls.newSessionDisabledReason = "Operator write access is required.";
|
||||
controls.onOpenNewSession = onOpenNewSession;
|
||||
document.body.append(controls);
|
||||
await controls.updateComplete;
|
||||
|
||||
const button = controls.querySelector<HTMLButtonElement>(
|
||||
".macos-titlebar-controls__new-session",
|
||||
);
|
||||
expect(button?.disabled).toBe(true);
|
||||
button?.click();
|
||||
expect(onOpenNewSession).not.toHaveBeenCalled();
|
||||
controls.remove();
|
||||
});
|
||||
|
||||
it("retains a native new-session request until a context exists", () => {
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
|
||||
@@ -163,16 +202,30 @@ describe("OpenClaw native shell", () => {
|
||||
shell.handleNativeNewSession();
|
||||
|
||||
shell.runtime = {
|
||||
context: {
|
||||
navigate,
|
||||
agentSelection: { state: { selectedId: "main" } },
|
||||
} as unknown as ApplicationContext,
|
||||
context: nativeSessionContext(navigate, "main"),
|
||||
};
|
||||
shell.handleNativeNewSession();
|
||||
|
||||
expect(navigate).toHaveBeenCalledExactlyOnceWith("new-session", { search: "?agent=main" });
|
||||
});
|
||||
|
||||
it("does not start a native session without exact sessions.create access", () => {
|
||||
for (const options of [
|
||||
{ methods: ["sessions.list"], scopes: ["operator.write"] },
|
||||
{ methods: ["sessions.create"], scopes: ["operator.read"] },
|
||||
]) {
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
|
||||
shell.runtime = {
|
||||
context: nativeSessionContext(navigate, "main", options),
|
||||
};
|
||||
|
||||
shell.handleNativeNewSession();
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("navigates valid native Dashboard paths and acknowledges them", () => {
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { BoardFace } from "../lib/board/settings.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
|
||||
import { readSessionMethodAccess } from "../lib/session-method-access.ts";
|
||||
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
|
||||
import type { ShellRouteState } from "./app-host-route-state.ts";
|
||||
import type { ApplicationContext, ApplicationNavigationOptions } from "./context.ts";
|
||||
@@ -235,6 +236,14 @@ export class ShellChromeOwner {
|
||||
host.pendingNativeNewSession = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!readSessionMethodAccess(context.gateway.snapshot, {
|
||||
method: "sessions.create",
|
||||
params: {},
|
||||
}).allowed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
host.openNewSession(context.agentSelection.state.selectedId ?? "");
|
||||
};
|
||||
|
||||
|
||||
@@ -297,6 +297,9 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
.historyOnly=${settingsTakeover}
|
||||
.canGoBack=${host.nativeHistoryState.canGoBack}
|
||||
.canGoForward=${host.nativeHistoryState.canGoForward}
|
||||
.newSessionDisabledReason=${newSessionAccess.allowed
|
||||
? undefined
|
||||
: newSessionAccess.reason}
|
||||
.onToggleSidebar=${() => host.toggleNavigationSurface()}
|
||||
.onOpenPalette=${() => host.openPalette()}
|
||||
.onOpenNewSession=${() => host.handleNativeNewSession()}
|
||||
|
||||
@@ -56,6 +56,7 @@ type SessionCatalogGroupsParams = {
|
||||
onLoadMore: (catalogId: string) => void;
|
||||
onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void;
|
||||
newSessionDisabledReason?: string;
|
||||
sectionDragDisabledReason?: string;
|
||||
onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
|
||||
catalogOpenTarget: "viewer" | "terminal";
|
||||
terminalAvailable: boolean;
|
||||
@@ -159,12 +160,19 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
|
||||
<div
|
||||
class=${sectionClass}
|
||||
data-session-section=${sectionId}
|
||||
@dragover=${(event: DragEvent) => params.onSectionDragOver(event, sectionId)}
|
||||
@dragleave=${(event: DragEvent) => params.onSectionDragLeave(event, sectionId)}
|
||||
@drop=${(event: DragEvent) => params.onSectionDrop(event, sectionId)}
|
||||
@dragover=${params.sectionDragDisabledReason
|
||||
? nothing
|
||||
: (event: DragEvent) => params.onSectionDragOver(event, sectionId)}
|
||||
@dragleave=${params.sectionDragDisabledReason
|
||||
? nothing
|
||||
: (event: DragEvent) => params.onSectionDragLeave(event, sectionId)}
|
||||
@drop=${params.sectionDragDisabledReason
|
||||
? nothing
|
||||
: (event: DragEvent) => params.onSectionDrop(event, sectionId)}
|
||||
>
|
||||
${renderSidebarSessionSectionHeader({
|
||||
sectionId,
|
||||
disabledReason: params.sectionDragDisabledReason,
|
||||
onStartDrag: params.onStartSectionDrag,
|
||||
onFinishDrag: params.onFinishSectionDrag,
|
||||
content: html`
|
||||
|
||||
@@ -81,6 +81,10 @@ function renderSessionSection(params: {
|
||||
collapsed &&
|
||||
section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.Attention));
|
||||
const newSessionAccess = host.readNewSessionAccess();
|
||||
const groupWriteAccess = host.readSessionMutationAccess({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const sectionClass = [
|
||||
"sidebar-recent-sessions__group",
|
||||
`sidebar-recent-sessions__group--zone-${zone}`,
|
||||
@@ -101,12 +105,19 @@ function renderSessionSection(params: {
|
||||
<div
|
||||
class=${sectionClass}
|
||||
data-session-section=${section.id}
|
||||
@dragover=${(event: DragEvent) => host.sectionDragOver(event, section.id, group)}
|
||||
@dragleave=${(event: DragEvent) => host.sectionDragLeave(event, section.id, group)}
|
||||
@drop=${(event: DragEvent) => host.sectionDrop(event, section.id, group)}
|
||||
@dragover=${groupWriteAccess.allowed
|
||||
? (event: DragEvent) => host.sectionDragOver(event, section.id, group)
|
||||
: nothing}
|
||||
@dragleave=${groupWriteAccess.allowed
|
||||
? (event: DragEvent) => host.sectionDragLeave(event, section.id, group)
|
||||
: nothing}
|
||||
@drop=${groupWriteAccess.allowed
|
||||
? (event: DragEvent) => host.sectionDrop(event, section.id, group)
|
||||
: nothing}
|
||||
>
|
||||
${renderSidebarSessionSectionHeader({
|
||||
sectionId: section.id,
|
||||
disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason,
|
||||
onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId),
|
||||
onFinishDrag: () => host.finishSidebarSectionDrag(),
|
||||
onContextMenu: group
|
||||
@@ -305,6 +316,10 @@ function renderSessionCatalog(params: {
|
||||
}) {
|
||||
const { host, snapshot, catalog, renderer } = params;
|
||||
const newSessionAccess = host.readNewSessionAccess();
|
||||
const groupWriteAccess = host.readSessionMutationAccess({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
return html`
|
||||
${renderer({
|
||||
catalogs: [catalog],
|
||||
@@ -344,6 +359,7 @@ function renderSessionCatalog(params: {
|
||||
onLoadMore: (catalogId) => void host.sessionData.loadMoreSessionCatalog(catalogId),
|
||||
onOpenNewSession: (agentId, target) => host.requestOpenNewSession(agentId, target),
|
||||
newSessionDisabledReason: newSessionAccess.allowed ? undefined : newSessionAccess.reason,
|
||||
sectionDragDisabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason,
|
||||
onNavigate: host.onNavigate,
|
||||
catalogOpenTarget: snapshot.catalogOpenTarget,
|
||||
terminalAvailable: snapshot.terminalAvailable,
|
||||
|
||||
@@ -217,13 +217,19 @@ export function renderRecentSession(params: {
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const childrenExpanded = host.isSessionChildrenExpanded(session);
|
||||
const groupWriteAccess = host.readSessionMutationAccess({
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
const rowDraggable = !session.isChild && groupWriteAccess.allowed;
|
||||
const row = html`
|
||||
<div
|
||||
class=${rowClass}
|
||||
data-session-key=${session.key}
|
||||
role="listitem"
|
||||
draggable=${session.isChild ? "false" : "true"}
|
||||
@dragstart=${session.isChild
|
||||
draggable=${rowDraggable ? "true" : "false"}
|
||||
title=${!session.isChild && !groupWriteAccess.allowed ? groupWriteAccess.reason : nothing}
|
||||
@dragstart=${!rowDraggable
|
||||
? nothing
|
||||
: (event: DragEvent) => {
|
||||
if (event.dataTransfer) {
|
||||
@@ -231,7 +237,7 @@ export function renderRecentSession(params: {
|
||||
host.startSessionDrag(session);
|
||||
}
|
||||
}}
|
||||
@dragend=${session.isChild
|
||||
@dragend=${!rowDraggable
|
||||
? nothing
|
||||
: () => {
|
||||
host.finishSessionDrag();
|
||||
|
||||
@@ -4,14 +4,18 @@ import { writeSidebarSectionDragData } from "../lib/sessions/drag.ts";
|
||||
export function renderSidebarSessionSectionHeader(params: {
|
||||
sectionId: string;
|
||||
content: TemplateResult;
|
||||
disabledReason?: string;
|
||||
onStartDrag: (sectionId: string) => void;
|
||||
onFinishDrag: () => void;
|
||||
onContextMenu?: (event: MouseEvent) => void;
|
||||
}) {
|
||||
return html`
|
||||
<div
|
||||
class="sidebar-recent-sessions__head sidebar-recent-sessions__head--draggable"
|
||||
draggable="true"
|
||||
class="sidebar-recent-sessions__head ${params.disabledReason
|
||||
? ""
|
||||
: "sidebar-recent-sessions__head--draggable"}"
|
||||
draggable=${params.disabledReason ? "false" : "true"}
|
||||
title=${params.disabledReason ?? nothing}
|
||||
@mousedown=${(event: MouseEvent) => {
|
||||
const header = event.currentTarget as HTMLElement;
|
||||
header.toggleAttribute(
|
||||
@@ -23,6 +27,10 @@ export function renderSidebarSessionSectionHeader(params: {
|
||||
(event.currentTarget as HTMLElement).removeAttribute("data-section-drag-blocked");
|
||||
}}
|
||||
@dragstart=${(event: DragEvent) => {
|
||||
if (params.disabledReason) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const header = event.currentTarget as HTMLElement;
|
||||
const startedFromButton =
|
||||
Boolean((event.target as HTMLElement).closest("button")) ||
|
||||
|
||||
@@ -11,6 +11,7 @@ class MacosTitlebarControls extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) historyOnly = false;
|
||||
@property({ attribute: false }) canGoBack = false;
|
||||
@property({ attribute: false }) canGoForward = false;
|
||||
@property({ attribute: false }) newSessionDisabledReason?: string;
|
||||
@property({ attribute: false }) onToggleSidebar?: () => void;
|
||||
@property({ attribute: false }) onOpenPalette?: () => void;
|
||||
@property({ attribute: false }) onOpenNewSession?: () => void;
|
||||
@@ -54,9 +55,11 @@ class MacosTitlebarControls extends OpenClawLightDomContentsElement {
|
||||
${this.navCollapsed
|
||||
? this.renderButton({
|
||||
// While the sidebar rail is collapsed, this mirrors the native
|
||||
// ⌘N item and stays deliberately free of connection state.
|
||||
// new-session item and its current Gateway authorization.
|
||||
label: t("chat.runControls.newSession"),
|
||||
tooltip: this.newSessionDisabledReason,
|
||||
icon: icons.plus,
|
||||
disabled: Boolean(this.newSessionDisabledReason),
|
||||
onClick: this.onOpenNewSession,
|
||||
className: "macos-titlebar-controls__new-session",
|
||||
})
|
||||
|
||||
@@ -549,6 +549,49 @@ describe("sessions view", () => {
|
||||
expect(onAssignCategory).toHaveBeenCalledWith("agent:main:main", "Research");
|
||||
});
|
||||
|
||||
it("disables category assignment controls without group write access", async () => {
|
||||
const container = document.createElement("div");
|
||||
const onAssignCategory = vi.fn();
|
||||
const reason = "Operator write access is required.";
|
||||
render(
|
||||
renderSessions({
|
||||
...buildProps(
|
||||
buildMultiResult([
|
||||
{ key: "agent:main:main", kind: "direct", updatedAt: 1, category: "Research" },
|
||||
]),
|
||||
),
|
||||
groupBy: "category",
|
||||
knownCategories: ["Research"],
|
||||
groupWriteDisabledReason: reason,
|
||||
onAssignCategory,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const select = container.querySelector<HTMLSelectElement>(
|
||||
'select[aria-label="Move thread to a group"]',
|
||||
);
|
||||
expect(select?.disabled).toBe(true);
|
||||
expect(select?.title).toBe(reason);
|
||||
if (select) {
|
||||
select.value = "";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
const headerRow = container.querySelector(".session-group-row");
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["application/x-openclaw-session-key"],
|
||||
getData: () => "agent:main:main",
|
||||
},
|
||||
});
|
||||
headerRow?.dispatchEvent(drop);
|
||||
|
||||
expect(onAssignCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the session menu from the kebab and row context menu", async () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSessionMenu = vi.fn();
|
||||
@@ -1236,6 +1279,7 @@ describe("sessions view", () => {
|
||||
}),
|
||||
),
|
||||
expandedSessionKey: "agent:main:main",
|
||||
patchAdminDisabledReason: "Operator admin access is required.",
|
||||
checkpointItemsByKey: {
|
||||
"agent:main:main": [
|
||||
{
|
||||
@@ -1294,6 +1338,12 @@ describe("sessions view", () => {
|
||||
(label) => label.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["Label", "Thinking", "Fast", "Verbose", "Reasoning"]);
|
||||
for (const select of overridesSection?.querySelectorAll<HTMLSelectElement>(
|
||||
".session-override-field select",
|
||||
) ?? []) {
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.title).toBe("Operator admin access is required.");
|
||||
}
|
||||
|
||||
expect(
|
||||
compactionSection?.querySelector(".session-details-panel__eyebrow")?.textContent?.trim(),
|
||||
|
||||
@@ -813,7 +813,7 @@ function setDropTargetActive(event: DragEvent, active: boolean) {
|
||||
}
|
||||
|
||||
function categoryDropHandlers(props: SessionsProps, category: string | null) {
|
||||
if (props.groupBy !== "category") {
|
||||
if (props.groupBy !== "category" || props.groupWriteDisabledReason) {
|
||||
return { dragover: nothing, dragleave: nothing, drop: nothing } as const;
|
||||
}
|
||||
const carriesSessionKey = (event: DragEvent) =>
|
||||
@@ -878,10 +878,14 @@ function renderCategoryCell(row: GatewaySessionRow, props: SessionsProps) {
|
||||
return html`
|
||||
<td>
|
||||
<select
|
||||
?disabled=${props.loading}
|
||||
?disabled=${props.loading || Boolean(props.groupWriteDisabledReason)}
|
||||
title=${props.groupWriteDisabledReason ?? nothing}
|
||||
aria-label=${t("sessionsView.moveToGroup")}
|
||||
class="session-group-select"
|
||||
@change=${(e: Event) => {
|
||||
if (props.groupWriteDisabledReason) {
|
||||
return;
|
||||
}
|
||||
const select = e.target as HTMLSelectElement;
|
||||
if (select.value === NEW_GROUP_OPTION) {
|
||||
// The page prompts for a name and patches; restore until the refresh lands.
|
||||
@@ -945,6 +949,7 @@ function renderFilterToggle(params: {
|
||||
function renderOverrideSelect(params: {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
options: readonly { value: string; label: string }[];
|
||||
current: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -955,6 +960,7 @@ function renderOverrideSelect(params: {
|
||||
<select
|
||||
class="settings-select"
|
||||
?disabled=${params.disabled}
|
||||
title=${params.disabledReason ?? nothing}
|
||||
@change=${(e: Event) => params.onChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${params.options.map(
|
||||
@@ -1679,6 +1685,7 @@ function renderSessionDetailsRow(params: {
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.thinking"),
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
disabledReason: props.patchAdminDisabledReason,
|
||||
options: thinkLevels,
|
||||
current: thinking,
|
||||
onChange: (value) => props.onPatch(row.key, { thinkingLevel: value || null }),
|
||||
@@ -1686,6 +1693,7 @@ function renderSessionDetailsRow(params: {
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.fast"),
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
disabledReason: props.patchAdminDisabledReason,
|
||||
options: fastLevels,
|
||||
current: fastMode,
|
||||
onChange: (value) =>
|
||||
@@ -1696,6 +1704,7 @@ function renderSessionDetailsRow(params: {
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.verbose"),
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
disabledReason: props.patchAdminDisabledReason,
|
||||
options: verboseLevels,
|
||||
current: verbose,
|
||||
onChange: (value) => props.onPatch(row.key, { verboseLevel: value || null }),
|
||||
@@ -1703,6 +1712,7 @@ function renderSessionDetailsRow(params: {
|
||||
${renderOverrideSelect({
|
||||
label: t("sessionsView.reasoning"),
|
||||
disabled: props.loading || Boolean(props.patchAdminDisabledReason),
|
||||
disabledReason: props.patchAdminDisabledReason,
|
||||
options: reasoningLevels.map((level) => ({
|
||||
value: level,
|
||||
label: level || t("sessionsView.inherit"),
|
||||
|
||||
@@ -38,16 +38,22 @@ describe("AppSidebar section reordering", () => {
|
||||
async function mountWithGroups(
|
||||
groups: string[],
|
||||
sectionOrder: string[] = [],
|
||||
options: { withCatalog?: boolean } = {},
|
||||
options: { withCatalog?: boolean; scopes?: string[] } = {},
|
||||
) {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValue(catalogPage([{ threadId: "thread-codex", name: "Codex thread" }]));
|
||||
const gateway = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
|
||||
if (options.withCatalog) {
|
||||
if (options.withCatalog || options.scopes) {
|
||||
gateway.publish({
|
||||
hello: {
|
||||
features: { methods: ["sessions.catalog.list", "sessions.groups.put"] },
|
||||
...(options.scopes ? { auth: { role: "operator", scopes: options.scopes } } : {}),
|
||||
features: {
|
||||
methods: [
|
||||
...(options.withCatalog ? ["sessions.catalog.list"] : []),
|
||||
"sessions.groups.put",
|
||||
],
|
||||
},
|
||||
} as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
}
|
||||
@@ -129,6 +135,30 @@ describe("AppSidebar section reordering", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("disables section and row dragging without group write access", async () => {
|
||||
const { sidebar, harness } = await mountWithGroups(["Alpha"], [], {
|
||||
scopes: ["operator.read"],
|
||||
});
|
||||
const header = groupHeader(sidebar, "category:Alpha");
|
||||
const row = sidebar.querySelector('[data-session-key="agent:main:plain"]');
|
||||
|
||||
expect(header.getAttribute("draggable")).toBe("false");
|
||||
expect(header.getAttribute("title")).toBeTruthy();
|
||||
expect(row?.getAttribute("draggable")).toBe("false");
|
||||
expect(row?.getAttribute("title")).toBeTruthy();
|
||||
|
||||
const dataTransfer = createDataTransferStub();
|
||||
dispatchDragEvent(header, "dragstart", dataTransfer);
|
||||
const threadsSection = sidebar.querySelector('[data-session-section="ungrouped"]');
|
||||
if (!threadsSection) {
|
||||
throw new Error("expected Threads section");
|
||||
}
|
||||
dispatchDragEvent(threadsSection, "drop", dataTransfer);
|
||||
|
||||
expect(dataTransfer.types).toEqual([]);
|
||||
expect(harness.groupsPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start a section drag from a header action button", async () => {
|
||||
const { sidebar } = await mountWithGroups([]);
|
||||
const dataTransfer = createDataTransferStub();
|
||||
|
||||
Reference in New Issue
Block a user