@stigmer/react 3.31.0 → 3.33.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.
- package/api-key/ApiKeyListPanel.js +2 -4
- package/api-key/ApiKeyListPanel.js.map +1 -1
- package/iam-policy/OrgMembersPanel.d.ts +7 -0
- package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
- package/iam-policy/OrgMembersPanel.js +28 -12
- package/iam-policy/OrgMembersPanel.js.map +1 -1
- package/iam-policy/RoleSelector.d.ts +7 -1
- package/iam-policy/RoleSelector.d.ts.map +1 -1
- package/iam-policy/RoleSelector.js +2 -2
- package/iam-policy/RoleSelector.js.map +1 -1
- package/iam-policy/useOwnerAssignment.d.ts +23 -0
- package/iam-policy/useOwnerAssignment.d.ts.map +1 -0
- package/iam-policy/useOwnerAssignment.js +23 -0
- package/iam-policy/useOwnerAssignment.js.map +1 -0
- package/iam-policy/useRoleSelector.d.ts +6 -1
- package/iam-policy/useRoleSelector.d.ts.map +1 -1
- package/iam-policy/useRoleSelector.js +11 -3
- package/iam-policy/useRoleSelector.js.map +1 -1
- package/internal/LastUsedLabel.d.ts +20 -0
- package/internal/LastUsedLabel.d.ts.map +1 -0
- package/internal/LastUsedLabel.js +23 -0
- package/internal/LastUsedLabel.js.map +1 -0
- package/invitation/InvitationManager.d.ts.map +1 -1
- package/invitation/InvitationManager.js +5 -1
- package/invitation/InvitationManager.js.map +1 -1
- package/package.json +5 -5
- package/platform-client/PlatformClientDetailPanel.d.ts +13 -3
- package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
- package/platform-client/PlatformClientDetailPanel.js +28 -10
- package/platform-client/PlatformClientDetailPanel.js.map +1 -1
- package/platform-client/PlatformClientListPanel.d.ts +19 -3
- package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
- package/platform-client/PlatformClientListPanel.js +13 -8
- package/platform-client/PlatformClientListPanel.js.map +1 -1
- package/platform-client/copy.d.ts +14 -0
- package/platform-client/copy.d.ts.map +1 -0
- package/platform-client/copy.js +14 -0
- package/platform-client/copy.js.map +1 -0
- package/platform-client/expiry.d.ts +4 -0
- package/platform-client/expiry.d.ts.map +1 -0
- package/platform-client/expiry.js +18 -0
- package/platform-client/expiry.js.map +1 -0
- package/settings/MembersSection.d.ts.map +1 -1
- package/settings/MembersSection.js +1 -1
- package/settings/MembersSection.js.map +1 -1
- package/settings/PlatformClientsSection.d.ts +7 -0
- package/settings/PlatformClientsSection.d.ts.map +1 -1
- package/settings/PlatformClientsSection.js +18 -2
- package/settings/PlatformClientsSection.js.map +1 -1
- package/src/api-key/ApiKeyListPanel.tsx +2 -6
- package/src/iam-policy/OrgMembersPanel.tsx +38 -8
- package/src/iam-policy/RoleSelector.tsx +8 -0
- package/src/iam-policy/__tests__/OrgMembersPanel.test.tsx +139 -0
- package/src/iam-policy/__tests__/useOwnerAssignment.test.tsx +55 -0
- package/src/iam-policy/useOwnerAssignment.ts +40 -0
- package/src/iam-policy/useRoleSelector.ts +14 -3
- package/src/internal/LastUsedLabel.tsx +41 -0
- package/src/invitation/InvitationManager.tsx +5 -0
- package/src/platform-client/PlatformClientDetailPanel.tsx +69 -12
- package/src/platform-client/PlatformClientListPanel.tsx +57 -21
- package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +139 -4
- package/src/platform-client/__tests__/PlatformClientListPanel.test.tsx +140 -0
- package/src/platform-client/__tests__/expiry.test.ts +37 -0
- package/src/platform-client/copy.ts +15 -0
- package/src/platform-client/expiry.ts +22 -0
- package/src/settings/MembersSection.tsx +2 -1
- package/src/settings/PlatformClientsSection.tsx +23 -2
- package/src/settings/__tests__/PlatformClientsSection.test.tsx +92 -5
|
@@ -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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the one expiry rule the platform-client panels share (#1254): it is
|
|
3
|
+
* the server's rule, so a client reads expired exactly when the mint would
|
|
4
|
+
* refuse it — an expiry set, never_expires off, and the expiry passed.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { create } from "@bufbuild/protobuf";
|
|
8
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
9
|
+
import { PlatformClientSpecSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/spec_pb";
|
|
10
|
+
|
|
11
|
+
import { isPlatformClientExpired } from "../expiry";
|
|
12
|
+
|
|
13
|
+
const NOW = new Date("2026-09-28T12:00:00Z");
|
|
14
|
+
const before = timestampFromDate(new Date(NOW.getTime() - 1000));
|
|
15
|
+
const after = timestampFromDate(new Date(NOW.getTime() + 1000));
|
|
16
|
+
|
|
17
|
+
describe("isPlatformClientExpired", () => {
|
|
18
|
+
it("is true only for a passed expiry with never_expires off", () => {
|
|
19
|
+
expect(
|
|
20
|
+
isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: before }), NOW),
|
|
21
|
+
).toBe(true);
|
|
22
|
+
expect(
|
|
23
|
+
isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: after }), NOW),
|
|
24
|
+
).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("never_expires, an unset expiry and a missing spec never read expired", () => {
|
|
28
|
+
expect(
|
|
29
|
+
isPlatformClientExpired(
|
|
30
|
+
create(PlatformClientSpecSchema, { expiresAt: before, neverExpires: true }),
|
|
31
|
+
NOW,
|
|
32
|
+
),
|
|
33
|
+
).toBe(false);
|
|
34
|
+
expect(isPlatformClientExpired(create(PlatformClientSpecSchema, {}), NOW)).toBe(false);
|
|
35
|
+
expect(isPlatformClientExpired(undefined, NOW)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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.";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule for "this PlatformClient has expired", shared by the list's
|
|
3
|
+
* expiry badge, the detail view and the rotate confirmation so the three
|
|
4
|
+
* never disagree. It is the server's rule, the one the mint refuses by: an
|
|
5
|
+
* expiry is set, `never_expires` is off, and the expiry has passed.
|
|
6
|
+
*
|
|
7
|
+
* Package-internal on purpose: every name the barrel exports is a public
|
|
8
|
+
* API contract, and no platform builder has asked for this one.
|
|
9
|
+
*/
|
|
10
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
11
|
+
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
12
|
+
|
|
13
|
+
/** Whether the client's secret no longer mints (its expiry has passed). */
|
|
14
|
+
export function isPlatformClientExpired(
|
|
15
|
+
spec: PlatformClient["spec"],
|
|
16
|
+
now: Date = new Date(),
|
|
17
|
+
): boolean {
|
|
18
|
+
if (spec === undefined || spec.neverExpires || spec.expiresAt === undefined) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return timestampDate(spec.expiresAt).getTime() < now.getTime();
|
|
22
|
+
}
|
|
@@ -45,7 +45,8 @@ export function MembersSection() {
|
|
|
45
45
|
</h2>
|
|
46
46
|
<p className="stg:text-muted-foreground stg:mb-4 stg:text-xs">
|
|
47
47
|
Manage who has access to this organization and what they can do.
|
|
48
|
-
Members can be granted owner, admin, member, or viewer roles
|
|
48
|
+
Members can be granted owner, admin, member, or viewer roles; only
|
|
49
|
+
an owner grants or removes owner.
|
|
49
50
|
{!invitationsAvailable && (
|
|
50
51
|
<>
|
|
51
52
|
{" "}
|
|
@@ -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.
|
|
11
|
-
*
|
|
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: ({
|
|
27
|
-
|
|
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(
|
|
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
|
+
});
|