fix: honor operator scopes in session sharing

This commit is contained in:
Shakker
2026-08-02 05:53:54 +01:00
parent 4e484da052
commit d504df0367
10 changed files with 509 additions and 53 deletions
+1
View File
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI operator session permissions:** honor Gateway-advertised operator scopes for new-thread creation, thread management, checkpoints, and sharing controls while preserving read-only navigation and legacy Gateway compatibility. Fixes #117786. Thanks @shakkernerd.
- **Control UI archived session deletion:** send archive-gated delete requests from Sessions-page row and mixed-selection actions so write-scoped operators can remove archived threads while active-session deletion remains admin-only. Thanks @shakkernerd.
- **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd.
- **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo.
+71 -1
View File
@@ -198,6 +198,7 @@ describeControlUiE2e("Control UI session ownership", () => {
page = currentPage;
await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
featureMethods: ["chat.metadata", "chat.startup", "sessions.create"],
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) },
});
@@ -279,6 +280,7 @@ describeControlUiE2e("Control UI session ownership", () => {
page = currentPage;
const gateway = await installMockGateway(currentPage, {
allowedSessionVisibilities: ["shared", "draft"],
featureMethods: ["chat.metadata", "chat.startup", "sessions.create"],
hasMultipleSessionSharingIdentities: true,
methodResponses: {
"sessions.list": sessionsList(["profile-ada", "profile-bob"]),
@@ -324,7 +326,15 @@ describeControlUiE2e("Control UI session ownership", () => {
Object.assign(ownerSession, { sharingRole: "owner" });
const gateway = await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
featureMethods: ["chat.metadata", "chat.startup", "session.visibility.set"],
featureMethods: [
"chat.metadata",
"chat.startup",
"session.visibility.set",
"session.members.list",
"session.members.add",
"session.members.remove",
],
operatorScopes: ["operator.write"],
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: {
"sessions.list": sessions,
@@ -359,6 +369,66 @@ describeControlUiE2e("Control UI session ownership", () => {
expect(await gateway.getRequests("session.visibility.set")).toHaveLength(1);
});
it("lets a read-scoped owner inspect sharing but blocks mutations", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const sessions = draftSessionsList();
const ownerSession = sessions.sessions[0];
if (!ownerSession) {
throw new Error("expected owner draft fixture");
}
Object.assign(ownerSession, { sharingRole: "owner" });
const gateway = await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
featureMethods: [
"chat.metadata",
"chat.startup",
"session.visibility.set",
"session.members.list",
"session.members.add",
"session.members.remove",
],
operatorScopes: ["operator.read"],
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: {
"sessions.list": sessions,
"session.members.list": {
sessionKey: "agent:main:ada",
members: [],
identities: [{ type: "human", id: "profile-bob", label: "Bob" }],
role: "owner",
allowedVisibilities: ["shared", "draft"],
},
},
});
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await currentPage.getByLabel("Thread sharing").click();
await gateway.waitForRequest("session.members.list");
const dropdown = currentPage.locator(".chat-pane__sharing-menu");
const publish = dropdown.locator('wa-dropdown-item[value="visibility:shared"]');
await publish.waitFor();
expect(await publish.getAttribute("disabled")).not.toBeNull();
await dropdown.evaluate((element) => {
element.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: "visibility:shared" } },
}),
);
element.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: "member:profile-bob" } },
}),
);
});
expect(await gateway.getRequests("session.visibility.set")).toHaveLength(0);
expect(await gateway.getRequests("session.members.add")).toHaveLength(0);
});
it("clears a selected draft mode when sharing policy becomes unavailable", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
+1
View File
@@ -667,6 +667,7 @@ export const en: TranslationMap = {
automationPrefix: "Automation:",
actionRequiresConnection: "Connect to the Gateway to change threads.",
actionUnavailable: "This Gateway does not support this thread action.",
actionRequiresRead: "This action requires operator.read access.",
actionRequiresWrite: "This action requires operator.write access.",
actionRequiresAdmin: "This action requires operator.admin access.",
deletePreservedWorktrees:
+24
View File
@@ -52,6 +52,30 @@ describe("readSessionMethodAccess", () => {
).toBe(true);
});
it("allows read, write, and admin scopes to satisfy read-scoped actions", () => {
for (const scope of ["operator.read", "operator.write", "operator.admin"]) {
expect(
readSessionMethodAccess(snapshot({ methods: ["session.members.list"], scopes: [scope] }), {
method: "session.members.list",
requiredScope: "operator.read",
}).allowed,
).toBe(true);
}
});
it("rejects a read-scoped action without a compatible operator scope", () => {
expect(
readSessionMethodAccess(
snapshot({ methods: ["session.members.list"], scopes: ["operator.approvals"] }),
{ method: "session.members.list", requiredScope: "operator.read" },
),
).toMatchObject({
allowed: false,
cause: "missing-scope",
requiredScope: "operator.read",
});
});
it("preserves legacy snapshots without advertised auth scopes", () => {
expect(
readSessionMethodAccess(snapshot({ includeAuth: false }), {
+9 -5
View File
@@ -7,11 +7,13 @@ import type { ApplicationGatewaySnapshot } from "../app/gateway.ts";
import { t } from "../i18n/index.ts";
import { isGatewayMethodAdvertised } from "./gateway-methods.ts";
export type SessionMethodOperatorScope = "operator.read" | SessionMutationOperatorScope;
export type SessionMethodAccess =
| { allowed: true; requiredScope: SessionMutationOperatorScope }
| { allowed: true; requiredScope: SessionMethodOperatorScope }
| {
allowed: false;
requiredScope: SessionMutationOperatorScope;
requiredScope: SessionMethodOperatorScope;
reason: string;
cause: "disconnected" | "method-unavailable" | "missing-scope";
};
@@ -19,12 +21,12 @@ export type SessionMethodAccess =
type SessionMethodAccessRequest = {
method: string;
params?: unknown;
requiredScope?: SessionMutationOperatorScope;
requiredScope?: SessionMethodOperatorScope;
};
function sessionMethodAccessReason(
cause: Exclude<SessionMethodAccess, { allowed: true }>["cause"],
requiredScope: SessionMutationOperatorScope,
requiredScope: SessionMethodOperatorScope,
): string {
if (cause === "disconnected") {
return t("sessionsView.actionRequiresConnection");
@@ -35,7 +37,9 @@ function sessionMethodAccessReason(
return t(
requiredScope === "operator.admin"
? "sessionsView.actionRequiresAdmin"
: "sessionsView.actionRequiresWrite",
: requiredScope === "operator.write"
? "sessionsView.actionRequiresWrite"
: "sessionsView.actionRequiresRead",
);
}
+47 -14
View File
@@ -17,6 +17,7 @@ import { hasSessionPresenceViewers } from "../../components/viewer-facepile.ts";
import { t } from "../../i18n/index.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
import { renderBoardDockMenu, renderBoardFaceToggle } from "./board-session-surface.ts";
import { ChatPaneContext } from "./chat-pane-context.ts";
@@ -100,6 +101,29 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
: branchSwitchWorking
? t("chat.sessionHeader.branchSwitchUnavailable")
: null;
const sharingSnapshot = this.context.gateway.snapshot;
const sharingMethodsSupported = [
"session.visibility.set",
"session.members.list",
"session.members.add",
"session.members.remove",
].some((method) => isGatewayMethodAdvertised(sharingSnapshot, method) !== false);
const sharingReadAccess = readSessionMethodAccess(sharingSnapshot, {
method: "session.members.list",
requiredScope: "operator.read",
});
const sharingVisibilityAccess = readSessionMethodAccess(sharingSnapshot, {
method: "session.visibility.set",
requiredScope: "operator.write",
});
const sharingMemberAddAccess = readSessionMethodAccess(sharingSnapshot, {
method: "session.members.add",
requiredScope: "operator.write",
});
const sharingMemberRemoveAccess = readSessionMethodAccess(sharingSnapshot, {
method: "session.members.remove",
requiredScope: "operator.write",
});
return renderChatPaneHeader({
paneId: this.paneId,
narrow: this.narrow,
@@ -156,20 +180,29 @@ export abstract class ChatPaneHeader extends ChatPaneContext {
this.syncChatSidebarForDock(face === "dashboard" ? board.dock : "hidden");
this.persistBoardSessionView({ face });
}),
sharingControl:
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.visibility.set") === true
? renderChatSessionSharing({
session: row,
state: row
? this.sessionSharingStates.get(this.sessionSharingCacheKey(row.key))
: undefined,
onOpen: () => row && void this.loadSessionSharing(row),
onVisibilityChange: (visibility) =>
row && void this.setSessionVisibility(row, visibility),
onMemberChange: (identityId, member) =>
row && void this.setSessionMember(row, identityId, member),
})
: nothing,
sharingControl: sharingMethodsSupported
? renderChatSessionSharing({
session: row,
state: row
? this.sessionSharingStates.get(this.sessionSharingCacheKey(row.key))
: undefined,
openDisabledReason: sharingReadAccess.allowed ? undefined : sharingReadAccess.reason,
visibilityDisabledReason: sharingVisibilityAccess.allowed
? undefined
: sharingVisibilityAccess.reason,
memberAddDisabledReason: sharingMemberAddAccess.allowed
? undefined
: sharingMemberAddAccess.reason,
memberRemoveDisabledReason: sharingMemberRemoveAccess.allowed
? undefined
: sharingMemberRemoveAccess.reason,
onOpen: () => row && void this.loadSessionSharing(row),
onVisibilityChange: (visibility) =>
row && void this.setSessionVisibility(row, visibility),
onMemberChange: (identityId, member) =>
row && void this.setSessionMember(row, identityId, member),
})
: nothing,
boardDockAction: renderBoardDockMenu(
board.hasBoard && !board.activeTabReadOnly && board.provider.canMutate,
board.face,
+165 -7
View File
@@ -18,12 +18,49 @@ import type { ChatPageHost } from "./chat-state-host.ts";
import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
type SharingPane = TestChatPane & {
loadSessionSharing: (row: GatewaySessionRow, force?: boolean) => Promise<void>;
sessionSharingCacheKey: (sessionKey: string) => string;
sessionSharingStates: Map<string, ChatSessionSharingState>;
setSessionMember: (row: GatewaySessionRow, identityId: string, member: boolean) => Promise<void>;
setSessionVisibility: (row: GatewaySessionRow, visibility: SessionVisibility) => Promise<void>;
};
const SHARING_METHODS = [
"session.visibility.set",
"session.members.list",
"session.members.add",
"session.members.remove",
];
function setSharingAuthorization(
pane: SharingPane,
params: {
methods?: string[];
phase?: "connected" | "reconnecting";
scopes?: string[];
} = {},
): void {
const snapshot = pane.context.gateway.snapshot;
snapshot.phase = params.phase ?? "connected";
snapshot.hello = {
...snapshot.hello,
auth: {
role: "operator",
scopes: params.scopes ?? ["operator.admin"],
},
features: {
...snapshot.hello?.features,
methods: params.methods ?? SHARING_METHODS,
},
} as typeof snapshot.hello;
}
function createSharingTestChatPane(params: Parameters<typeof createTestChatPane>[0]) {
const result = createTestChatPane(params);
setSharingAuthorization(result.pane as SharingPane);
return result;
}
type Deferred<T> = {
promise: Promise<T>;
reject: (error: unknown) => void;
@@ -68,6 +105,7 @@ function replaceConnection(
): void {
pane.connectionGeneration += 1;
pane.context = createSessionContext(client, sessions);
setSharingAuthorization(pane);
pane.state = state;
state.client = client;
state.connected = true;
@@ -108,6 +146,126 @@ const mutations = [
},
] as const;
describe("chat pane sharing authorization", () => {
it("allows read-scoped owners to load sharing data but not mutate it", async () => {
const row = sessionRow();
const request = vi.fn(async (method: string) => {
if (method === "session.members.list") {
return sharingResult(row);
}
throw new Error(`unexpected request: ${method}`);
});
const sessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
const pane = testPane as SharingPane;
setSharingAuthorization(pane, { scopes: ["operator.read"] });
await pane.loadSessionSharing(row);
await pane.setSessionVisibility(row, "shared");
await pane.setSessionMember(row, "identity-alice", true);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"session.members.list",
expect.objectContaining({ sessionKey: row.key }),
);
expect(sessions.refreshReplacement).not.toHaveBeenCalled();
});
it("allows write and admin scoped owners to mutate sharing", async () => {
for (const scope of ["operator.write", "operator.admin"]) {
const row = sessionRow();
const request = vi.fn(async (method: string) => {
if (method === "session.members.list") {
return sharingResult(row);
}
return {};
});
const sessions = {
refreshReplacement: vi.fn(async () => undefined),
} as unknown as SessionCapability;
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
const pane = testPane as SharingPane;
setSharingAuthorization(pane, { scopes: [scope] });
await pane.setSessionVisibility(row, "shared");
await pane.setSessionMember(row, "identity-alice", true);
expect(request).toHaveBeenCalledWith(
"session.visibility.set",
expect.objectContaining({ sessionKey: row.key }),
);
expect(request).toHaveBeenCalledWith(
"session.members.add",
expect.objectContaining({ sessionKey: row.key }),
);
}
});
it("refuses sharing requests for non-managers and disconnected snapshots", async () => {
for (const authorization of [
{ row: { ...sessionRow(), sharingRole: "member" as const } },
{ row: sessionRow(), phase: "reconnecting" as const },
]) {
const request = vi.fn();
const sessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
const pane = testPane as SharingPane;
setSharingAuthorization(pane, {
phase: authorization.phase,
scopes: ["operator.admin"],
});
await pane.loadSessionSharing(authorization.row);
await pane.setSessionVisibility(authorization.row, "shared");
await pane.setSessionMember(authorization.row, "identity-alice", true);
expect(request).not.toHaveBeenCalled();
expect(sessions.refreshReplacement).not.toHaveBeenCalled();
}
});
it("refuses explicitly unadvertised sharing methods", async () => {
const row = sessionRow();
const request = vi.fn(async () => sharingResult(row));
const sessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
const pane = testPane as SharingPane;
setSharingAuthorization(pane, {
methods: ["session.members.list"],
scopes: ["operator.admin"],
});
await pane.loadSessionSharing(row);
await pane.setSessionVisibility(row, "shared");
await pane.setSessionMember(row, "identity-alice", true);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"session.members.list",
expect.objectContaining({ sessionKey: row.key }),
);
});
});
describe.each(mutations)("chat pane $name mutation connection ownership", (mutation) => {
it.each(["resolve", "reject"] as const)(
"drops a stale mutation when the previous connection later %s",
@@ -122,7 +280,7 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat
const oldSessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane, state } = createTestChatPane({
const { pane: testPane, state } = createSharingTestChatPane({
client: { request: oldRequest } as unknown as GatewayBrowserClient,
sessions: oldSessions,
});
@@ -159,7 +317,7 @@ describe.each(mutations)("chat pane $name mutation connection ownership", (mutat
const sessions = {
refreshReplacement: vi.fn(),
} as unknown as SessionCapability;
const { pane: testPane } = createTestChatPane({
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
@@ -190,7 +348,7 @@ describe("chat pane sharing mutation phase ownership", () => {
const oldSessions = {
refreshReplacement: vi.fn(() => refreshed.promise),
} as unknown as SessionCapability;
const { pane: testPane, state } = createTestChatPane({
const { pane: testPane, state } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: oldSessions,
});
@@ -232,7 +390,7 @@ describe("chat pane sharing mutation phase ownership", () => {
const oldSessions = {
refreshReplacement: vi.fn(async () => undefined),
} as unknown as SessionCapability;
const { pane: testPane, state } = createTestChatPane({
const { pane: testPane, state } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: oldSessions,
});
@@ -277,7 +435,7 @@ describe("chat pane sharing mutation phase ownership", () => {
const oldSessions = {
refreshReplacement: vi.fn(() => refreshed.promise),
} as unknown as SessionCapability;
const { pane: testPane, state } = createTestChatPane({
const { pane: testPane, state } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions: oldSessions,
});
@@ -313,7 +471,7 @@ describe("chat pane current sharing mutation refresh order", () => {
calls.push("sessions.refreshReplacement");
}),
} as unknown as SessionCapability;
const { pane: testPane } = createTestChatPane({
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
@@ -346,7 +504,7 @@ describe("chat pane current sharing mutation refresh order", () => {
calls.push("sessions.refreshReplacement");
}),
} as unknown as SessionCapability;
const { pane: testPane } = createTestChatPane({
const { pane: testPane } = createSharingTestChatPane({
client: { request } as unknown as GatewayBrowserClient,
sessions,
});
+45 -14
View File
@@ -15,6 +15,7 @@ import type {
import { hasMultiplePresenceIdentities } from "../../components/viewer-facepile.ts";
import { t } from "../../i18n/index.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { readSessionMethodAccess } from "../../lib/session-method-access.ts";
import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
import {
areUiSessionKeysEquivalent,
@@ -28,7 +29,10 @@ import {
} from "./chat-pane-shared.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { resolveChatAgentId } from "./chat-state-route.ts";
import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts";
import {
canManageChatSessionSharing,
type ChatSessionSharingState,
} from "./components/chat-session-sharing.ts";
export abstract class ChatPaneSharing extends ChatPaneBase {
protected setSessionSharingState(cacheKey: string, state: ChatSessionSharingState): void {
@@ -48,7 +52,15 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
protected async loadSessionSharing(row: GatewaySessionRow, force = false): Promise<void> {
const state = this.state;
if (!state?.connected || !state.client) {
if (
!state?.connected ||
!state.client ||
!canManageChatSessionSharing(row) ||
!readSessionMethodAccess(this.context.gateway.snapshot, {
method: "session.members.list",
requiredScope: "operator.read",
}).allowed
) {
return;
}
const cacheKey = this.sessionSharingCacheKey(row.key);
@@ -86,17 +98,26 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
visibility: SessionVisibility,
): Promise<void> {
const scope = this.captureConnectionScope();
if (!scope || visibility === row.visibility) {
if (!scope || visibility === row.visibility || !canManageChatSessionSharing(row)) {
return;
}
const agentId = this.sessionSharingAgentId(row.key);
const cacheKey = this.sessionSharingCacheKey(row.key);
const params = {
sessionKey: row.key,
visibility,
...(agentId ? { agentId } : {}),
};
if (
!readSessionMethodAccess(scope.context.gateway.snapshot, {
method: "session.visibility.set",
requiredScope: "operator.write",
}).allowed
) {
return;
}
try {
await scope.client.request("session.visibility.set", {
sessionKey: row.key,
visibility,
...(agentId ? { agentId } : {}),
});
await scope.client.request("session.visibility.set", params);
if (!this.isConnectionScopeCurrent(scope)) {
return;
}
@@ -123,17 +144,27 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
member: boolean,
): Promise<void> {
const scope = this.captureConnectionScope();
if (!scope) {
if (!scope || !canManageChatSessionSharing(row)) {
return;
}
const agentId = this.sessionSharingAgentId(row.key);
const cacheKey = this.sessionSharingCacheKey(row.key);
const method = member ? "session.members.add" : "session.members.remove";
const params = {
sessionKey: row.key,
identityId,
...(agentId ? { agentId } : {}),
};
if (
!readSessionMethodAccess(scope.context.gateway.snapshot, {
method,
requiredScope: "operator.write",
}).allowed
) {
return;
}
try {
await scope.client.request(member ? "session.members.add" : "session.members.remove", {
sessionKey: row.key,
identityId,
...(agentId ? { agentId } : {}),
});
await scope.client.request(method, params);
if (!this.isConnectionScopeCurrent(scope)) {
return;
}
@@ -127,4 +127,98 @@ describe("chat session sharing menu", () => {
expect(onVisibilityChange).toHaveBeenCalledWith("shared");
expect(root.querySelectorAll('wa-dropdown-item[value="visibility:shared"]')).toHaveLength(1);
});
it("keeps read-only owner controls visible but refuses disabled synthetic actions", () => {
const onOpen = vi.fn();
const onVisibilityChange = vi.fn();
const onMemberChange = vi.fn();
const root = mount(
renderChatSessionSharing({
session: {
key: "agent:main:main",
kind: "direct",
updatedAt: 1,
visibility: "shared",
sharingRole: "owner",
},
state: {
loading: false,
result: {
sessionKey: "agent:main:main",
members: [{ identityId: "alice", addedAt: 1 }],
identities: [
{ type: "human", id: "alice", label: "Alice" },
{ type: "human", id: "bob", label: "Bob" },
],
role: "owner",
allowedVisibilities: ["shared", "read-only"],
},
},
visibilityDisabledReason: "Requires write",
memberAddDisabledReason: "Requires write",
memberRemoveDisabledReason: "Requires write",
onOpen,
onVisibilityChange,
onMemberChange,
}),
);
const dropdown = root.querySelector("wa-dropdown");
expect(dropdown).not.toBeNull();
expect(root.querySelector('wa-dropdown-item[value="visibility:read-only"]')?.title).toBe(
"Requires write",
);
expect(
root.querySelector('wa-dropdown-item[value="member:alice"]')?.hasAttribute("disabled"),
).toBe(true);
expect(
root.querySelector('wa-dropdown-item[value="member:bob"]')?.hasAttribute("disabled"),
).toBe(true);
dropdown?.dispatchEvent(new CustomEvent("wa-show"));
dropdown?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: "visibility:read-only" } },
}),
);
dropdown?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: "member:alice" } },
}),
);
dropdown?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: "member:bob" } },
}),
);
expect(onOpen).toHaveBeenCalledOnce();
expect(onVisibilityChange).not.toHaveBeenCalled();
expect(onMemberChange).not.toHaveBeenCalled();
});
it("disables opening when sharing reads are unavailable", () => {
const onOpen = vi.fn();
const root = mount(
renderChatSessionSharing({
session: {
key: "agent:main:main",
kind: "direct",
updatedAt: 1,
visibility: "shared",
sharingRole: "owner",
},
state: undefined,
openDisabledReason: "Connect to the Gateway",
onOpen,
onVisibilityChange: vi.fn(),
onMemberChange: vi.fn(),
}),
);
expect(root.querySelector<HTMLButtonElement>(".chat-pane__sharing-trigger")?.disabled).toBe(
true,
);
root.querySelector("wa-dropdown")?.dispatchEvent(new CustomEvent("wa-show"));
expect(onOpen).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,10 @@ export type ChatSessionSharingState = {
type ChatSessionSharingProps = {
session: GatewaySessionRow | undefined;
state: ChatSessionSharingState | undefined;
openDisabledReason?: string;
visibilityDisabledReason?: string;
memberAddDisabledReason?: string;
memberRemoveDisabledReason?: string;
onOpen: () => void;
onVisibilityChange: (visibility: SessionVisibility) => void;
onMemberChange: (identityId: string, member: boolean) => void;
@@ -36,13 +40,19 @@ function sharingIcon(visibility: SessionVisibility): TemplateResult {
return visibility === "shared" ? icons.users : icons.lock;
}
export function canManageChatSessionSharing(
session: Pick<GatewaySessionRow, "sharingRole">,
): boolean {
return session.sharingRole === "admin" || session.sharingRole === "owner";
}
export function renderChatSessionSharing(props: ChatSessionSharingProps) {
const session = props.session;
if (!session) {
return nothing;
}
const visibility = session.visibility ?? "shared";
const canManage = session.sharingRole === "admin" || session.sharingRole === "owner";
const canManage = canManageChatSessionSharing(session);
if (!canManage) {
return visibility === "draft"
? html`<span class="chat-pane__draft-indicator" title=${t("chat.sessionSharing.draft")}
@@ -61,16 +71,28 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
<wa-dropdown
class="chat-pane__sharing-menu"
placement="bottom-end"
@wa-show=${props.onOpen}
@wa-show=${() => {
if (!props.openDisabledReason) {
props.onOpen();
}
}}
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
const value = event.detail.item.value;
if (value?.startsWith("visibility:")) {
props.onVisibilityChange(value.slice("visibility:".length) as SessionVisibility);
if (!props.visibilityDisabledReason) {
props.onVisibilityChange(value.slice("visibility:".length) as SessionVisibility);
}
return;
}
if (value?.startsWith("member:")) {
const identityId = value.slice("member:".length);
props.onMemberChange(identityId, !members.has(identityId));
const member = !members.has(identityId);
const disabledReason = member
? props.memberAddDisabledReason
: props.memberRemoveDisabledReason;
if (!disabledReason) {
props.onMemberChange(identityId, member);
}
}
}}
>
@@ -79,14 +101,21 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
class="btn btn--ghost btn--icon chat-icon-btn chat-pane__sharing-trigger"
type="button"
aria-label=${t("chat.sessionSharing.menu")}
title=${t("chat.sessionSharing.current", {
?disabled=${Boolean(props.openDisabledReason)}
title=${props.openDisabledReason ??
t("chat.sessionSharing.current", {
visibility: t(VISIBILITY_LABEL_KEYS[visibility]),
})}
>
${sharingIcon(visibility)}
</button>
${canPublish
? html`<wa-dropdown-item value="visibility:shared" class="chat-pane__publish-draft">
? html`<wa-dropdown-item
value="visibility:shared"
class="chat-pane__publish-draft"
?disabled=${Boolean(props.visibilityDisabledReason)}
title=${props.visibilityDisabledReason ?? nothing}
>
<span>${t("chat.sessionSharing.publishDraft")}</span>
<span slot="details" aria-hidden="true">${icons.users}</span>
</wa-dropdown-item>
@@ -97,7 +126,11 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
.filter((option) => !canPublish || option !== "shared")
.map(
(option) => html`
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
<wa-dropdown-item
value=${`visibility:${option}`}
?disabled=${option === visibility || Boolean(props.visibilityDisabledReason)}
title=${props.visibilityDisabledReason ?? nothing}
>
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
${option === visibility
? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
@@ -110,9 +143,16 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
${props.state?.loading
? html`<div class="chat-pane__sharing-status">${t("common.loading")}</div>`
: identities.length > 0
? identities.map(
(identity) => html`
<wa-dropdown-item value=${`member:${identity.id}`}>
? identities.map((identity) => {
const disabledReason = members.has(identity.id)
? props.memberRemoveDisabledReason
: props.memberAddDisabledReason;
return html`
<wa-dropdown-item
value=${`member:${identity.id}`}
?disabled=${Boolean(disabledReason)}
title=${disabledReason ?? nothing}
>
<span>${identity.label ?? identity.id}</span>
${members.has(identity.id)
? html`<span slot="details" aria-label=${t("chat.sessionSharing.selected")}
@@ -120,8 +160,8 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
>`
: nothing}
</wa-dropdown-item>
`,
)
`;
})
: html`<div class="chat-pane__sharing-status">${t("chat.sessionSharing.noPeople")}</div>`}
${props.state?.error
? html`<div class="chat-pane__sharing-status chat-pane__sharing-status--error">