@stigmer/react 3.1.13 → 3.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentDetailView.d.ts +9 -9
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +23 -31
- package/agent/AgentDetailView.js.map +1 -1
- package/agent-instance/AgentInstanceEmptyState.d.ts +4 -1
- package/agent-instance/AgentInstanceEmptyState.d.ts.map +1 -1
- package/agent-instance/AgentInstanceEmptyState.js +12 -5
- package/agent-instance/AgentInstanceEmptyState.js.map +1 -1
- package/agent-instance/AgentInstanceList.d.ts +13 -2
- package/agent-instance/AgentInstanceList.d.ts.map +1 -1
- package/agent-instance/AgentInstanceList.js +8 -4
- package/agent-instance/AgentInstanceList.js.map +1 -1
- package/agent-instance/useAgentInstances.d.ts +10 -3
- package/agent-instance/useAgentInstances.d.ts.map +1 -1
- package/agent-instance/useAgentInstances.js +12 -4
- package/agent-instance/useAgentInstances.js.map +1 -1
- package/button/Button.d.ts +54 -0
- package/button/Button.d.ts.map +1 -0
- package/button/Button.js +42 -0
- package/button/Button.js.map +1 -0
- package/button/index.d.ts +3 -0
- package/button/index.d.ts.map +1 -0
- package/button/index.js +2 -0
- package/button/index.js.map +1 -0
- package/empty-state/EmptyState.d.ts.map +1 -1
- package/empty-state/EmptyState.js +8 -3
- package/empty-state/EmptyState.js.map +1 -1
- package/empty-state/types.d.ts +2 -0
- package/empty-state/types.d.ts.map +1 -1
- package/execution/index.d.ts +1 -1
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js.map +1 -1
- package/execution/useCreateAgentExecution.d.ts +81 -9
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +42 -4
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/index.d.ts +5 -3
- package/index.d.ts.map +1 -1
- package/index.js +4 -2
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/session/useNewSessionFlow.d.ts +6 -3
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +34 -35
- package/session/useNewSessionFlow.js.map +1 -1
- package/sharing/AgentShareList.d.ts +46 -0
- package/sharing/AgentShareList.d.ts.map +1 -0
- package/sharing/AgentShareList.js +159 -0
- package/sharing/AgentShareList.js.map +1 -0
- package/sharing/ShareAgentDialog.d.ts +38 -25
- package/sharing/ShareAgentDialog.d.ts.map +1 -1
- package/sharing/ShareAgentDialog.js +131 -62
- package/sharing/ShareAgentDialog.js.map +1 -1
- package/sharing/index.d.ts +10 -8
- package/sharing/index.d.ts.map +1 -1
- package/sharing/index.js +5 -4
- package/sharing/index.js.map +1 -1
- package/sharing/useAgentShares.d.ts +48 -0
- package/sharing/useAgentShares.d.ts.map +1 -0
- package/sharing/useAgentShares.js +44 -0
- package/sharing/useAgentShares.js.map +1 -0
- package/sharing/useCanCreateAgentShare.d.ts +43 -0
- package/sharing/useCanCreateAgentShare.d.ts.map +1 -0
- package/sharing/useCanCreateAgentShare.js +44 -0
- package/sharing/useCanCreateAgentShare.js.map +1 -0
- package/sharing/useDeleteAgentShare.d.ts +42 -0
- package/sharing/useDeleteAgentShare.d.ts.map +1 -0
- package/sharing/useDeleteAgentShare.js +45 -0
- package/sharing/useDeleteAgentShare.js.map +1 -0
- package/sharing/useSaveAgentShare.d.ts +43 -18
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +55 -22
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/src/agent/AgentDetailView.tsx +37 -43
- package/src/agent-instance/AgentInstanceEmptyState.tsx +24 -43
- package/src/agent-instance/AgentInstanceList.tsx +29 -16
- package/src/agent-instance/__tests__/agent-instance-hooks.test.tsx +19 -0
- package/src/agent-instance/useAgentInstances.ts +12 -3
- package/src/button/Button.tsx +104 -0
- package/src/button/__tests__/Button.test.tsx +82 -0
- package/src/button/index.ts +2 -0
- package/src/empty-state/EmptyState.tsx +8 -10
- package/src/empty-state/types.ts +2 -0
- package/src/execution/__tests__/useCreateAgentExecution.test.tsx +92 -0
- package/src/execution/index.ts +2 -0
- package/src/execution/useCreateAgentExecution.ts +116 -10
- package/src/index.ts +17 -9
- package/src/session/__tests__/useNewSessionFlow.test.tsx +121 -52
- package/src/session/useNewSessionFlow.ts +38 -37
- package/src/sharing/AgentShareList.tsx +502 -0
- package/src/sharing/ShareAgentDialog.tsx +310 -112
- package/src/sharing/__tests__/AgentShareList.test.tsx +388 -0
- package/src/sharing/__tests__/ShareAgentDialog.test.tsx +311 -312
- package/src/sharing/__tests__/useAgentShares.test.tsx +186 -0
- package/src/sharing/__tests__/useCanCreateAgentShare.test.tsx +190 -0
- package/src/sharing/index.ts +10 -12
- package/src/sharing/useAgentShares.ts +74 -0
- package/src/sharing/useCanCreateAgentShare.ts +74 -0
- package/src/sharing/useDeleteAgentShare.ts +73 -0
- package/src/sharing/useSaveAgentShare.ts +73 -23
- package/src/workflow/WorkflowDetailView.tsx +9 -0
- package/src/workflow/instance/WorkflowInstanceEmptyState.tsx +24 -43
- package/src/workflow/instance/WorkflowInstanceList.tsx +29 -16
- package/src/workflow/useWorkflowInstances.ts +10 -3
- package/styles.css +1 -1
- package/workflow/WorkflowDetailView.d.ts +8 -1
- package/workflow/WorkflowDetailView.d.ts.map +1 -1
- package/workflow/WorkflowDetailView.js +2 -2
- package/workflow/WorkflowDetailView.js.map +1 -1
- package/workflow/instance/WorkflowInstanceEmptyState.d.ts +4 -1
- package/workflow/instance/WorkflowInstanceEmptyState.d.ts.map +1 -1
- package/workflow/instance/WorkflowInstanceEmptyState.js +12 -5
- package/workflow/instance/WorkflowInstanceEmptyState.js.map +1 -1
- package/workflow/instance/WorkflowInstanceList.d.ts +13 -2
- package/workflow/instance/WorkflowInstanceList.d.ts.map +1 -1
- package/workflow/instance/WorkflowInstanceList.js +8 -4
- package/workflow/instance/WorkflowInstanceList.js.map +1 -1
- package/workflow/useWorkflowInstances.d.ts +8 -3
- package/workflow/useWorkflowInstances.d.ts.map +1 -1
- package/workflow/useWorkflowInstances.js +10 -4
- package/workflow/useWorkflowInstances.js.map +1 -1
- package/sharing/useAgentShare.d.ts +0 -49
- package/sharing/useAgentShare.d.ts.map +0 -1
- package/sharing/useAgentShare.js +0 -64
- package/sharing/useAgentShare.js.map +0 -1
- package/sharing/useCreateExternalShareLink.d.ts +0 -90
- package/sharing/useCreateExternalShareLink.d.ts.map +0 -1
- package/sharing/useCreateExternalShareLink.js +0 -65
- package/sharing/useCreateExternalShareLink.js.map +0 -1
- package/sharing/useShareAgent.d.ts +0 -69
- package/sharing/useShareAgent.d.ts.map +0 -1
- package/sharing/useShareAgent.js +0 -42
- package/sharing/useShareAgent.js.map +0 -1
- package/src/sharing/__tests__/useAgentShare.test.tsx +0 -210
- package/src/sharing/__tests__/useCreateExternalShareLink.test.tsx +0 -194
- package/src/sharing/__tests__/useShareAgent.test.tsx +0 -131
- package/src/sharing/useAgentShare.ts +0 -105
- package/src/sharing/useCreateExternalShareLink.tsx +0 -141
- package/src/sharing/useShareAgent.tsx +0 -107
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { GetAgentSharesByAgentRequest } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/io_pb";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
|
+
import { useAgentShares } from "../useAgentShares";
|
|
8
|
+
|
|
9
|
+
function createMockStigmer(overrides: {
|
|
10
|
+
getByAgent?: (input: GetAgentSharesByAgentRequest) => Promise<unknown>;
|
|
11
|
+
} = {}) {
|
|
12
|
+
return {
|
|
13
|
+
agentShare: {
|
|
14
|
+
getByAgent:
|
|
15
|
+
overrides.getByAgent ??
|
|
16
|
+
vi.fn().mockResolvedValue({ totalCount: 0, items: [] }),
|
|
17
|
+
},
|
|
18
|
+
} as never;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function wrapper(client: unknown) {
|
|
22
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
23
|
+
return (
|
|
24
|
+
<FetchCacheContext.Provider value={null}>
|
|
25
|
+
<StigmerContext.Provider value={client as never}>
|
|
26
|
+
{children}
|
|
27
|
+
</StigmerContext.Provider>
|
|
28
|
+
</FetchCacheContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeShare(org: string, slug: string, id = `ash_${org}_${slug}`) {
|
|
34
|
+
return {
|
|
35
|
+
metadata: { id, org, slug },
|
|
36
|
+
spec: { enabled: true },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("useAgentShares", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("loads the agent's shares by agent id", async () => {
|
|
46
|
+
const share = makeShare("acme", "support-agent");
|
|
47
|
+
const getByAgent = vi
|
|
48
|
+
.fn()
|
|
49
|
+
.mockResolvedValue({ totalCount: 1, items: [share] });
|
|
50
|
+
const client = createMockStigmer({ getByAgent });
|
|
51
|
+
|
|
52
|
+
const { result } = renderHook(() => useAgentShares("agt_1"), {
|
|
53
|
+
wrapper: wrapper(client),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.current.isLoading).toBe(true);
|
|
57
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
58
|
+
|
|
59
|
+
expect(result.current.shares).toEqual([share]);
|
|
60
|
+
expect(result.current.error).toBeNull();
|
|
61
|
+
expect(getByAgent).toHaveBeenCalledWith(
|
|
62
|
+
expect.objectContaining({ agentId: "agt_1", org: "" }),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("threads the org scope into the request", async () => {
|
|
67
|
+
// The org scope belongs in the RPC, never client-side filtering: the
|
|
68
|
+
// server narrows a multi-org member's view to one org's channels.
|
|
69
|
+
const getByAgent = vi
|
|
70
|
+
.fn()
|
|
71
|
+
.mockResolvedValue({ totalCount: 0, items: [] });
|
|
72
|
+
const client = createMockStigmer({ getByAgent });
|
|
73
|
+
|
|
74
|
+
const { result } = renderHook(() => useAgentShares("agt_1", "acme"), {
|
|
75
|
+
wrapper: wrapper(client),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
79
|
+
expect(getByAgent).toHaveBeenCalledWith(
|
|
80
|
+
expect.objectContaining({ agentId: "agt_1", org: "acme" }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("refetches when the org scope changes", async () => {
|
|
85
|
+
const getByAgent = vi
|
|
86
|
+
.fn()
|
|
87
|
+
.mockResolvedValue({ totalCount: 0, items: [] });
|
|
88
|
+
const client = createMockStigmer({ getByAgent });
|
|
89
|
+
|
|
90
|
+
const { result, rerender } = renderHook(
|
|
91
|
+
({ org }: { org: string }) => useAgentShares("agt_1", org),
|
|
92
|
+
{ wrapper: wrapper(client), initialProps: { org: "acme" } },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
96
|
+
expect(getByAgent).toHaveBeenCalledTimes(1);
|
|
97
|
+
|
|
98
|
+
// Switching org context must re-query — a stale other-org list is
|
|
99
|
+
// exactly the bug the scope exists to prevent.
|
|
100
|
+
rerender({ org: "consumer-org" });
|
|
101
|
+
await waitFor(() => expect(getByAgent).toHaveBeenCalledTimes(2));
|
|
102
|
+
expect(getByAgent).toHaveBeenLastCalledWith(
|
|
103
|
+
expect.objectContaining({ agentId: "agt_1", org: "consumer-org" }),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns the FULL list — never collapses to a canonical share", async () => {
|
|
108
|
+
// An agent can carry N shares across N orgs (decision 011 D3 +
|
|
109
|
+
// decision 013): the owner's, a renamed sibling, another org's
|
|
110
|
+
// external channel. The management surface shows them all.
|
|
111
|
+
const owner = makeShare("acme", "support-agent");
|
|
112
|
+
const renamed = makeShare("acme", "support-help-desk");
|
|
113
|
+
const external = makeShare("consumer-org", "support-agent");
|
|
114
|
+
const getByAgent = vi
|
|
115
|
+
.fn()
|
|
116
|
+
.mockResolvedValue({ totalCount: 3, items: [owner, renamed, external] });
|
|
117
|
+
const client = createMockStigmer({ getByAgent });
|
|
118
|
+
|
|
119
|
+
const { result } = renderHook(() => useAgentShares("agt_1"), {
|
|
120
|
+
wrapper: wrapper(client),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
124
|
+
expect(result.current.shares).toEqual([owner, renamed, external]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("resolves empty (no shares yet) when the agent has never been shared", async () => {
|
|
128
|
+
const client = createMockStigmer();
|
|
129
|
+
|
|
130
|
+
const { result } = renderHook(() => useAgentShares("agt_1"), {
|
|
131
|
+
wrapper: wrapper(client),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
135
|
+
|
|
136
|
+
// The no-share state is not an error: creating the first share is
|
|
137
|
+
// the empty state's call to action.
|
|
138
|
+
expect(result.current.shares).toEqual([]);
|
|
139
|
+
expect(result.current.error).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("skips fetching while the agent id is empty (stable no-op)", () => {
|
|
143
|
+
const getByAgent = vi.fn();
|
|
144
|
+
const client = createMockStigmer({ getByAgent });
|
|
145
|
+
|
|
146
|
+
const { result } = renderHook(() => useAgentShares(""), {
|
|
147
|
+
wrapper: wrapper(client),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.current.isLoading).toBe(false);
|
|
151
|
+
expect(result.current.shares).toEqual([]);
|
|
152
|
+
expect(getByAgent).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("exposes fetch failures as errors", async () => {
|
|
156
|
+
const getByAgent = vi
|
|
157
|
+
.fn()
|
|
158
|
+
.mockRejectedValue(new Error("backend unavailable"));
|
|
159
|
+
const client = createMockStigmer({ getByAgent });
|
|
160
|
+
|
|
161
|
+
const { result } = renderHook(() => useAgentShares("agt_1"), {
|
|
162
|
+
wrapper: wrapper(client),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
166
|
+
expect(result.current.error).toBeTruthy();
|
|
167
|
+
expect(result.current.shares).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("refetches on demand", async () => {
|
|
171
|
+
const getByAgent = vi
|
|
172
|
+
.fn()
|
|
173
|
+
.mockResolvedValue({ totalCount: 0, items: [] });
|
|
174
|
+
const client = createMockStigmer({ getByAgent });
|
|
175
|
+
|
|
176
|
+
const { result } = renderHook(() => useAgentShares("agt_1"), {
|
|
177
|
+
wrapper: wrapper(client),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
181
|
+
expect(getByAgent).toHaveBeenCalledTimes(1);
|
|
182
|
+
|
|
183
|
+
result.current.refetch();
|
|
184
|
+
await waitFor(() => expect(getByAgent).toHaveBeenCalledTimes(2));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
5
|
+
import { StigmerContext } from "../../context";
|
|
6
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
7
|
+
import { useCanCreateAgentShare } from "../useCanCreateAgentShare";
|
|
8
|
+
|
|
9
|
+
function createMockStigmer(overrides: {
|
|
10
|
+
isAuthorized?: boolean;
|
|
11
|
+
checkMyPermission?: (...args: unknown[]) => Promise<unknown>;
|
|
12
|
+
} = {}) {
|
|
13
|
+
return {
|
|
14
|
+
iamPolicy: {
|
|
15
|
+
checkMyPermission:
|
|
16
|
+
overrides.checkMyPermission ??
|
|
17
|
+
vi.fn().mockResolvedValue({
|
|
18
|
+
isAuthorized: overrides.isAuthorized ?? true,
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
} as never;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wrapper(client: unknown) {
|
|
25
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
26
|
+
return (
|
|
27
|
+
<FetchCacheContext.Provider value={null}>
|
|
28
|
+
<StigmerContext.Provider value={client as never}>
|
|
29
|
+
{children}
|
|
30
|
+
</StigmerContext.Provider>
|
|
31
|
+
</FetchCacheContext.Provider>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeAgent(visibility?: ApiResourceVisibility) {
|
|
37
|
+
return {
|
|
38
|
+
metadata: {
|
|
39
|
+
id: "agt_1",
|
|
40
|
+
org: "acme",
|
|
41
|
+
slug: "support-agent",
|
|
42
|
+
name: "Support Agent",
|
|
43
|
+
...(visibility !== undefined && { visibility }),
|
|
44
|
+
},
|
|
45
|
+
spec: {},
|
|
46
|
+
} as never;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function permissionCheckOf(client: unknown) {
|
|
50
|
+
return (
|
|
51
|
+
client as { iamPolicy: { checkMyPermission: ReturnType<typeof vi.fn> } }
|
|
52
|
+
).iamPolicy.checkMyPermission;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("useCanCreateAgentShare", () => {
|
|
56
|
+
it("is not allowed while the agent is loading — no affordance flash", () => {
|
|
57
|
+
const client = createMockStigmer();
|
|
58
|
+
const { result } = renderHook(() => useCanCreateAgentShare(null, "acme"), {
|
|
59
|
+
wrapper: wrapper(client),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(result.current.allowed).toBe(false);
|
|
63
|
+
expect(permissionCheckOf(client)).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("same-org (viewer org equals the agent's, or omitted)", () => {
|
|
67
|
+
it("requires agent can_edit — the server's Phase A create bar", async () => {
|
|
68
|
+
const client = createMockStigmer({ isAuthorized: true });
|
|
69
|
+
const { result } = renderHook(
|
|
70
|
+
() => useCanCreateAgentShare(makeAgent(), "acme"),
|
|
71
|
+
{ wrapper: wrapper(client) },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
await waitFor(() => expect(result.current.allowed).toBe(true));
|
|
75
|
+
expect(result.current.isCrossOrg).toBe(false);
|
|
76
|
+
expect(result.current.shareOrg).toBe("acme");
|
|
77
|
+
|
|
78
|
+
const input = permissionCheckOf(client).mock.calls[0][0] as {
|
|
79
|
+
resource?: { kind: string; id: string };
|
|
80
|
+
relation: string;
|
|
81
|
+
};
|
|
82
|
+
expect(input.relation).toBe("can_edit");
|
|
83
|
+
expect(input.resource?.kind).toBe("agent");
|
|
84
|
+
expect(input.resource?.id).toBe("agt_1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("defaults the share org to the agent's own when viewerOrg is omitted", async () => {
|
|
88
|
+
const client = createMockStigmer({ isAuthorized: true });
|
|
89
|
+
const { result } = renderHook(
|
|
90
|
+
() => useCanCreateAgentShare(makeAgent()),
|
|
91
|
+
{ wrapper: wrapper(client) },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(result.current.allowed).toBe(true));
|
|
95
|
+
expect(result.current.shareOrg).toBe("acme");
|
|
96
|
+
expect(result.current.isCrossOrg).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses when the viewer lacks can_edit", async () => {
|
|
100
|
+
const client = createMockStigmer({ isAuthorized: false });
|
|
101
|
+
const { result } = renderHook(
|
|
102
|
+
() => useCanCreateAgentShare(makeAgent(), "acme"),
|
|
103
|
+
{ wrapper: wrapper(client) },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => expect(permissionCheckOf(client)).toHaveBeenCalled());
|
|
107
|
+
await waitFor(() => expect(result.current.allowed).toBe(false));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("cross-org (decision 013 D2's two-sided bar)", () => {
|
|
112
|
+
it("allows on a public agent when the viewer holds can_create_agent_share in their org", async () => {
|
|
113
|
+
const client = createMockStigmer({ isAuthorized: true });
|
|
114
|
+
const { result } = renderHook(
|
|
115
|
+
() =>
|
|
116
|
+
useCanCreateAgentShare(
|
|
117
|
+
makeAgent(ApiResourceVisibility.visibility_public),
|
|
118
|
+
"consumer-org",
|
|
119
|
+
),
|
|
120
|
+
{ wrapper: wrapper(client) },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await waitFor(() => expect(result.current.allowed).toBe(true));
|
|
124
|
+
expect(result.current.isCrossOrg).toBe(true);
|
|
125
|
+
expect(result.current.shareOrg).toBe("consumer-org");
|
|
126
|
+
|
|
127
|
+
const input = permissionCheckOf(client).mock.calls[0][0] as {
|
|
128
|
+
resource?: { kind: string; id: string };
|
|
129
|
+
relation: string;
|
|
130
|
+
};
|
|
131
|
+
expect(input.relation).toBe("can_create_agent_share");
|
|
132
|
+
expect(input.resource?.kind).toBe("organization");
|
|
133
|
+
// An Organization's id equals its slug (ApiResourceMetadata.id).
|
|
134
|
+
expect(input.resource?.id).toBe("consumer-org");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("refuses on a non-public agent without any permission RPC — visibility IS the consent (D1)", () => {
|
|
138
|
+
const client = createMockStigmer();
|
|
139
|
+
const { result } = renderHook(
|
|
140
|
+
() => useCanCreateAgentShare(makeAgent(), "consumer-org"),
|
|
141
|
+
{ wrapper: wrapper(client) },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(result.current.allowed).toBe(false);
|
|
145
|
+
expect(result.current.isCrossOrg).toBe(true);
|
|
146
|
+
expect(permissionCheckOf(client)).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses when the viewer lacks can_create_agent_share", async () => {
|
|
150
|
+
const client = createMockStigmer({ isAuthorized: false });
|
|
151
|
+
const { result } = renderHook(
|
|
152
|
+
() =>
|
|
153
|
+
useCanCreateAgentShare(
|
|
154
|
+
makeAgent(ApiResourceVisibility.visibility_public),
|
|
155
|
+
"consumer-org",
|
|
156
|
+
),
|
|
157
|
+
{ wrapper: wrapper(client) },
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await waitFor(() => expect(permissionCheckOf(client)).toHaveBeenCalled());
|
|
161
|
+
await waitFor(() => expect(result.current.allowed).toBe(false));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("creates in the VIEWER's org even when the viewer could also edit the agent", async () => {
|
|
165
|
+
// The case that produced the old double-entry confusion: a user
|
|
166
|
+
// holding can_edit on a public agent while acting as another org.
|
|
167
|
+
// There is exactly one answer now — the share lands in the org
|
|
168
|
+
// they are acting as, on that org's bill.
|
|
169
|
+
const client = createMockStigmer({ isAuthorized: true });
|
|
170
|
+
const { result } = renderHook(
|
|
171
|
+
() =>
|
|
172
|
+
useCanCreateAgentShare(
|
|
173
|
+
makeAgent(ApiResourceVisibility.visibility_public),
|
|
174
|
+
"personal",
|
|
175
|
+
),
|
|
176
|
+
{ wrapper: wrapper(client) },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await waitFor(() => expect(result.current.allowed).toBe(true));
|
|
180
|
+
expect(result.current.shareOrg).toBe("personal");
|
|
181
|
+
expect(result.current.isCrossOrg).toBe(true);
|
|
182
|
+
// Only the org-side bar is consulted — the agent-side can_edit
|
|
183
|
+
// check belongs to the same-org branch alone.
|
|
184
|
+
const relations = permissionCheckOf(client).mock.calls.map(
|
|
185
|
+
(call: unknown[]) => (call[0] as { relation: string }).relation,
|
|
186
|
+
);
|
|
187
|
+
expect(relations).toEqual(["can_create_agent_share"]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/sharing/index.ts
CHANGED
|
@@ -5,31 +5,29 @@ export type {
|
|
|
5
5
|
} from "./useSharedAgentProfile.js";
|
|
6
6
|
export { SharedAgentChat } from "./SharedAgentChat.js";
|
|
7
7
|
export type { SharedAgentChatProps } from "./SharedAgentChat.js";
|
|
8
|
-
export {
|
|
9
|
-
export type {
|
|
8
|
+
export { useAgentShares } from "./useAgentShares.js";
|
|
9
|
+
export type { UseAgentSharesReturn } from "./useAgentShares.js";
|
|
10
|
+
export { useCanCreateAgentShare } from "./useCanCreateAgentShare.js";
|
|
11
|
+
export type { UseCanCreateAgentShareReturn } from "./useCanCreateAgentShare.js";
|
|
10
12
|
export {
|
|
13
|
+
draftFromShare,
|
|
11
14
|
sharingAudienceFromProto,
|
|
12
15
|
useSaveAgentShare,
|
|
13
16
|
} from "./useSaveAgentShare.js";
|
|
14
17
|
export type {
|
|
18
|
+
AgentShareCreateIdentity,
|
|
15
19
|
AgentShareDraft,
|
|
16
20
|
SharingAudience,
|
|
17
21
|
UseSaveAgentShareReturn,
|
|
18
22
|
} from "./useSaveAgentShare.js";
|
|
23
|
+
export { useDeleteAgentShare } from "./useDeleteAgentShare.js";
|
|
24
|
+
export type { UseDeleteAgentShareReturn } from "./useDeleteAgentShare.js";
|
|
19
25
|
export { useRotateShareLink } from "./useRotateShareLink.js";
|
|
20
26
|
export type { UseRotateShareLinkReturn } from "./useRotateShareLink.js";
|
|
21
27
|
export { ShareAgentDialog } from "./ShareAgentDialog.js";
|
|
22
28
|
export type { ShareAgentDialogProps } from "./ShareAgentDialog.js";
|
|
23
|
-
export {
|
|
24
|
-
export type {
|
|
25
|
-
UseShareAgentArgs,
|
|
26
|
-
UseShareAgentReturn,
|
|
27
|
-
} from "./useShareAgent.js";
|
|
28
|
-
export { useCreateExternalShareLink } from "./useCreateExternalShareLink.js";
|
|
29
|
-
export type {
|
|
30
|
-
UseCreateExternalShareLinkArgs,
|
|
31
|
-
UseCreateExternalShareLinkReturn,
|
|
32
|
-
} from "./useCreateExternalShareLink.js";
|
|
29
|
+
export { AgentShareList } from "./AgentShareList.js";
|
|
30
|
+
export type { AgentShareListProps } from "./AgentShareList.js";
|
|
33
31
|
export { useShareToolReadiness } from "./useShareToolReadiness.js";
|
|
34
32
|
export type { ShareToolReadiness } from "./useShareToolReadiness.js";
|
|
35
33
|
// Origin validation moved to @stigmer/sdk (framework-free, shared with the
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { create } from "@bufbuild/protobuf";
|
|
4
|
+
import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
|
|
5
|
+
import { GetAgentSharesByAgentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/io_pb";
|
|
6
|
+
import { useStigmer } from "../hooks.js";
|
|
7
|
+
import { useFetch } from "../internal/useFetch.js";
|
|
8
|
+
|
|
9
|
+
/** Return value of {@link useAgentShares}. */
|
|
10
|
+
export interface UseAgentSharesReturn {
|
|
11
|
+
/**
|
|
12
|
+
* Every share of the agent visible to the caller, in server order.
|
|
13
|
+
* Empty while loading, on error, or when the agent has never been
|
|
14
|
+
* shared — `shares.length === 0 && !isLoading && !error` means "no
|
|
15
|
+
* shares exist yet".
|
|
16
|
+
*/
|
|
17
|
+
readonly shares: readonly AgentShare[];
|
|
18
|
+
/** `true` while the initial fetch or a refetch is in flight. */
|
|
19
|
+
readonly isLoading: boolean;
|
|
20
|
+
/** `true` while a background refetch is in flight and stale data is shown. */
|
|
21
|
+
readonly isRefetching: boolean;
|
|
22
|
+
/** Error from the last failed request, or `null` when healthy. */
|
|
23
|
+
readonly error: Error | null;
|
|
24
|
+
/** Discard cached data and re-fetch the shares from the server. */
|
|
25
|
+
readonly refetch: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Data hook that loads an agent's {@link AgentShare} resources — the
|
|
30
|
+
* channels carrying its hosted-chat configuration (audience, allowed
|
|
31
|
+
* origins, visitor messages, tool credentials, link token).
|
|
32
|
+
*
|
|
33
|
+
* Sharing is channel configuration, not agent behavior (decision 011):
|
|
34
|
+
* an agent can carry N shares, each with its own URL, billing org, and
|
|
35
|
+
* credentials (decision 011 D3 + decision 013 cross-org shares).
|
|
36
|
+
*
|
|
37
|
+
* Pass `org` to scope the list to one organization's channels — the
|
|
38
|
+
* org-context view a console tab needs. Permissions alone cannot provide
|
|
39
|
+
* this scope: a member of several orgs can view all of their orgs'
|
|
40
|
+
* channels of the agent, so the unscoped list merges them. The server
|
|
41
|
+
* applies the scope (it only ever narrows the permission-bounded view);
|
|
42
|
+
* omit `org` for the full permission-bounded list.
|
|
43
|
+
*
|
|
44
|
+
* Pass an empty `agentId` to skip fetching (stable no-op) — useful
|
|
45
|
+
* while the agent is still loading.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const { shares, isLoading, refetch } = useAgentShares(
|
|
50
|
+
* agent?.metadata?.id ?? "",
|
|
51
|
+
* viewerOrg,
|
|
52
|
+
* );
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function useAgentShares(agentId: string, org?: string): UseAgentSharesReturn {
|
|
56
|
+
const stigmer = useStigmer();
|
|
57
|
+
|
|
58
|
+
const fetchFn = agentId
|
|
59
|
+
? async () => {
|
|
60
|
+
const result = await stigmer.agentShare.getByAgent(
|
|
61
|
+
create(GetAgentSharesByAgentRequestSchema, { agentId, org: org ?? "" }),
|
|
62
|
+
);
|
|
63
|
+
return result.items;
|
|
64
|
+
}
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const { data: shares, isLoading, isRefetching, error, refetch } = useFetch(
|
|
68
|
+
fetchFn,
|
|
69
|
+
[agentId, org, stigmer],
|
|
70
|
+
[] as AgentShare[],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return { shares, isLoading, isRefetching, error, refetch };
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
4
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
5
|
+
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
6
|
+
|
|
7
|
+
/** Return value of {@link useCanCreateAgentShare}. */
|
|
8
|
+
export interface UseCanCreateAgentShareReturn {
|
|
9
|
+
/** Whether the viewer may create a share of this agent in `shareOrg`. */
|
|
10
|
+
readonly allowed: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The org that would own the new share — the viewer's org when set,
|
|
13
|
+
* else the agent's own. The share's URL, billing, and credential
|
|
14
|
+
* bindings all belong to this org.
|
|
15
|
+
*/
|
|
16
|
+
readonly shareOrg: string;
|
|
17
|
+
/** Whether creating in `shareOrg` would be a cross-org share (decision 013). */
|
|
18
|
+
readonly isCrossOrg: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mirrors the server's AgentShare create bar so create affordances never
|
|
23
|
+
* appear to a user whose create would be refused:
|
|
24
|
+
*
|
|
25
|
+
* - **Same-org** (`shareOrg` equals the agent's org): agent `can_edit` —
|
|
26
|
+
* the exact permission the create/apply handlers enforce on the
|
|
27
|
+
* referenced agent.
|
|
28
|
+
* - **Cross-org** (decision 013 D2, two-sided): the agent must be
|
|
29
|
+
* `visibility_public` — the origin org's implicit consent (D1), and
|
|
30
|
+
* the client-side proxy for the `can_execute`-via-`allow_public` half
|
|
31
|
+
* of the bar — and the viewer must hold `can_create_agent_share` in
|
|
32
|
+
* their own org (admin-level: a public share spends that org's credits
|
|
33
|
+
* on the open internet). The org check uses the org slug as the
|
|
34
|
+
* resource id — an Organization's id equals its slug
|
|
35
|
+
* (ApiResourceMetadata.id).
|
|
36
|
+
*
|
|
37
|
+
* On the OSS edition {@link useCheckPermission} degrades to allowed
|
|
38
|
+
* (no IAM service), matching the backend's documented no-op
|
|
39
|
+
* authorization (decision 011 D4).
|
|
40
|
+
*
|
|
41
|
+
* Pass `null` for `agent` while it loads — `allowed` stays `false` so
|
|
42
|
+
* no affordance flashes before the gate can be evaluated.
|
|
43
|
+
*
|
|
44
|
+
* @param agent The agent to share, or `null` while loading.
|
|
45
|
+
* @param viewerOrg The viewer's active org. Empty/omitted means the
|
|
46
|
+
* agent's own org (the same-org owner flow).
|
|
47
|
+
*/
|
|
48
|
+
export function useCanCreateAgentShare(
|
|
49
|
+
agent: Agent | null,
|
|
50
|
+
viewerOrg?: string,
|
|
51
|
+
): UseCanCreateAgentShareReturn {
|
|
52
|
+
const agentId = agent?.metadata?.id ?? "";
|
|
53
|
+
const agentOrg = agent?.metadata?.org ?? "";
|
|
54
|
+
const shareOrg = viewerOrg || agentOrg;
|
|
55
|
+
const isCrossOrg = shareOrg !== "" && shareOrg !== agentOrg;
|
|
56
|
+
const isPublic =
|
|
57
|
+
agent?.metadata?.visibility === ApiResourceVisibility.visibility_public;
|
|
58
|
+
|
|
59
|
+
// Both permission checks are declared unconditionally (hook rules);
|
|
60
|
+
// each skips its RPC (null resource) when its branch doesn't apply.
|
|
61
|
+
const { allowed: canEditAgent } = useCheckPermission(
|
|
62
|
+
!isCrossOrg && agentId ? { kind: "agent", id: agentId } : null,
|
|
63
|
+
"can_edit",
|
|
64
|
+
);
|
|
65
|
+
const { allowed: canCreateInOrg } = useCheckPermission(
|
|
66
|
+
isCrossOrg && isPublic ? { kind: "organization", id: shareOrg } : null,
|
|
67
|
+
"can_create_agent_share",
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const allowed =
|
|
71
|
+
!!agent && (isCrossOrg ? isPublic && canCreateInOrg : canEditAgent);
|
|
72
|
+
|
|
73
|
+
return { allowed, shareOrg, isCrossOrg };
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
|
|
5
|
+
import { useStigmer } from "../hooks.js";
|
|
6
|
+
import { toError } from "../internal/toError.js";
|
|
7
|
+
|
|
8
|
+
/** Return value of {@link useDeleteAgentShare}. */
|
|
9
|
+
export interface UseDeleteAgentShareReturn {
|
|
10
|
+
/**
|
|
11
|
+
* Delete an agent share by ID. Returns the deleted resource.
|
|
12
|
+
*
|
|
13
|
+
* Deleting a share is full teardown (decision 011 D1): its hosted
|
|
14
|
+
* link dies immediately — including for visitors mid-conversation —
|
|
15
|
+
* and its configuration (origins, messages, credential bindings, link
|
|
16
|
+
* token) is gone. To stop serving while keeping the configuration,
|
|
17
|
+
* save the share with `enabled: false` instead (a config-preserving
|
|
18
|
+
* pause via {@link useSaveAgentShare}).
|
|
19
|
+
*/
|
|
20
|
+
readonly deleteShare: (id: string) => Promise<AgentShare>;
|
|
21
|
+
/** `true` while the delete request is in flight. */
|
|
22
|
+
readonly isDeleting: boolean;
|
|
23
|
+
/** Error from the last failed delete, or `null` when healthy. */
|
|
24
|
+
readonly error: Error | null;
|
|
25
|
+
/** Reset `error` to `null`. */
|
|
26
|
+
readonly clearError: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mutation hook that deletes an {@link AgentShare} resource.
|
|
31
|
+
*
|
|
32
|
+
* Wraps `stigmer.agentShare.delete()` (authorized by the share's
|
|
33
|
+
* `can_delete`) with loading/error state. The caller is responsible for
|
|
34
|
+
* post-delete UI updates (e.g. refreshing the share list) and for
|
|
35
|
+
* confirming the destructive action — the delete-vs-pause distinction
|
|
36
|
+
* belongs in the confirmation copy.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const { deleteShare, isDeleting } = useDeleteAgentShare();
|
|
41
|
+
*
|
|
42
|
+
* const handleDelete = async () => {
|
|
43
|
+
* await deleteShare(share.metadata.id);
|
|
44
|
+
* refetch(); // refresh the share list
|
|
45
|
+
* };
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function useDeleteAgentShare(): UseDeleteAgentShareReturn {
|
|
49
|
+
const stigmer = useStigmer();
|
|
50
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
51
|
+
const [error, setError] = useState<Error | null>(null);
|
|
52
|
+
|
|
53
|
+
const clearError = useCallback(() => setError(null), []);
|
|
54
|
+
|
|
55
|
+
const deleteShare = useCallback(
|
|
56
|
+
async (id: string): Promise<AgentShare> => {
|
|
57
|
+
setIsDeleting(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return await stigmer.agentShare.delete(id);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setError(toError(err));
|
|
64
|
+
throw err;
|
|
65
|
+
} finally {
|
|
66
|
+
setIsDeleting(false);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[stigmer],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return { deleteShare, isDeleting, error, clearError };
|
|
73
|
+
}
|