@stigmer/react 3.32.0 → 3.34.0

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.
Files changed (38) hide show
  1. package/api-key/ApiKeyListPanel.js +2 -4
  2. package/api-key/ApiKeyListPanel.js.map +1 -1
  3. package/internal/LastUsedLabel.d.ts +20 -0
  4. package/internal/LastUsedLabel.d.ts.map +1 -0
  5. package/internal/LastUsedLabel.js +23 -0
  6. package/internal/LastUsedLabel.js.map +1 -0
  7. package/licenses/license-format.d.ts +15 -3
  8. package/licenses/license-format.d.ts.map +1 -1
  9. package/licenses/license-format.js +1 -1
  10. package/licenses/license-format.js.map +1 -1
  11. package/package.json +5 -5
  12. package/platform-client/PlatformClientDetailPanel.d.ts +13 -3
  13. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
  14. package/platform-client/PlatformClientDetailPanel.js +21 -8
  15. package/platform-client/PlatformClientDetailPanel.js.map +1 -1
  16. package/platform-client/PlatformClientListPanel.d.ts +19 -3
  17. package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
  18. package/platform-client/PlatformClientListPanel.js +11 -7
  19. package/platform-client/PlatformClientListPanel.js.map +1 -1
  20. package/platform-client/copy.d.ts +14 -0
  21. package/platform-client/copy.d.ts.map +1 -0
  22. package/platform-client/copy.js +14 -0
  23. package/platform-client/copy.js.map +1 -0
  24. package/settings/PlatformClientsSection.d.ts +7 -0
  25. package/settings/PlatformClientsSection.d.ts.map +1 -1
  26. package/settings/PlatformClientsSection.js +18 -2
  27. package/settings/PlatformClientsSection.js.map +1 -1
  28. package/src/api-key/ApiKeyListPanel.tsx +2 -6
  29. package/src/internal/LastUsedLabel.tsx +41 -0
  30. package/src/licenses/__tests__/license-format.test.ts +24 -4
  31. package/src/licenses/license-format.ts +19 -3
  32. package/src/platform-client/PlatformClientDetailPanel.tsx +41 -7
  33. package/src/platform-client/PlatformClientListPanel.tsx +55 -20
  34. package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +99 -6
  35. package/src/platform-client/__tests__/PlatformClientListPanel.test.tsx +140 -0
  36. package/src/platform-client/copy.ts +15 -0
  37. package/src/settings/PlatformClientsSection.tsx +23 -2
  38. package/src/settings/__tests__/PlatformClientsSection.test.tsx +92 -5
@@ -4,9 +4,11 @@ import { clone, create } from "@bufbuild/protobuf";
4
4
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
5
5
  import {
6
6
  PlatformClientSchema,
7
+ PlatformClientStatusSchema,
7
8
  type PlatformClient,
8
9
  } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
9
10
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
11
+ import type { CheckMyPermissionInput } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
10
12
  import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
11
13
  import type { PlatformClientInput } from "@stigmer/sdk";
12
14
  import { StigmerContext } from "../../context";
@@ -23,6 +25,13 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
23
25
  * says "Expired", and the rotate confirmation says rotating will not help,
24
26
  * because rotation leaves the expiry as it is. A live client shows
25
27
  * neither.
28
+ *
29
+ * And what the view offers (stigmer/stigmer#1302): Edit and Rotate secret
30
+ * only with `can_edit`, Delete only with `can_delete` on the client, so an
31
+ * organization admin who may view a client they did not create reads it
32
+ * with no actions at all; and when the client last minted a token, or
33
+ * "Never" (stigmer/stigmer#1255). The permission check runs through the
34
+ * real hook over a stubbed `checkMyPermission`.
26
35
  */
27
36
 
28
37
  const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
@@ -46,16 +55,25 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
46
55
  },
47
56
  });
48
57
 
58
+ const ALLOW_ALL = async (_input: CheckMyPermissionInput) => ({
59
+ isAuthorized: true,
60
+ });
61
+
49
62
  function renderPanel(
50
63
  update: ReturnType<typeof vi.fn>,
51
64
  platformClient: PlatformClient = PLATFORM_CLIENT,
65
+ checkMyPermission: (
66
+ input: CheckMyPermissionInput,
67
+ ) => Promise<{ isAuthorized: boolean }> = ALLOW_ALL,
68
+ now?: Date,
52
69
  ) {
53
70
  const client = {
54
71
  platformclient: { update },
72
+ iamPolicy: { checkMyPermission },
55
73
  } as never;
56
74
  return render(
57
75
  <StigmerContext.Provider value={client}>
58
- <PlatformClientDetailPanel platformClient={platformClient} />
76
+ <PlatformClientDetailPanel platformClient={platformClient} now={now} />
59
77
  </StigmerContext.Provider>,
60
78
  );
61
79
  }
@@ -79,7 +97,7 @@ describe("PlatformClientDetailPanel save payload", () => {
79
97
  const update = vi.fn(async (_input: PlatformClientInput) => PLATFORM_CLIENT);
80
98
  renderPanel(update);
81
99
 
82
- fireEvent.click(screen.getByRole("button", { name: "Edit" }));
100
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
83
101
  fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
84
102
 
85
103
  await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
@@ -102,23 +120,98 @@ describe("PlatformClientDetailPanel save payload", () => {
102
120
  });
103
121
 
104
122
  describe("PlatformClientDetailPanel expiry", () => {
105
- it("an expired client reads Expired, and its rotate confirmation says rotating will not help", () => {
123
+ it("an expired client reads Expired, and its rotate confirmation says rotating will not help", async () => {
106
124
  renderPanel(vi.fn(), clientExpiringInDays(-1));
107
125
 
108
126
  expect(screen.getByText("Expired")).toBeTruthy();
109
127
  expect(screen.queryByText("Expires")).toBeNull();
110
128
 
111
- fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
129
+ fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
112
130
  expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
113
131
  });
114
132
 
115
- it("a live client reads Expires, and its rotate confirmation carries no expiry warning", () => {
133
+ it("a live client reads Expires, and its rotate confirmation carries no expiry warning", async () => {
116
134
  renderPanel(vi.fn(), clientExpiringInDays(1));
117
135
 
118
136
  expect(screen.getByText("Expires")).toBeTruthy();
119
137
  expect(screen.queryByText("Expired")).toBeNull();
120
138
 
121
- fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
139
+ fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
122
140
  expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
123
141
  });
124
142
  });
143
+
144
+ describe("PlatformClientDetailPanel actions by permission", () => {
145
+ it("asks can_edit and can_delete on the client", async () => {
146
+ const checkMyPermission = vi.fn(ALLOW_ALL);
147
+ renderPanel(vi.fn(), PLATFORM_CLIENT, checkMyPermission);
148
+
149
+ await screen.findByRole("button", { name: "Edit" });
150
+ const asked = checkMyPermission.mock.calls.map(
151
+ ([input]) =>
152
+ `${input.resource?.kind}:${input.resource?.id}:${input.relation}`,
153
+ );
154
+ expect(asked.sort()).toEqual([
155
+ "platform_client:pc-1:can_delete",
156
+ "platform_client:pc-1:can_edit",
157
+ ]);
158
+ });
159
+
160
+ it("offers Edit and Rotate secret only with can_edit, Delete only with can_delete", async () => {
161
+ renderPanel(vi.fn(), PLATFORM_CLIENT, async (input) => ({
162
+ isAuthorized: input.relation === "can_delete",
163
+ }));
164
+
165
+ await screen.findByRole("button", { name: "Delete platform client" });
166
+ expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
167
+ expect(screen.queryByRole("button", { name: "Rotate secret" })).toBeNull();
168
+ });
169
+
170
+ it("an admin who did not create the client reads it with no actions at all", async () => {
171
+ const checkMyPermission = vi.fn(async (_input: CheckMyPermissionInput) => ({
172
+ isAuthorized: false,
173
+ }));
174
+ const { container } = renderPanel(vi.fn(), PLATFORM_CLIENT, checkMyPermission);
175
+
176
+ await waitFor(() => expect(checkMyPermission).toHaveBeenCalledTimes(2));
177
+ // The configuration stays readable; only the change affordances are withheld.
178
+ expect(screen.getByText("client-abc")).toBeTruthy();
179
+ await waitFor(() => {
180
+ expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
181
+ expect(screen.queryByRole("button", { name: "Rotate secret" })).toBeNull();
182
+ expect(
183
+ screen.queryByRole("button", { name: "Delete platform client" }),
184
+ ).toBeNull();
185
+ });
186
+ const withoutActions = container.querySelectorAll("hr").length;
187
+ cleanup();
188
+
189
+ const allowed = renderPanel(vi.fn());
190
+ await screen.findByRole("button", { name: "Delete platform client" });
191
+ expect(
192
+ withoutActions,
193
+ "no empty actions bar is left behind: only the actions bar's divider goes",
194
+ ).toBe(allowed.container.querySelectorAll("hr").length - 1);
195
+ });
196
+ });
197
+
198
+ describe("PlatformClientDetailPanel last use", () => {
199
+ it("reads Never for a client that never minted a token", () => {
200
+ renderPanel(vi.fn());
201
+ expect(screen.getByText("Last used").nextElementSibling?.textContent).toBe(
202
+ "Never",
203
+ );
204
+ });
205
+
206
+ it("reads how long ago the client last minted a token", () => {
207
+ const used = clone(PlatformClientSchema, PLATFORM_CLIENT);
208
+ const now = new Date("2026-09-28T12:00:00Z");
209
+ used.status = create(PlatformClientStatusSchema, {
210
+ lastUsedAt: timestampFromDate(new Date(now.getTime() - 5 * 60_000)),
211
+ });
212
+ renderPanel(vi.fn(), used, ALLOW_ALL, now);
213
+ expect(screen.getByText("Last used").nextElementSibling?.textContent).toBe(
214
+ "5m",
215
+ );
216
+ });
217
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
5
+ import {
6
+ PlatformClientSchema,
7
+ type PlatformClient,
8
+ } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
9
+ import type { CheckMyPermissionInput } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
10
+ import { StigmerContext } from "../../context";
11
+ import { PlatformClientListPanel } from "../PlatformClientListPanel";
12
+
13
+ /**
14
+ * Pins what each row of the list says and offers:
15
+ *
16
+ * - when the client last minted a token, "Used 5m" in the SDK's compact
17
+ * vocabulary, or "Never used" (stigmer/stigmer#1255);
18
+ * - Edit only with `can_edit` and Delete only with `can_delete` on the
19
+ * client, checked through the real permission hook, so an organization
20
+ * admin who may view a client they did not create is handed neither
21
+ * (stigmer/stigmer#1302);
22
+ * - an empty list says "No platform clients configured." unless the host
23
+ * passes the empty state for a caller who may not manage clients.
24
+ *
25
+ * The data hook is stubbed; the delete hook is never exercised here.
26
+ */
27
+
28
+ const stubs = vi.hoisted(() => ({
29
+ clients: [] as PlatformClient[],
30
+ }));
31
+
32
+ vi.mock("../usePlatformClientList.js", () => ({
33
+ usePlatformClientList: () => ({
34
+ platformClients: stubs.clients,
35
+ isLoading: false,
36
+ isRefetching: false,
37
+ error: null,
38
+ refetch: () => {},
39
+ }),
40
+ }));
41
+
42
+ const NOW = new Date("2026-09-28T12:00:00Z");
43
+
44
+ function client(id: string, name: string, lastUsedAt?: Date): PlatformClient {
45
+ return create(PlatformClientSchema, {
46
+ metadata: { id, name, slug: id, org: "acme" },
47
+ spec: { clientId: `cid-${id}`, neverExpires: true },
48
+ ...(lastUsedAt !== undefined
49
+ ? { status: { lastUsedAt: timestampFromDate(lastUsedAt) } }
50
+ : {}),
51
+ });
52
+ }
53
+
54
+ function renderList(
55
+ checkMyPermission: (
56
+ input: CheckMyPermissionInput,
57
+ ) => Promise<{ isAuthorized: boolean }>,
58
+ props: { emptyState?: string } = {},
59
+ ) {
60
+ const stigmer = { iamPolicy: { checkMyPermission } } as never;
61
+ return render(
62
+ <StigmerContext.Provider value={stigmer}>
63
+ <PlatformClientListPanel
64
+ org="acme"
65
+ onEdit={() => {}}
66
+ now={NOW}
67
+ {...props}
68
+ />
69
+ </StigmerContext.Provider>,
70
+ );
71
+ }
72
+
73
+ afterEach(() => {
74
+ cleanup();
75
+ stubs.clients = [];
76
+ });
77
+
78
+ describe("PlatformClientListPanel rows", () => {
79
+ it("says when each client last minted a token, or that it never did", () => {
80
+ stubs.clients = [
81
+ client("pcl_used", "Dashboard", new Date(NOW.getTime() - 5 * 60_000)),
82
+ client("pcl_idle", "Mobile"),
83
+ ];
84
+ renderList(async () => ({ isAuthorized: true }));
85
+
86
+ expect(screen.getByText("Used 5m")).toBeTruthy();
87
+ expect(screen.getByText("Never used")).toBeTruthy();
88
+ });
89
+
90
+ it("offers Edit only with can_edit and Delete only with can_delete on the client", async () => {
91
+ stubs.clients = [client("pcl_dash", "Dashboard")];
92
+ const checkMyPermission = vi.fn(async (input: CheckMyPermissionInput) => ({
93
+ isAuthorized: input.relation === "can_delete",
94
+ }));
95
+ renderList(checkMyPermission);
96
+
97
+ await screen.findByRole("button", { name: "Delete Dashboard" });
98
+ expect(screen.queryByRole("button", { name: "Edit Dashboard" })).toBeNull();
99
+ const asked = checkMyPermission.mock.calls.map(
100
+ ([input]) =>
101
+ `${input.resource?.kind}:${input.resource?.id}:${input.relation}`,
102
+ );
103
+ expect(asked.sort()).toEqual([
104
+ "platform_client:pcl_dash:can_delete",
105
+ "platform_client:pcl_dash:can_edit",
106
+ ]);
107
+ });
108
+
109
+ it("an admin who did not create the client sees it with neither control", async () => {
110
+ stubs.clients = [client("pcl_dash", "Dashboard")];
111
+ const checkMyPermission = vi.fn(async (_input: CheckMyPermissionInput) => ({
112
+ isAuthorized: false,
113
+ }));
114
+ renderList(checkMyPermission);
115
+
116
+ await waitFor(() => expect(checkMyPermission).toHaveBeenCalledTimes(2));
117
+ expect(screen.getByText("Dashboard")).toBeTruthy();
118
+ await waitFor(() => {
119
+ expect(screen.queryByRole("button", { name: "Edit Dashboard" })).toBeNull();
120
+ expect(screen.queryByRole("button", { name: "Delete Dashboard" })).toBeNull();
121
+ });
122
+ });
123
+ });
124
+
125
+ describe("PlatformClientListPanel empty state", () => {
126
+ it("says none is configured by default", () => {
127
+ renderList(async () => ({ isAuthorized: true }));
128
+ expect(screen.getByText("No platform clients configured.")).toBeTruthy();
129
+ });
130
+
131
+ it("says what the host passes instead", () => {
132
+ renderList(async () => ({ isAuthorized: true }), {
133
+ emptyState: "Platform clients are managed by your organization's admins.",
134
+ });
135
+ expect(
136
+ screen.getByText("Platform clients are managed by your organization's admins."),
137
+ ).toBeTruthy();
138
+ expect(screen.queryByText("No platform clients configured.")).toBeNull();
139
+ });
140
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Platform-client copy shared by more than one surface, so the settings
3
+ * section and anything else that lists an organization's platform clients
4
+ * say the same thing.
5
+ *
6
+ * A platform client is a credential: its creator and the organization's
7
+ * admins see it, and other members may not see it at all. An empty list is
8
+ * therefore not evidence that none exists, so a caller who may not create
9
+ * platform clients is told who manages them instead of being told that
10
+ * nothing is configured (stigmer/stigmer#1302).
11
+ */
12
+
13
+ /** Shown in place of the empty state to a caller who may not create platform clients. */
14
+ export const PLATFORM_CLIENTS_MANAGED_BY_ADMINS =
15
+ "Platform clients are managed by your organization's admins.";
@@ -7,8 +7,10 @@ import { PlatformClientListPanel } from "../platform-client/PlatformClientListPa
7
7
  import { CreatePlatformClientForm } from "../platform-client/CreatePlatformClientForm.js";
8
8
  import { PlatformClientDetailPanel } from "../platform-client/PlatformClientDetailPanel.js";
9
9
  import { PlatformClientSecretAlert } from "../platform-client/PlatformClientSecretAlert.js";
10
+ import { PLATFORM_CLIENTS_MANAGED_BY_ADMINS } from "../platform-client/copy.js";
11
+ import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
10
12
  import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
11
- import { useActiveOrgSlug } from "../organization/OrgProvider.js";
13
+ import { useActiveOrgId, useActiveOrgSlug } from "../organization/OrgProvider.js";
12
14
  import { useServerInfo } from "../server-info.js";
13
15
 
14
16
  type FlowState =
@@ -32,13 +34,30 @@ type FlowState =
32
34
  * A server too old to say either way is offered the button, and its own
33
35
  * answer to the create decides: the section never claims a limitation the
34
36
  * server did not report. Until the server has answered, it offers neither.
37
+ *
38
+ * The button also needs the caller to hold `can_create_platform_client` on
39
+ * the organization, which the model gives its admins. The list shows only
40
+ * the clients the caller may view (each client's creator and the
41
+ * organization's admins), so a caller who may not create them is told that
42
+ * the organization's admins manage them rather than that none is
43
+ * configured.
35
44
  */
36
45
  export function PlatformClientsSection() {
37
46
  const headingId = useId();
38
47
  const org = useActiveOrgSlug();
48
+ const orgId = useActiveOrgId();
39
49
  const { serverInfo } = useServerInfo();
40
50
  const trustsEveryRequest = serverInfo?.authenticationRequired === false;
41
51
  const canMint = serverInfo !== null && !trustsEveryRequest;
52
+ const createCheck = useCheckPermission(
53
+ orgId ? { kind: "organization", id: orgId } : null,
54
+ "can_create_platform_client",
55
+ );
56
+ const canCreate = !createCheck.isLoading && createCheck.allowed;
57
+ const deniedCreate = !createCheck.isLoading && !createCheck.allowed;
58
+ const emptyState = deniedCreate
59
+ ? PLATFORM_CLIENTS_MANAGED_BY_ADMINS
60
+ : undefined;
42
61
 
43
62
  const [flow, setFlow] = useState<FlowState>({ phase: "idle" });
44
63
  const listRefetchRef = useRef<(() => void) | null>(null);
@@ -93,7 +112,7 @@ export function PlatformClientsSection() {
93
112
  Platform Clients
94
113
  </h2>
95
114
 
96
- {canMint && org && flow.phase === "idle" && (
115
+ {canMint && canCreate && org && flow.phase === "idle" && (
97
116
  <button
98
117
  type="button"
99
118
  onClick={() => setFlow({ phase: "creating" })}
@@ -140,6 +159,7 @@ export function PlatformClientsSection() {
140
159
  setFlow({ phase: "editing", platformClient: pc })
141
160
  }
142
161
  onRefetchRef={handleRefetchRef}
162
+ emptyState={emptyState}
143
163
  />
144
164
  </div>
145
165
  ) : flow.phase === "editing" ? (
@@ -159,6 +179,7 @@ export function PlatformClientsSection() {
159
179
  setFlow({ phase: "editing", platformClient: pc })
160
180
  }
161
181
  onRefetchRef={handleRefetchRef}
182
+ emptyState={emptyState}
162
183
  />
163
184
  )}
164
185
  </section>
@@ -7,8 +7,15 @@
7
7
  * old to report its posture it offers the button and no notice, because a
8
8
  * console that ships ahead of its server must not claim a limitation the
9
9
  * server never reported; while the server's answer is still loading it
10
- * offers neither, so no button flashes and disappears. The list panel is
11
- * proven by its own tests and stubbed here.
10
+ * offers neither, so no button flashes and disappears.
11
+ *
12
+ * And its caller gate (stigmer/stigmer#1302): the button also needs
13
+ * `can_create_platform_client` on the organization, checked on the
14
+ * organization's id and never offered while the check is in flight; a
15
+ * caller the server refuses is told the organization's admins manage
16
+ * platform clients, in place of the list's own empty state. The list panel
17
+ * is proven by its own tests and stubbed here; the permission check is
18
+ * stubbed.
12
19
  */
13
20
  import { describe, it, expect, vi, afterEach } from "vitest";
14
21
  import { cleanup, render, screen } from "@testing-library/react";
@@ -16,15 +23,46 @@ import type { UseServerInfoReturn } from "../../server-info";
16
23
 
17
24
  let serverInfo: UseServerInfoReturn;
18
25
 
26
+ const permission = vi.hoisted(() => ({
27
+ allowed: true,
28
+ isLoading: false,
29
+ checked: [] as Array<[string, string, string]>,
30
+ }));
31
+
19
32
  vi.mock("../../server-info.js", () => ({
20
33
  useServerInfo: () => serverInfo,
21
34
  }));
22
35
  vi.mock("../../organization/OrgProvider.js", () => ({
23
36
  useActiveOrgSlug: () => "acme",
37
+ useActiveOrgId: () => "org_acme",
38
+ }));
39
+ vi.mock("../../iam-policy/useCheckPermission.js", () => ({
40
+ useCheckPermission: (
41
+ resource: { kind: string; id: string } | null,
42
+ relation: string,
43
+ ) => {
44
+ if (resource !== null) {
45
+ permission.checked.push([resource.kind, resource.id, relation]);
46
+ }
47
+ return {
48
+ allowed: permission.allowed,
49
+ isLoading: permission.isLoading,
50
+ error: null,
51
+ };
52
+ },
24
53
  }));
25
54
  vi.mock("../../platform-client/PlatformClientListPanel.js", () => ({
26
- PlatformClientListPanel: ({ org }: { org: string }) => (
27
- <div data-testid="list-panel">clients of {org}</div>
55
+ PlatformClientListPanel: ({
56
+ org,
57
+ emptyState,
58
+ }: {
59
+ org: string;
60
+ emptyState?: string;
61
+ }) => (
62
+ <div data-testid="list-panel">
63
+ clients of {org}
64
+ {emptyState !== undefined && <p data-testid="empty-state">{emptyState}</p>}
65
+ </div>
28
66
  ),
29
67
  }));
30
68
 
@@ -52,7 +90,12 @@ const LOADING: UseServerInfoReturn = {
52
90
  error: null,
53
91
  };
54
92
 
55
- afterEach(cleanup);
93
+ afterEach(() => {
94
+ cleanup();
95
+ permission.allowed = true;
96
+ permission.isLoading = false;
97
+ permission.checked = [];
98
+ });
56
99
 
57
100
  describe("PlatformClientsSection's posture gate", () => {
58
101
  it("offers the create button and no notice on a server that authenticates its callers", () => {
@@ -97,3 +140,47 @@ describe("PlatformClientsSection's posture gate", () => {
97
140
  expect(screen.queryByText(/trusts every request/i)).toBeNull();
98
141
  });
99
142
  });
143
+
144
+ describe("PlatformClientsSection's caller gate", () => {
145
+ it("checks can_create_platform_client on the active organization's id", () => {
146
+ serverInfo = answering(true);
147
+ render(<PlatformClientsSection />);
148
+ expect(permission.checked).toContainEqual([
149
+ "organization",
150
+ "org_acme",
151
+ "can_create_platform_client",
152
+ ]);
153
+ });
154
+
155
+ it("offers the create button to a caller who may create, and keeps the list's own empty state", () => {
156
+ serverInfo = answering(true);
157
+ render(<PlatformClientsSection />);
158
+ expect(
159
+ screen.getByRole("button", { name: /new platform client/i }),
160
+ ).toBeTruthy();
161
+ expect(screen.queryByTestId("empty-state")).toBeNull();
162
+ });
163
+
164
+ it("offers no create button to a caller the server refuses, and says who manages platform clients", () => {
165
+ serverInfo = answering(true);
166
+ permission.allowed = false;
167
+ render(<PlatformClientsSection />);
168
+ expect(
169
+ screen.queryByRole("button", { name: /new platform client/i }),
170
+ ).toBeNull();
171
+ expect(screen.getByTestId("empty-state").textContent).toBe(
172
+ "Platform clients are managed by your organization's admins.",
173
+ );
174
+ });
175
+
176
+ it("offers nothing and claims nothing while the check is in flight", () => {
177
+ serverInfo = answering(true);
178
+ permission.isLoading = true;
179
+ permission.allowed = false;
180
+ render(<PlatformClientsSection />);
181
+ expect(
182
+ screen.queryByRole("button", { name: /new platform client/i }),
183
+ ).toBeNull();
184
+ expect(screen.queryByTestId("empty-state")).toBeNull();
185
+ });
186
+ });