mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: honor operator scopes in session sharing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }), {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user