@stigmer/react 3.31.0 → 3.32.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 (46) hide show
  1. package/iam-policy/OrgMembersPanel.d.ts +7 -0
  2. package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
  3. package/iam-policy/OrgMembersPanel.js +28 -12
  4. package/iam-policy/OrgMembersPanel.js.map +1 -1
  5. package/iam-policy/RoleSelector.d.ts +7 -1
  6. package/iam-policy/RoleSelector.d.ts.map +1 -1
  7. package/iam-policy/RoleSelector.js +2 -2
  8. package/iam-policy/RoleSelector.js.map +1 -1
  9. package/iam-policy/useOwnerAssignment.d.ts +23 -0
  10. package/iam-policy/useOwnerAssignment.d.ts.map +1 -0
  11. package/iam-policy/useOwnerAssignment.js +23 -0
  12. package/iam-policy/useOwnerAssignment.js.map +1 -0
  13. package/iam-policy/useRoleSelector.d.ts +6 -1
  14. package/iam-policy/useRoleSelector.d.ts.map +1 -1
  15. package/iam-policy/useRoleSelector.js +11 -3
  16. package/iam-policy/useRoleSelector.js.map +1 -1
  17. package/invitation/InvitationManager.d.ts.map +1 -1
  18. package/invitation/InvitationManager.js +5 -1
  19. package/invitation/InvitationManager.js.map +1 -1
  20. package/package.json +5 -5
  21. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
  22. package/platform-client/PlatformClientDetailPanel.js +9 -4
  23. package/platform-client/PlatformClientDetailPanel.js.map +1 -1
  24. package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
  25. package/platform-client/PlatformClientListPanel.js +2 -1
  26. package/platform-client/PlatformClientListPanel.js.map +1 -1
  27. package/platform-client/expiry.d.ts +4 -0
  28. package/platform-client/expiry.d.ts.map +1 -0
  29. package/platform-client/expiry.js +18 -0
  30. package/platform-client/expiry.js.map +1 -0
  31. package/settings/MembersSection.d.ts.map +1 -1
  32. package/settings/MembersSection.js +1 -1
  33. package/settings/MembersSection.js.map +1 -1
  34. package/src/iam-policy/OrgMembersPanel.tsx +38 -8
  35. package/src/iam-policy/RoleSelector.tsx +8 -0
  36. package/src/iam-policy/__tests__/OrgMembersPanel.test.tsx +139 -0
  37. package/src/iam-policy/__tests__/useOwnerAssignment.test.tsx +55 -0
  38. package/src/iam-policy/useOwnerAssignment.ts +40 -0
  39. package/src/iam-policy/useRoleSelector.ts +14 -3
  40. package/src/invitation/InvitationManager.tsx +5 -0
  41. package/src/platform-client/PlatformClientDetailPanel.tsx +28 -5
  42. package/src/platform-client/PlatformClientListPanel.tsx +2 -1
  43. package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +45 -3
  44. package/src/platform-client/__tests__/expiry.test.ts +37 -0
  45. package/src/platform-client/expiry.ts +22 -0
  46. package/src/settings/MembersSection.tsx +2 -1
@@ -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
+ }
@@ -12,6 +12,9 @@ import {
12
12
  type GranteeKind,
13
13
  } from "@stigmer/sdk";
14
14
 
15
+ /** The default for `omit`: one shared empty list, so the options never recompute for it. */
16
+ const NOTHING_OMITTED: readonly IamRole[] = [];
17
+
15
18
  /** A single role option with display metadata. */
16
19
  export interface RoleOption {
17
20
  /** The IamRole enum value. */
@@ -52,6 +55,11 @@ export interface UseRoleSelectorReturn {
52
55
  * @param defaultRole - Optional initial selection.
53
56
  * @param granteeKind - Who the role is for: a person (the default) may
54
57
  * hold any grantable role, a team only the kind's team roles.
58
+ * @param omit - Roles to leave out even though the kind grants them: the
59
+ * ones the caller may not assign (an organization's `owner`, for anyone
60
+ * who is not its owner). The server refuses them anyway; leaving them out
61
+ * keeps a choice off the screen that could only fail. Pass a stable list
62
+ * (a constant, or a memoized one): the options follow its identity.
55
63
  *
56
64
  * @example
57
65
  * ```tsx
@@ -68,6 +76,7 @@ export function useRoleSelector(
68
76
  kind: ApiResourceKind | null,
69
77
  defaultRole?: IamRole,
70
78
  granteeKind: GranteeKind = "identity_account",
79
+ omit: readonly IamRole[] = NOTHING_OMITTED,
71
80
  ): UseRoleSelectorReturn {
72
81
  const [selected, setSelected] = useState<IamRole | null>(
73
82
  defaultRole ?? null,
@@ -75,15 +84,17 @@ export function useRoleSelector(
75
84
 
76
85
  const options = useMemo<readonly RoleOption[]>(() => {
77
86
  if (kind === null) return [];
78
- const roles =
79
- granteeKind === "team" ? getTeamGrantableRoles(kind) : getGrantableRoles(kind);
87
+ const left = new Set(omit);
88
+ const roles = (
89
+ granteeKind === "team" ? getTeamGrantableRoles(kind) : getGrantableRoles(kind)
90
+ ).filter((role) => !left.has(role));
80
91
  return roles.map((role) => ({
81
92
  role,
82
93
  label: iamRoleDisplayName(role),
83
94
  description: iamRoleDescription(role),
84
95
  value: iamRoleToString(role),
85
96
  }));
86
- }, [kind, granteeKind]);
97
+ }, [kind, granteeKind, omit]);
87
98
 
88
99
  const selectedValue = useMemo(
89
100
  () => (selected !== null ? iamRoleToString(selected) : ""),
@@ -18,6 +18,7 @@ import { useRevokeInvitation } from "./useRevokeInvitation.js";
18
18
  import { InvitationCreatedAlert } from "./InvitationCreatedAlert.js";
19
19
  import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
20
20
  import { RoleSelector } from "../iam-policy/RoleSelector.js";
21
+ import { useOwnerAssignment } from "../iam-policy/useOwnerAssignment.js";
21
22
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
22
23
  import { useCopyFeedback } from "../internal/useCopyFeedback.js";
23
24
 
@@ -237,6 +238,9 @@ function CreateInvitationForm({
237
238
  onCancel: () => void;
238
239
  }) {
239
240
  const { create, isCreating, error, clearError } = useCreateInvitation();
241
+ // An invitation grants with its creator's authority: only an owner may
242
+ // invite an owner, and the server refuses anyone else.
243
+ const { unassignable } = useOwnerAssignment(org);
240
244
  const baseId = useId();
241
245
 
242
246
  const [label, setLabel] = useState("");
@@ -303,6 +307,7 @@ function CreateInvitationForm({
303
307
  kind={ApiResourceKind.organization}
304
308
  selected={role}
305
309
  onSelect={(r) => setRole(r)}
310
+ omitRoles={unassignable}
306
311
  disabled={isCreating}
307
312
  />
308
313
 
@@ -12,6 +12,7 @@ import { useUpdatePlatformClient } from "./useUpdatePlatformClient.js";
12
12
  import { useRotatePlatformClientSecret } from "./useRotatePlatformClientSecret.js";
13
13
  import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
14
14
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
15
+ import { isPlatformClientExpired } from "./expiry.js";
15
16
 
16
17
  /** Props for {@link PlatformClientDetailPanel}. */
17
18
  export interface PlatformClientDetailPanelProps {
@@ -87,6 +88,8 @@ export function PlatformClientDetailPanel({
87
88
  const [confirmingRotate, setConfirmingRotate] = useState(false);
88
89
 
89
90
  const isBusy = isUpdating || isRotating || isDeleting;
91
+ // Rotating never revives an expired client: only a later expiry does.
92
+ const expired = isPlatformClientExpired(spec);
90
93
 
91
94
  // Edit form state
92
95
  const [neverExpires, setNeverExpires] = useState(
@@ -507,6 +510,12 @@ export function PlatformClientDetailPanel({
507
510
  <p className="stg:text-xs stg:text-foreground">
508
511
  Rotate secret? The current secret will be
509
512
  <span className="stg:font-medium"> permanently invalidated</span>.
513
+ {expired && (
514
+ <span className="stg:mt-1 stg:block stg:text-destructive">
515
+ This client has expired. Rotating does not extend it; edit its
516
+ expiry instead.
517
+ </span>
518
+ )}
510
519
  </p>
511
520
  <div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
512
521
  <button
@@ -654,10 +663,18 @@ function ViewMode({
654
663
  {spec?.neverExpires ? (
655
664
  <Field label="Expiry" value="Never expires" />
656
665
  ) : spec?.expiresAt ? (
657
- <Field
658
- label="Expires"
659
- value={formatDate(timestampDate(spec.expiresAt))}
660
- />
666
+ isPlatformClientExpired(spec) ? (
667
+ <Field
668
+ label="Expired"
669
+ value={formatDate(timestampDate(spec.expiresAt))}
670
+ tone="destructive"
671
+ />
672
+ ) : (
673
+ <Field
674
+ label="Expires"
675
+ value={formatDate(timestampDate(spec.expiresAt))}
676
+ />
677
+ )
661
678
  ) : null}
662
679
 
663
680
  {/* JIT provisioning */}
@@ -726,10 +743,13 @@ function Field({
726
743
  label,
727
744
  value,
728
745
  mono,
746
+ tone,
729
747
  }: {
730
748
  label: string;
731
749
  value?: string;
732
750
  mono?: boolean;
751
+ /** `destructive` for a value that needs action (an expired client). */
752
+ tone?: "destructive";
733
753
  }) {
734
754
  if (!value) return null;
735
755
  return (
@@ -739,7 +759,10 @@ function Field({
739
759
  </dt>
740
760
  <dd
741
761
  className={cn(
742
- "stg:text-foreground stg:mt-0.5 stg:break-all stg:text-xs",
762
+ "stg:mt-0.5 stg:break-all stg:text-xs",
763
+ tone === "destructive"
764
+ ? "stg:text-destructive stg:font-medium"
765
+ : "stg:text-foreground",
743
766
  mono && "stg:font-mono",
744
767
  )}
745
768
  >
@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js"
9
9
  import { usePlatformClientList } from "./usePlatformClientList.js";
10
10
  import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
11
11
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
12
+ import { isPlatformClientExpired } from "./expiry.js";
12
13
 
13
14
  /** Props for {@link PlatformClientListPanel}. */
14
15
  export interface PlatformClientListPanelProps {
@@ -314,7 +315,7 @@ function ExpiryBadge({ spec }: { spec: PlatformClient["spec"] }) {
314
315
  }
315
316
  if (spec?.expiresAt) {
316
317
  const date = timestampDate(spec.expiresAt);
317
- const isExpired = date < new Date();
318
+ const isExpired = isPlatformClientExpired(spec);
318
319
  return (
319
320
  <Tooltip>
320
321
  <TooltipTrigger
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
- import { create } from "@bufbuild/protobuf";
3
+ import { clone, create } from "@bufbuild/protobuf";
4
4
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
5
5
  import {
6
6
  PlatformClientSchema,
@@ -18,6 +18,11 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
18
18
  * NEVER sent `environment_refs` — saving any edit silently wiped the
19
19
  * client's credential-delivery environment bindings. The panel must
20
20
  * spread `toPlatformClientUpdateInput` and override only what it edits.
21
+ *
22
+ * Also pins how the panel presents an expired client (#1254): the view
23
+ * says "Expired", and the rotate confirmation says rotating will not help,
24
+ * because rotation leaves the expiry as it is. A live client shows
25
+ * neither.
21
26
  */
22
27
 
23
28
  const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
@@ -41,17 +46,32 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
41
46
  },
42
47
  });
43
48
 
44
- function renderPanel(update: ReturnType<typeof vi.fn>) {
49
+ function renderPanel(
50
+ update: ReturnType<typeof vi.fn>,
51
+ platformClient: PlatformClient = PLATFORM_CLIENT,
52
+ ) {
45
53
  const client = {
46
54
  platformclient: { update },
47
55
  } as never;
48
56
  return render(
49
57
  <StigmerContext.Provider value={client}>
50
- <PlatformClientDetailPanel platformClient={PLATFORM_CLIENT} />
58
+ <PlatformClientDetailPanel platformClient={platformClient} />
51
59
  </StigmerContext.Provider>,
52
60
  );
53
61
  }
54
62
 
63
+ /** PLATFORM_CLIENT with an expiry a day from now (+1) or a day ago (-1). */
64
+ function clientExpiringInDays(days: 1 | -1): PlatformClient {
65
+ const platformClient = clone(PlatformClientSchema, PLATFORM_CLIENT);
66
+ platformClient.spec!.expiresAt = timestampFromDate(
67
+ new Date(Date.now() + days * 86_400_000),
68
+ );
69
+ return platformClient;
70
+ }
71
+
72
+ const ROTATE_WILL_NOT_HELP =
73
+ "This client has expired. Rotating does not extend it; edit its expiry instead.";
74
+
55
75
  afterEach(cleanup);
56
76
 
57
77
  describe("PlatformClientDetailPanel save payload", () => {
@@ -80,3 +100,25 @@ describe("PlatformClientDetailPanel save payload", () => {
80
100
  expect(input.name).toBe("Embed Client");
81
101
  });
82
102
  });
103
+
104
+ describe("PlatformClientDetailPanel expiry", () => {
105
+ it("an expired client reads Expired, and its rotate confirmation says rotating will not help", () => {
106
+ renderPanel(vi.fn(), clientExpiringInDays(-1));
107
+
108
+ expect(screen.getByText("Expired")).toBeTruthy();
109
+ expect(screen.queryByText("Expires")).toBeNull();
110
+
111
+ fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
112
+ expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
113
+ });
114
+
115
+ it("a live client reads Expires, and its rotate confirmation carries no expiry warning", () => {
116
+ renderPanel(vi.fn(), clientExpiringInDays(1));
117
+
118
+ expect(screen.getByText("Expires")).toBeTruthy();
119
+ expect(screen.queryByText("Expired")).toBeNull();
120
+
121
+ fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
122
+ expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
123
+ });
124
+ });
@@ -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,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
  {" "}