@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.
- 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/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.map +1 -1
- package/platform-client/PlatformClientDetailPanel.js +9 -4
- package/platform-client/PlatformClientDetailPanel.js.map +1 -1
- package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
- package/platform-client/PlatformClientListPanel.js +2 -1
- package/platform-client/PlatformClientListPanel.js.map +1 -1
- 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/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/invitation/InvitationManager.tsx +5 -0
- package/src/platform-client/PlatformClientDetailPanel.tsx +28 -5
- package/src/platform-client/PlatformClientListPanel.tsx +2 -1
- package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +45 -3
- package/src/platform-client/__tests__/expiry.test.ts +37 -0
- package/src/platform-client/expiry.ts +22 -0
- 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
|
|
79
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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:
|
|
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 =
|
|
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(
|
|
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={
|
|
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
|
{" "}
|