@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 @@
|
|
|
1
|
+
{"version":3,"file":"copy.js","sourceRoot":"","sources":["../../src/platform-client/copy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,yFAAyF;AACzF,MAAM,CAAC,MAAM,kCAAkC,GAC7C,6DAA6D,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
2
|
+
/** Whether the client's secret no longer mints (its expiry has passed). */
|
|
3
|
+
export declare function isPlatformClientExpired(spec: PlatformClient["spec"], now?: Date): boolean;
|
|
4
|
+
//# sourceMappingURL=expiry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expiry.d.ts","sourceRoot":"","sources":["../../src/platform-client/expiry.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yDAAyD,CAAC;AAE9F,2EAA2E;AAC3E,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,EAC5B,GAAG,GAAE,IAAiB,GACrB,OAAO,CAKT"}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
/** Whether the client's secret no longer mints (its expiry has passed). */
|
|
12
|
+
export function isPlatformClientExpired(spec, now = new Date()) {
|
|
13
|
+
if (spec === undefined || spec.neverExpires || spec.expiresAt === undefined) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return timestampDate(spec.expiresAt).getTime() < now.getTime();
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=expiry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expiry.js","sourceRoot":"","sources":["../../src/platform-client/expiry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAGvD,2EAA2E;AAC3E,MAAM,UAAU,uBAAuB,CACrC,IAA4B,EAC5B,MAAY,IAAI,IAAI,EAAE;IAEtB,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,YAAY,IAAI,IAAI,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;AACjE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MembersSection.d.ts","sourceRoot":"","sources":["../../src/settings/MembersSection.tsx"],"names":[],"mappings":"AAQA;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,
|
|
1
|
+
{"version":3,"file":"MembersSection.d.ts","sourceRoot":"","sources":["../../src/settings/MembersSection.tsx"],"names":[],"mappings":"AAQA;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,4CA4D7B"}
|
|
@@ -25,6 +25,6 @@ export function MembersSection() {
|
|
|
25
25
|
const orgSlug = activeOrg?.metadata?.slug ?? "";
|
|
26
26
|
const { identityProviders } = useIdentityProviderList(idpAvailable && orgSlug ? orgSlug : null);
|
|
27
27
|
const hasJitProviders = identityProviders.some((idp) => idp.spec?.autoProvisionAccounts || idp.spec?.isSsoProvider);
|
|
28
|
-
return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:mb-1 stg:text-sm stg:font-semibold", children: "Members" }), _jsxs("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: ["Manage who has access to this organization and what they can do. Members can be granted owner, admin, member, or viewer roles.", !invitationsAvailable && (_jsxs(_Fragment, { children: [" ", "There are no invitations on this edition: people join this organization the first time they sign in, and you adjust their roles here."] }))] }), !orgId ? (_jsx("p", { className: "stg:text-muted-foreground stg:py-4 stg:text-center stg:text-xs", children: "Select an organization to manage members." })) : (_jsxs(_Fragment, { children: [hasJitProviders && (_jsx("div", { className: "stg:mb-3 stg:rounded-md stg:border stg:border-border-muted stg:bg-muted-faint stg:px-3 stg:py-2", children: _jsx("p", { className: "stg:text-[0.65rem] stg:text-muted-foreground", children: "This organization has identity providers with auto-provisioning enabled. Members may appear here automatically when users authenticate via federated identity." }) })), _jsx(OrgMembersPanel, { orgId: orgId })] }))] }));
|
|
28
|
+
return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:mb-1 stg:text-sm stg:font-semibold", children: "Members" }), _jsxs("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: ["Manage who has access to this organization and what they can do. Members can be granted owner, admin, member, or viewer roles; only an owner grants or removes owner.", !invitationsAvailable && (_jsxs(_Fragment, { children: [" ", "There are no invitations on this edition: people join this organization the first time they sign in, and you adjust their roles here."] }))] }), !orgId ? (_jsx("p", { className: "stg:text-muted-foreground stg:py-4 stg:text-center stg:text-xs", children: "Select an organization to manage members." })) : (_jsxs(_Fragment, { children: [hasJitProviders && (_jsx("div", { className: "stg:mb-3 stg:rounded-md stg:border stg:border-border-muted stg:bg-muted-faint stg:px-3 stg:py-2", children: _jsx("p", { className: "stg:text-[0.65rem] stg:text-muted-foreground", children: "This organization has identity providers with auto-provisioning enabled. Members may appear here automatically when users authenticate via federated identity." }) })), _jsx(OrgMembersPanel, { orgId: orgId })] }))] }));
|
|
29
29
|
}
|
|
30
30
|
//# sourceMappingURL=MembersSection.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MembersSection.js","sourceRoot":"","sources":["../../src/settings/MembersSection.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,MAAM,EAAE,MAAM,gCAAgC,CAAC;AACxD,OAAO,EAAE,uBAAuB,EAAE,MAAM,iDAAiD,CAAC;AAE1F;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC;IAC/B,MAAM,oBAAoB,GAAG,oBAAoB,CAC/C,eAAe,CAAC,UAAU,CAC3B,CAAC;IACF,MAAM,YAAY,GAAG,oBAAoB,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;IAC7E,MAAM,KAAK,GAAG,SAAS,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAC5C,MAAM,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,IAAI,IAAI,EAAE,CAAC;IAEhD,MAAM,EAAE,iBAAiB,EAAE,GAAG,uBAAuB,CACnD,YAAY,IAAI,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CACzC,CAAC;IAEF,MAAM,eAAe,GAAG,iBAAiB,CAAC,IAAI,CAC5C,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,qBAAqB,IAAI,GAAG,CAAC,IAAI,EAAE,aAAa,CACpE,CAAC;IAEF,OAAO,CACL,sCAA0B,SAAS,aACjC,aACE,EAAE,EAAE,SAAS,EACb,SAAS,EAAC,4DAA4D,wBAGnE,EACL,aAAG,SAAS,EAAC,gDAAgD
|
|
1
|
+
{"version":3,"file":"MembersSection.js","sourceRoot":"","sources":["../../src/settings/MembersSection.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC9E,OAAO,EAAE,MAAM,EAAE,MAAM,gCAAgC,CAAC;AACxD,OAAO,EAAE,uBAAuB,EAAE,MAAM,iDAAiD,CAAC;AAE1F;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC;IAC/B,MAAM,oBAAoB,GAAG,oBAAoB,CAC/C,eAAe,CAAC,UAAU,CAC3B,CAAC;IACF,MAAM,YAAY,GAAG,oBAAoB,CAAC,eAAe,CAAC,iBAAiB,CAAC,CAAC;IAC7E,MAAM,KAAK,GAAG,SAAS,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAC5C,MAAM,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,IAAI,IAAI,EAAE,CAAC;IAEhD,MAAM,EAAE,iBAAiB,EAAE,GAAG,uBAAuB,CACnD,YAAY,IAAI,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CACzC,CAAC;IAEF,MAAM,eAAe,GAAG,iBAAiB,CAAC,IAAI,CAC5C,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,qBAAqB,IAAI,GAAG,CAAC,IAAI,EAAE,aAAa,CACpE,CAAC;IAEF,OAAO,CACL,sCAA0B,SAAS,aACjC,aACE,EAAE,EAAE,SAAS,EACb,SAAS,EAAC,4DAA4D,wBAGnE,EACL,aAAG,SAAS,EAAC,gDAAgD,sLAI1D,CAAC,oBAAoB,IAAI,CACxB,8BACG,GAAG,6IAIH,CACJ,IACC,EAEH,CAAC,KAAK,CAAC,CAAC,CAAC,CACR,YAAG,SAAS,EAAC,gEAAgE,0DAEzE,CACL,CAAC,CAAC,CAAC,CACF,8BACG,eAAe,IAAI,CAClB,cAAK,SAAS,EAAC,iGAAiG,YAC9G,YAAG,SAAS,EAAC,8CAA8C,+KAIvD,GACA,CACP,EACD,KAAC,eAAe,IAAC,KAAK,EAAE,KAAK,GAAI,IAChC,CACJ,IACO,CACX,CAAC;AACJ,CAAC"}
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* A server too old to say either way is offered the button, and its own
|
|
9
9
|
* answer to the create decides: the section never claims a limitation the
|
|
10
10
|
* server did not report. Until the server has answered, it offers neither.
|
|
11
|
+
*
|
|
12
|
+
* The button also needs the caller to hold `can_create_platform_client` on
|
|
13
|
+
* the organization, which the model gives its admins. The list shows only
|
|
14
|
+
* the clients the caller may view (each client's creator and the
|
|
15
|
+
* organization's admins), so a caller who may not create them is told that
|
|
16
|
+
* the organization's admins manage them rather than that none is
|
|
17
|
+
* configured.
|
|
11
18
|
*/
|
|
12
19
|
export declare function PlatformClientsSection(): import("react/jsx-runtime").JSX.Element;
|
|
13
20
|
//# sourceMappingURL=PlatformClientsSection.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PlatformClientsSection.d.ts","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PlatformClientsSection.d.ts","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,4CA8IrC"}
|
|
@@ -5,8 +5,10 @@ import { PlatformClientListPanel } from "../platform-client/PlatformClientListPa
|
|
|
5
5
|
import { CreatePlatformClientForm } from "../platform-client/CreatePlatformClientForm.js";
|
|
6
6
|
import { PlatformClientDetailPanel } from "../platform-client/PlatformClientDetailPanel.js";
|
|
7
7
|
import { PlatformClientSecretAlert } from "../platform-client/PlatformClientSecretAlert.js";
|
|
8
|
+
import { PLATFORM_CLIENTS_MANAGED_BY_ADMINS } from "../platform-client/copy.js";
|
|
9
|
+
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
8
10
|
import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
|
|
9
|
-
import { useActiveOrgSlug } from "../organization/OrgProvider.js";
|
|
11
|
+
import { useActiveOrgId, useActiveOrgSlug } from "../organization/OrgProvider.js";
|
|
10
12
|
import { useServerInfo } from "../server-info.js";
|
|
11
13
|
/**
|
|
12
14
|
* Settings section for creating and maintaining platform clients.
|
|
@@ -18,13 +20,27 @@ import { useServerInfo } from "../server-info.js";
|
|
|
18
20
|
* A server too old to say either way is offered the button, and its own
|
|
19
21
|
* answer to the create decides: the section never claims a limitation the
|
|
20
22
|
* server did not report. Until the server has answered, it offers neither.
|
|
23
|
+
*
|
|
24
|
+
* The button also needs the caller to hold `can_create_platform_client` on
|
|
25
|
+
* the organization, which the model gives its admins. The list shows only
|
|
26
|
+
* the clients the caller may view (each client's creator and the
|
|
27
|
+
* organization's admins), so a caller who may not create them is told that
|
|
28
|
+
* the organization's admins manage them rather than that none is
|
|
29
|
+
* configured.
|
|
21
30
|
*/
|
|
22
31
|
export function PlatformClientsSection() {
|
|
23
32
|
const headingId = useId();
|
|
24
33
|
const org = useActiveOrgSlug();
|
|
34
|
+
const orgId = useActiveOrgId();
|
|
25
35
|
const { serverInfo } = useServerInfo();
|
|
26
36
|
const trustsEveryRequest = serverInfo?.authenticationRequired === false;
|
|
27
37
|
const canMint = serverInfo !== null && !trustsEveryRequest;
|
|
38
|
+
const createCheck = useCheckPermission(orgId ? { kind: "organization", id: orgId } : null, "can_create_platform_client");
|
|
39
|
+
const canCreate = !createCheck.isLoading && createCheck.allowed;
|
|
40
|
+
const deniedCreate = !createCheck.isLoading && !createCheck.allowed;
|
|
41
|
+
const emptyState = deniedCreate
|
|
42
|
+
? PLATFORM_CLIENTS_MANAGED_BY_ADMINS
|
|
43
|
+
: undefined;
|
|
28
44
|
const [flow, setFlow] = useState({ phase: "idle" });
|
|
29
45
|
const listRefetchRef = useRef(null);
|
|
30
46
|
const handleRefetchRef = useCallback((refetch) => {
|
|
@@ -56,6 +72,6 @@ export function PlatformClientsSection() {
|
|
|
56
72
|
listRefetchRef.current?.();
|
|
57
73
|
setFlow({ phase: "idle" });
|
|
58
74
|
}, []);
|
|
59
|
-
return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsxs("div", { className: "stg:mb-3 stg:flex stg:items-center stg:justify-between", children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:text-sm stg:font-semibold", children: "Platform Clients" }), canMint && org && flow.phase === "idle" && (_jsx("button", { type: "button", onClick: () => setFlow({ phase: "creating" }), className: "stg:text-primary stg:hover:text-foreground stg:text-xs stg:font-medium stg:transition-colors", children: "+ New platform client" }))] }), _jsx("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: "Platform clients let your backend mint Stigmer user tokens so you can embed Stigmer components in your application without requiring OIDC federation." }), trustsEveryRequest && (_jsxs(CloudFeatureNotice, { className: "stg:mb-4", children: ["This server trusts every request, so nothing would verify a platform client's tokens and it does not mint them. Configure an identity provider (", _jsx("code", { children: "STIGMER_OIDC_ISSUER" }), ") to create platform clients."] })), flow.phase === "creating" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(CreatePlatformClientForm, { org: org, onCreated: handleCreated, onCancel: () => setFlow({ phase: "idle" }) }) })) : flow.phase === "revealing" ? (_jsxs("div", { className: "stg:space-y-4", children: [_jsx(PlatformClientSecretAlert, { clientId: flow.clientId, clientSecret: flow.clientSecret, context: flow.context, onDismiss: () => setFlow({ phase: "idle" }) }), _jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef })] })) : flow.phase === "editing" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(PlatformClientDetailPanel, { platformClient: flow.platformClient, onUpdated: handleUpdated, onSecretRotated: handleSecretRotated, onDeleted: handleDeleted, onBack: () => setFlow({ phase: "idle" }) }) })) : (_jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef }))] }));
|
|
75
|
+
return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsxs("div", { className: "stg:mb-3 stg:flex stg:items-center stg:justify-between", children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:text-sm stg:font-semibold", children: "Platform Clients" }), canMint && canCreate && org && flow.phase === "idle" && (_jsx("button", { type: "button", onClick: () => setFlow({ phase: "creating" }), className: "stg:text-primary stg:hover:text-foreground stg:text-xs stg:font-medium stg:transition-colors", children: "+ New platform client" }))] }), _jsx("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: "Platform clients let your backend mint Stigmer user tokens so you can embed Stigmer components in your application without requiring OIDC federation." }), trustsEveryRequest && (_jsxs(CloudFeatureNotice, { className: "stg:mb-4", children: ["This server trusts every request, so nothing would verify a platform client's tokens and it does not mint them. Configure an identity provider (", _jsx("code", { children: "STIGMER_OIDC_ISSUER" }), ") to create platform clients."] })), flow.phase === "creating" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(CreatePlatformClientForm, { org: org, onCreated: handleCreated, onCancel: () => setFlow({ phase: "idle" }) }) })) : flow.phase === "revealing" ? (_jsxs("div", { className: "stg:space-y-4", children: [_jsx(PlatformClientSecretAlert, { clientId: flow.clientId, clientSecret: flow.clientSecret, context: flow.context, onDismiss: () => setFlow({ phase: "idle" }) }), _jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef, emptyState: emptyState })] })) : flow.phase === "editing" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(PlatformClientDetailPanel, { platformClient: flow.platformClient, onUpdated: handleUpdated, onSecretRotated: handleSecretRotated, onDeleted: handleDeleted, onBack: () => setFlow({ phase: "idle" }) }) })) : (_jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef, emptyState: emptyState }))] }));
|
|
60
76
|
}
|
|
61
77
|
//# sourceMappingURL=PlatformClientsSection.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PlatformClientsSection.js","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAG7D,OAAO,EAAE,uBAAuB,EAAE,MAAM,+CAA+C,CAAC;AACxF,OAAO,EAAE,wBAAwB,EAAE,MAAM,gDAAgD,CAAC;AAC1F,OAAO,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"PlatformClientsSection.js","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"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"}
|
|
@@ -9,7 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js"
|
|
|
9
9
|
import { useApiKeyList } from "./useApiKeyList.js";
|
|
10
10
|
import { useDeleteApiKey } from "./useDeleteApiKey.js";
|
|
11
11
|
import { SpinnerIcon } from "../internal/SpinnerIcon.js";
|
|
12
|
-
import {
|
|
12
|
+
import { LastUsedLabel } from "../internal/LastUsedLabel.js";
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Public API
|
|
@@ -263,11 +263,7 @@ function ApiKeyRow({
|
|
|
263
263
|
? `Expires ${formatShortDate(timestampDate(expiresAt))}`
|
|
264
264
|
: "No expiry"}
|
|
265
265
|
</span>
|
|
266
|
-
<
|
|
267
|
-
{lastUsedAt
|
|
268
|
-
? formatRelativeTime(timestampDate(lastUsedAt), now)
|
|
269
|
-
: "Never used"}
|
|
270
|
-
</span>
|
|
266
|
+
<LastUsedLabel at={lastUsedAt} now={now} />
|
|
271
267
|
</div>
|
|
272
268
|
|
|
273
269
|
{/* Delete button */}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useState } from "react";
|
|
4
4
|
import { create } from "@bufbuild/protobuf";
|
|
5
5
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
6
|
-
import
|
|
6
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
7
7
|
import type { PrincipalAccess, RoleGrant } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
8
8
|
import {
|
|
9
9
|
IamPolicySpecSchema,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
iamRoleToString,
|
|
17
17
|
} from "@stigmer/sdk";
|
|
18
18
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
|
|
19
|
+
import { useOwnerAssignment } from "./useOwnerAssignment.js";
|
|
19
20
|
import { useResourceAccess } from "./useResourceAccess.js";
|
|
20
21
|
import { usePrincipalsCount } from "./usePrincipalsCount.js";
|
|
21
22
|
import { useWhoAmI } from "./useWhoAmI.js";
|
|
@@ -52,6 +53,13 @@ export interface OrgMembersPanelProps {
|
|
|
52
53
|
* `identityAccount.whoAmI()` for self-protection (disabling
|
|
53
54
|
* destructive actions on yourself).
|
|
54
55
|
*
|
|
56
|
+
* Owner is assigned by owners: the server asks `can_assign_roles` on the
|
|
57
|
+
* organization before any change to the owner role. The panel asks the
|
|
58
|
+
* same question once, and for a caller without it offers no owner in the
|
|
59
|
+
* role picker and no change or removal on an owner's row. A role change
|
|
60
|
+
* grants the new role before it revokes the old one, so a refused step
|
|
61
|
+
* leaves the member with the role they had.
|
|
62
|
+
*
|
|
55
63
|
* All visual properties flow through `--stgm-*` design tokens.
|
|
56
64
|
*
|
|
57
65
|
* @example
|
|
@@ -69,6 +77,7 @@ export function OrgMembersPanel({
|
|
|
69
77
|
const { count, refetch: refetchCount } = usePrincipalsCount(orgId || null);
|
|
70
78
|
const { account: currentAccount } = useWhoAmI();
|
|
71
79
|
const currentAccountId = currentAccount?.metadata?.id ?? null;
|
|
80
|
+
const { canAssignOwner, unassignable } = useOwnerAssignment(orgId || null);
|
|
72
81
|
|
|
73
82
|
const [actionMemberId, setActionMemberId] = useState<string | null>(null);
|
|
74
83
|
|
|
@@ -133,6 +142,8 @@ export function OrgMembersPanel({
|
|
|
133
142
|
entry={entry}
|
|
134
143
|
orgId={orgId}
|
|
135
144
|
isSelf={memberId === currentAccountId}
|
|
145
|
+
canAssignOwner={canAssignOwner}
|
|
146
|
+
unassignable={unassignable}
|
|
136
147
|
isActioning={actionMemberId === memberId}
|
|
137
148
|
onStartAction={() => setActionMemberId(memberId)}
|
|
138
149
|
onEndAction={() => setActionMemberId(null)}
|
|
@@ -154,6 +165,8 @@ function MemberRow({
|
|
|
154
165
|
entry,
|
|
155
166
|
orgId,
|
|
156
167
|
isSelf,
|
|
168
|
+
canAssignOwner,
|
|
169
|
+
unassignable,
|
|
157
170
|
isActioning,
|
|
158
171
|
onStartAction,
|
|
159
172
|
onEndAction,
|
|
@@ -162,6 +175,10 @@ function MemberRow({
|
|
|
162
175
|
entry: PrincipalAccess;
|
|
163
176
|
orgId: string;
|
|
164
177
|
isSelf: boolean;
|
|
178
|
+
/** Whether the caller may grant, revoke or remove the owner role here. */
|
|
179
|
+
canAssignOwner: boolean;
|
|
180
|
+
/** The roles the caller may not assign, left out of the role picker. */
|
|
181
|
+
unassignable: readonly IamRole[];
|
|
165
182
|
isActioning: boolean;
|
|
166
183
|
onStartAction: () => void;
|
|
167
184
|
onEndAction: () => void;
|
|
@@ -177,6 +194,11 @@ function MemberRow({
|
|
|
177
194
|
const email = principal?.email ?? "";
|
|
178
195
|
const initial = name.charAt(0).toUpperCase();
|
|
179
196
|
const directRoles = entry.roles.filter((r) => !r.isInherited);
|
|
197
|
+
const holdsOwner = directRoles.some(
|
|
198
|
+
(grant) => grant.role?.code === iamRoleToString(IamRole.owner),
|
|
199
|
+
);
|
|
200
|
+
// Changing or removing an owner changes the owner role: an owner's act.
|
|
201
|
+
const mayAct = !isSelf && (canAssignOwner || !holdsOwner);
|
|
180
202
|
|
|
181
203
|
const startAction = (type: "remove" | "change-role") => {
|
|
182
204
|
setActionType(type);
|
|
@@ -208,6 +230,7 @@ function MemberRow({
|
|
|
208
230
|
<ChangeRoleRow
|
|
209
231
|
entry={entry}
|
|
210
232
|
orgId={orgId}
|
|
233
|
+
omitRoles={unassignable}
|
|
211
234
|
onDone={() => {
|
|
212
235
|
cancelAction();
|
|
213
236
|
onMutated();
|
|
@@ -255,7 +278,7 @@ function MemberRow({
|
|
|
255
278
|
</div>
|
|
256
279
|
|
|
257
280
|
{/* Actions */}
|
|
258
|
-
{
|
|
281
|
+
{mayAct && (
|
|
259
282
|
<div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1">
|
|
260
283
|
<button
|
|
261
284
|
type="button"
|
|
@@ -398,11 +421,14 @@ function RemoveConfirmation({
|
|
|
398
421
|
function ChangeRoleRow({
|
|
399
422
|
entry,
|
|
400
423
|
orgId,
|
|
424
|
+
omitRoles,
|
|
401
425
|
onDone,
|
|
402
426
|
onCancel,
|
|
403
427
|
}: {
|
|
404
428
|
entry: PrincipalAccess;
|
|
405
429
|
orgId: string;
|
|
430
|
+
/** The roles the caller may not assign, left out of the picker. */
|
|
431
|
+
omitRoles: readonly IamRole[];
|
|
406
432
|
onDone: () => void;
|
|
407
433
|
onCancel: () => void;
|
|
408
434
|
}) {
|
|
@@ -429,7 +455,10 @@ function ChangeRoleRow({
|
|
|
429
455
|
setError(null);
|
|
430
456
|
|
|
431
457
|
try {
|
|
432
|
-
|
|
458
|
+
// Grant first, then revoke: a refusal of either step (the owner rule,
|
|
459
|
+
// an organization's last owner) leaves the member holding a role, never
|
|
460
|
+
// none, and the revoke is the one that can be retried.
|
|
461
|
+
const createSpec = create(IamPolicySpecSchema, {
|
|
433
462
|
principal: create(ApiResourceRefSchema, {
|
|
434
463
|
kind: "identity_account",
|
|
435
464
|
id: memberId,
|
|
@@ -438,11 +467,11 @@ function ChangeRoleRow({
|
|
|
438
467
|
kind: "organization",
|
|
439
468
|
id: orgId,
|
|
440
469
|
}),
|
|
441
|
-
relation:
|
|
470
|
+
relation: iamRoleToString(selectedRole),
|
|
442
471
|
});
|
|
443
|
-
await
|
|
472
|
+
await createPolicy(createSpec);
|
|
444
473
|
|
|
445
|
-
const
|
|
474
|
+
const deleteSpec = create(IamPolicySpecSchema, {
|
|
446
475
|
principal: create(ApiResourceRefSchema, {
|
|
447
476
|
kind: "identity_account",
|
|
448
477
|
id: memberId,
|
|
@@ -451,9 +480,9 @@ function ChangeRoleRow({
|
|
|
451
480
|
kind: "organization",
|
|
452
481
|
id: orgId,
|
|
453
482
|
}),
|
|
454
|
-
relation:
|
|
483
|
+
relation: currentRoleCode,
|
|
455
484
|
});
|
|
456
|
-
await
|
|
485
|
+
await deletePolicy(deleteSpec);
|
|
457
486
|
|
|
458
487
|
onDone();
|
|
459
488
|
} catch (err) {
|
|
@@ -482,6 +511,7 @@ function ChangeRoleRow({
|
|
|
482
511
|
kind={ApiResourceKind.organization}
|
|
483
512
|
selected={selectedRole ?? currentRole}
|
|
484
513
|
onSelect={setSelectedRole}
|
|
514
|
+
omitRoles={omitRoles}
|
|
485
515
|
disabled={isWorking}
|
|
486
516
|
/>
|
|
487
517
|
|
|
@@ -25,6 +25,12 @@ export interface RoleSelectorProps {
|
|
|
25
25
|
* Defaults to a person.
|
|
26
26
|
*/
|
|
27
27
|
readonly granteeKind?: GranteeKind;
|
|
28
|
+
/**
|
|
29
|
+
* Roles to leave out even though the kind grants them: the ones the
|
|
30
|
+
* caller may not assign (an organization's `owner`, for anyone who is not
|
|
31
|
+
* its owner). See {@link useRoleSelector}.
|
|
32
|
+
*/
|
|
33
|
+
readonly omitRoles?: readonly IamRole[];
|
|
28
34
|
/** Additional CSS class names for the root container. */
|
|
29
35
|
readonly className?: string;
|
|
30
36
|
}
|
|
@@ -54,6 +60,7 @@ export function RoleSelector({
|
|
|
54
60
|
defaultRole,
|
|
55
61
|
disabled = false,
|
|
56
62
|
granteeKind = "identity_account",
|
|
63
|
+
omitRoles,
|
|
57
64
|
className,
|
|
58
65
|
}: RoleSelectorProps) {
|
|
59
66
|
// Instance-scoped radio-group name (oss#593): a hardcoded name would make
|
|
@@ -66,6 +73,7 @@ export function RoleSelector({
|
|
|
66
73
|
kind,
|
|
67
74
|
defaultRole,
|
|
68
75
|
granteeKind,
|
|
76
|
+
omitRoles,
|
|
69
77
|
);
|
|
70
78
|
|
|
71
79
|
const currentSelected = controlledSelected !== undefined
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins OrgMembersPanel's reading of the owner rule (the server asks
|
|
3
|
+
* `can_assign_roles` on the organization before any change to its owner
|
|
4
|
+
* role):
|
|
5
|
+
*
|
|
6
|
+
* - a caller who may not assign owner is offered no change and no removal
|
|
7
|
+
* on an owner's row, and no owner in the role picker for anyone else;
|
|
8
|
+
* an owner is offered all of them;
|
|
9
|
+
* - a role change grants the new role before it revokes the old one, so
|
|
10
|
+
* a refused grant leaves the member with the role they had.
|
|
11
|
+
*
|
|
12
|
+
* The data hooks are stubbed: each is proven by its own tests, and the
|
|
13
|
+
* permission answer is the one question this panel adds.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
17
|
+
import { create } from "@bufbuild/protobuf";
|
|
18
|
+
import {
|
|
19
|
+
PrincipalAccessSchema,
|
|
20
|
+
type PrincipalAccess,
|
|
21
|
+
} from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
22
|
+
import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
23
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
24
|
+
|
|
25
|
+
const state = vi.hoisted(() => ({
|
|
26
|
+
members: [] as PrincipalAccess[],
|
|
27
|
+
canAssignOwner: false,
|
|
28
|
+
calls: [] as string[],
|
|
29
|
+
createFails: false,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../useResourceAccess.js", () => ({
|
|
33
|
+
useResourceAccess: () => ({
|
|
34
|
+
members: state.members,
|
|
35
|
+
isLoading: false,
|
|
36
|
+
error: null,
|
|
37
|
+
refetch: () => {},
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("../usePrincipalsCount.js", () => ({
|
|
41
|
+
usePrincipalsCount: () => ({ count: state.members.length, refetch: () => {} }),
|
|
42
|
+
}));
|
|
43
|
+
vi.mock("../useWhoAmI.js", () => ({
|
|
44
|
+
useWhoAmI: () => ({ account: { metadata: { id: "ida_me" } } }),
|
|
45
|
+
}));
|
|
46
|
+
vi.mock("../useOwnerAssignment.js", () => ({
|
|
47
|
+
useOwnerAssignment: () => ({
|
|
48
|
+
canAssignOwner: state.canAssignOwner,
|
|
49
|
+
unassignable: state.canAssignOwner ? [] : [IamRole.owner],
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
vi.mock("../useRevokeOrgAccess.js", () => ({
|
|
53
|
+
useRevokeOrgAccess: () => ({ revoke: async () => {}, isRevoking: false, error: null }),
|
|
54
|
+
}));
|
|
55
|
+
vi.mock("../useCreateIamPolicy.js", () => ({
|
|
56
|
+
useCreateIamPolicy: () => ({
|
|
57
|
+
create: async (spec: IamPolicySpec) => {
|
|
58
|
+
state.calls.push(`create ${spec.relation}`);
|
|
59
|
+
if (state.createFails) {
|
|
60
|
+
throw new Error("only an owner of the organization can grant, revoke or remove the owner role");
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
isCreating: false,
|
|
64
|
+
}),
|
|
65
|
+
}));
|
|
66
|
+
vi.mock("../useDeleteIamPolicy.js", () => ({
|
|
67
|
+
useDeleteIamPolicy: () => ({
|
|
68
|
+
remove: async (spec: IamPolicySpec) => {
|
|
69
|
+
state.calls.push(`delete ${spec.relation}`);
|
|
70
|
+
},
|
|
71
|
+
isDeleting: false,
|
|
72
|
+
}),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import { OrgMembersPanel } from "../OrgMembersPanel";
|
|
76
|
+
|
|
77
|
+
function member(id: string, name: string, role: string): PrincipalAccess {
|
|
78
|
+
return create(PrincipalAccessSchema, {
|
|
79
|
+
principal: { kind: "identity_account", id, name },
|
|
80
|
+
roles: [{ role: { code: role, name: role } }],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
state.members = [];
|
|
87
|
+
state.canAssignOwner = false;
|
|
88
|
+
state.calls = [];
|
|
89
|
+
state.createFails = false;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("OrgMembersPanel and the owner role", () => {
|
|
93
|
+
it("offers a caller who is no owner nothing on an owner's row, and no owner in the picker", () => {
|
|
94
|
+
state.members = [member("ida_root", "Root", "owner"), member("ida_dave", "Dave", "member")];
|
|
95
|
+
render(<OrgMembersPanel orgId="acme" />);
|
|
96
|
+
|
|
97
|
+
expect(screen.queryByRole("button", { name: "Change role for Root" })).toBeNull();
|
|
98
|
+
expect(screen.queryByRole("button", { name: "Remove Root" })).toBeNull();
|
|
99
|
+
expect(screen.getByRole("button", { name: "Remove Dave" })).toBeTruthy();
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole("button", { name: "Change role for Dave" }));
|
|
102
|
+
expect(screen.queryByRole("radio", { name: /^Owner/ })).toBeNull();
|
|
103
|
+
expect(screen.getByRole("radio", { name: /^Admin/ })).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("offers an owner every control on every row, owner in the picker included", () => {
|
|
107
|
+
state.canAssignOwner = true;
|
|
108
|
+
state.members = [member("ida_root", "Root", "owner"), member("ida_dave", "Dave", "member")];
|
|
109
|
+
render(<OrgMembersPanel orgId="acme" />);
|
|
110
|
+
|
|
111
|
+
expect(screen.getByRole("button", { name: "Remove Root" })).toBeTruthy();
|
|
112
|
+
fireEvent.click(screen.getByRole("button", { name: "Change role for Dave" }));
|
|
113
|
+
expect(screen.getByRole("radio", { name: /^Owner/ })).toBeTruthy();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("grants the new role before it revokes the old one", async () => {
|
|
117
|
+
state.canAssignOwner = true;
|
|
118
|
+
state.members = [member("ida_dave", "Dave", "member")];
|
|
119
|
+
render(<OrgMembersPanel orgId="acme" />);
|
|
120
|
+
|
|
121
|
+
fireEvent.click(screen.getByRole("button", { name: "Change role for Dave" }));
|
|
122
|
+
fireEvent.click(screen.getByRole("radio", { name: /^Admin/ }));
|
|
123
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
124
|
+
await waitFor(() => expect(state.calls).toEqual(["create admin", "delete member"]));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("revokes nothing when the new role is refused, so the member keeps the one they had", async () => {
|
|
128
|
+
state.canAssignOwner = true;
|
|
129
|
+
state.createFails = true;
|
|
130
|
+
state.members = [member("ida_dave", "Dave", "member")];
|
|
131
|
+
render(<OrgMembersPanel orgId="acme" />);
|
|
132
|
+
|
|
133
|
+
fireEvent.click(screen.getByRole("button", { name: "Change role for Dave" }));
|
|
134
|
+
fireEvent.click(screen.getByRole("radio", { name: /^Owner/ }));
|
|
135
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
136
|
+
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
|
137
|
+
expect(state.calls).toEqual(["create owner"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins useOwnerAssignment, the console's one reading of the owner rule: it
|
|
3
|
+
* asks `can_assign_roles` on the organization it is given (and nothing
|
|
4
|
+
* without one), and leaves owner out of a role picker exactly when the
|
|
5
|
+
* answer is no. The permission hook is stubbed: it is proven by its own
|
|
6
|
+
* tests, fail-open posture included.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { renderHook } from "@testing-library/react";
|
|
10
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
11
|
+
|
|
12
|
+
const check = vi.hoisted(() => ({
|
|
13
|
+
allowed: false,
|
|
14
|
+
asked: [] as Array<{ resource: { kind: string; id: string } | null; relation: string }>,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("../useCheckPermission.js", () => ({
|
|
18
|
+
useCheckPermission: (resource: { kind: string; id: string } | null, relation: string) => {
|
|
19
|
+
check.asked.push({ resource, relation });
|
|
20
|
+
return { allowed: check.allowed, isLoading: false, error: null };
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { useOwnerAssignment } from "../useOwnerAssignment";
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
check.allowed = false;
|
|
28
|
+
check.asked = [];
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("useOwnerAssignment", () => {
|
|
32
|
+
it("asks can_assign_roles on the organization, and leaves owner out when the answer is no", () => {
|
|
33
|
+
const { result } = renderHook(() => useOwnerAssignment("acme"));
|
|
34
|
+
expect(check.asked.at(-1)).toEqual({
|
|
35
|
+
resource: { kind: "organization", id: "acme" },
|
|
36
|
+
relation: "can_assign_roles",
|
|
37
|
+
});
|
|
38
|
+
expect(result.current).toEqual({ canAssignOwner: false, unassignable: [IamRole.owner] });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves nothing out for an owner, and hands back the same list on every render", () => {
|
|
42
|
+
check.allowed = true;
|
|
43
|
+
const { result, rerender } = renderHook(() => useOwnerAssignment("acme"));
|
|
44
|
+
const first = result.current.unassignable;
|
|
45
|
+
rerender();
|
|
46
|
+
expect(result.current.canAssignOwner).toBe(true);
|
|
47
|
+
expect(result.current.unassignable).toEqual([]);
|
|
48
|
+
expect(result.current.unassignable).toBe(first);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("names no organization to the check when it has none", () => {
|
|
52
|
+
renderHook(() => useOwnerAssignment(null));
|
|
53
|
+
expect(check.asked.at(-1)?.resource).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
4
|
+
import { useCheckPermission } from "./useCheckPermission.js";
|
|
5
|
+
|
|
6
|
+
/** What a caller who is not an owner may not assign: owner is assigned by owners. */
|
|
7
|
+
const OWNER_ONLY: readonly IamRole[] = [IamRole.owner];
|
|
8
|
+
const NOTHING_OMITTED: readonly IamRole[] = [];
|
|
9
|
+
|
|
10
|
+
/** Return value of {@link useOwnerAssignment}. */
|
|
11
|
+
export interface UseOwnerAssignmentReturn {
|
|
12
|
+
/** Whether the caller may grant, revoke or remove the owner role on the organization. */
|
|
13
|
+
readonly canAssignOwner: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The organization roles to leave out of a role picker for this caller:
|
|
16
|
+
* owner unless they may assign it. A stable list, as `RoleSelector`'s
|
|
17
|
+
* `omitRoles` asks.
|
|
18
|
+
*/
|
|
19
|
+
readonly unassignable: readonly IamRole[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The console's one reading of the owner rule: the server asks
|
|
24
|
+
* `can_assign_roles` on an organization before any change to its owner
|
|
25
|
+
* role (only its owners hold it), and every surface that offers such a
|
|
26
|
+
* change asks the same question here. Fails open, like every capability
|
|
27
|
+
* gate: the server refuses whatever this lets through.
|
|
28
|
+
*
|
|
29
|
+
* @param orgId - The organization's id, or `null` to skip the check.
|
|
30
|
+
*/
|
|
31
|
+
export function useOwnerAssignment(orgId: string | null): UseOwnerAssignmentReturn {
|
|
32
|
+
const { allowed } = useCheckPermission(
|
|
33
|
+
orgId ? { kind: "organization", id: orgId } : null,
|
|
34
|
+
"can_assign_roles",
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
canAssignOwner: allowed,
|
|
38
|
+
unassignable: allowed ? NOTHING_OMITTED : OWNER_ONLY,
|
|
39
|
+
};
|
|
40
|
+
}
|