@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
|
@@ -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) : ""),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
4
|
+
import type { Timestamp } from "@bufbuild/protobuf/wkt";
|
|
5
|
+
import { formatRelativeTime } from "../activity/format-relative-time.js";
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
7
|
+
|
|
8
|
+
/** Props for {@link LastUsedLabel}. */
|
|
9
|
+
export interface LastUsedLabelProps {
|
|
10
|
+
/** The credential's `status.last_used_at`; absent means it was never used. */
|
|
11
|
+
readonly at: Timestamp | undefined;
|
|
12
|
+
/** Frozen clock for tests; defaults to the wall clock. */
|
|
13
|
+
readonly now?: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* When a credential was last used, said one way on every surface that
|
|
18
|
+
* lists credentials (API keys, platform clients): "Used 5m" in the SDK's
|
|
19
|
+
* compact relative vocabulary, with the exact instant in a tooltip, or
|
|
20
|
+
* "Never used". The label names what the time is, because it sits beside
|
|
21
|
+
* other dates (created, expires) in the same row.
|
|
22
|
+
*
|
|
23
|
+
* The server records a use at most once a minute, so "Used now" means
|
|
24
|
+
* within the last minute or so.
|
|
25
|
+
*/
|
|
26
|
+
export function LastUsedLabel({ at, now }: LastUsedLabelProps) {
|
|
27
|
+
if (at === undefined) {
|
|
28
|
+
return <span>Never used</span>;
|
|
29
|
+
}
|
|
30
|
+
const date = timestampDate(at);
|
|
31
|
+
return (
|
|
32
|
+
<Tooltip>
|
|
33
|
+
<TooltipTrigger render={<time dateTime={date.toISOString()} />}>
|
|
34
|
+
{`Used ${formatRelativeTime(date, now)}`}
|
|
35
|
+
</TooltipTrigger>
|
|
36
|
+
<TooltipContent side="top">
|
|
37
|
+
{`Last used ${date.toISOString()}`}
|
|
38
|
+
</TooltipContent>
|
|
39
|
+
</Tooltip>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -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
|
|
|
@@ -11,7 +11,10 @@ import type { PlatformClientCreateResponse } from "@stigmer/protos/ai/stigmer/ia
|
|
|
11
11
|
import { useUpdatePlatformClient } from "./useUpdatePlatformClient.js";
|
|
12
12
|
import { useRotatePlatformClientSecret } from "./useRotatePlatformClientSecret.js";
|
|
13
13
|
import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
|
|
14
|
+
import { formatRelativeTime } from "../activity/format-relative-time.js";
|
|
15
|
+
import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
|
|
14
16
|
import { SpinnerIcon } from "../internal/SpinnerIcon.js";
|
|
17
|
+
import { isPlatformClientExpired } from "./expiry.js";
|
|
15
18
|
|
|
16
19
|
/** Props for {@link PlatformClientDetailPanel}. */
|
|
17
20
|
export interface PlatformClientDetailPanelProps {
|
|
@@ -32,14 +35,24 @@ export interface PlatformClientDetailPanelProps {
|
|
|
32
35
|
readonly onBack?: () => void;
|
|
33
36
|
/** Additional CSS class names for the root container. */
|
|
34
37
|
readonly className?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Reference instant for the "last used" relative stamp. Defaults to
|
|
40
|
+
* the live clock. **Deterministic hosts (documentation embeds, video
|
|
41
|
+
* export) must pass a frozen instant** — otherwise a depicted client's
|
|
42
|
+
* "2h ago" drifts with every replay.
|
|
43
|
+
*/
|
|
44
|
+
readonly now?: Date;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
38
48
|
* View and edit panel for an existing platform client.
|
|
39
49
|
*
|
|
40
50
|
* In **view mode**, displays all configuration fields in a structured
|
|
41
|
-
* label/value layout with
|
|
42
|
-
* actions
|
|
51
|
+
* label/value layout, with when the client last minted a token, and
|
|
52
|
+
* "Edit", "Rotate Secret", and "Delete" actions offered only to a caller
|
|
53
|
+
* the server allows: `can_edit` for Edit and Rotate, `can_delete` for
|
|
54
|
+
* Delete. The organization's admins may view a client they did not
|
|
55
|
+
* create, and see no actions on it.
|
|
43
56
|
*
|
|
44
57
|
* In **edit mode**, mutable spec fields become editable: JIT
|
|
45
58
|
* provisioning toggles, expiry, auto-grant role, and allowed
|
|
@@ -70,11 +83,18 @@ export function PlatformClientDetailPanel({
|
|
|
70
83
|
onDeleted,
|
|
71
84
|
onBack,
|
|
72
85
|
className,
|
|
86
|
+
now,
|
|
73
87
|
}: PlatformClientDetailPanelProps) {
|
|
74
88
|
const baseId = useId();
|
|
75
89
|
const spec = platformClient.spec;
|
|
76
90
|
const meta = platformClient.metadata;
|
|
77
91
|
|
|
92
|
+
const resource = meta?.id ? { kind: "platform_client", id: meta.id } : null;
|
|
93
|
+
const editCheck = useCheckPermission(resource, "can_edit");
|
|
94
|
+
const deleteCheck = useCheckPermission(resource, "can_delete");
|
|
95
|
+
const canEdit = !editCheck.isLoading && editCheck.allowed;
|
|
96
|
+
const canDelete = !deleteCheck.isLoading && deleteCheck.allowed;
|
|
97
|
+
|
|
78
98
|
const { update, isUpdating, error: updateError, clearError: clearUpdateError } =
|
|
79
99
|
useUpdatePlatformClient();
|
|
80
100
|
const { rotateSecret, isRotating, error: rotateError, clearError: clearRotateError } =
|
|
@@ -87,6 +107,8 @@ export function PlatformClientDetailPanel({
|
|
|
87
107
|
const [confirmingRotate, setConfirmingRotate] = useState(false);
|
|
88
108
|
|
|
89
109
|
const isBusy = isUpdating || isRotating || isDeleting;
|
|
110
|
+
// Rotating never revives an expired client: only a later expiry does.
|
|
111
|
+
const expired = isPlatformClientExpired(spec);
|
|
90
112
|
|
|
91
113
|
// Edit form state
|
|
92
114
|
const [neverExpires, setNeverExpires] = useState(
|
|
@@ -236,6 +258,7 @@ export function PlatformClientDetailPanel({
|
|
|
236
258
|
|
|
237
259
|
const createdAt = platformClient.status?.audit?.specAudit?.createdAt;
|
|
238
260
|
const updatedAt = platformClient.status?.audit?.specAudit?.updatedAt;
|
|
261
|
+
const lastUsedAt = platformClient.status?.lastUsedAt;
|
|
239
262
|
|
|
240
263
|
return (
|
|
241
264
|
<div className={cn("stg:space-y-4", className)}>
|
|
@@ -269,7 +292,7 @@ export function PlatformClientDetailPanel({
|
|
|
269
292
|
</div>
|
|
270
293
|
</div>
|
|
271
294
|
|
|
272
|
-
{mode === "view" && (
|
|
295
|
+
{mode === "view" && canEdit && (
|
|
273
296
|
<button
|
|
274
297
|
type="button"
|
|
275
298
|
onClick={enterEdit}
|
|
@@ -290,6 +313,8 @@ export function PlatformClientDetailPanel({
|
|
|
290
313
|
spec={spec}
|
|
291
314
|
createdAt={createdAt}
|
|
292
315
|
updatedAt={updatedAt}
|
|
316
|
+
lastUsedAt={lastUsedAt}
|
|
317
|
+
now={now}
|
|
293
318
|
/>
|
|
294
319
|
) : (
|
|
295
320
|
<form onSubmit={handleSave} className="stg:space-y-3">
|
|
@@ -496,17 +521,23 @@ export function PlatformClientDetailPanel({
|
|
|
496
521
|
</form>
|
|
497
522
|
)}
|
|
498
523
|
|
|
499
|
-
{/* Actions bar (view mode only) */}
|
|
500
|
-
{mode === "view" && (
|
|
524
|
+
{/* Actions bar (view mode only, and only the actions the caller may take) */}
|
|
525
|
+
{mode === "view" && (canEdit || canDelete) && (
|
|
501
526
|
<div className="stg:space-y-2 stg:pt-2">
|
|
502
527
|
<hr className="stg:border-border-muted" />
|
|
503
528
|
|
|
504
529
|
{/* Rotate Secret */}
|
|
505
|
-
{confirmingRotate ? (
|
|
530
|
+
{!canEdit ? null : confirmingRotate ? (
|
|
506
531
|
<div className="stg:flex stg:items-center stg:justify-between stg:rounded-md stg:border stg:border-warning/30 stg:bg-warning/5 stg:px-3 stg:py-2">
|
|
507
532
|
<p className="stg:text-xs stg:text-foreground">
|
|
508
533
|
Rotate secret? The current secret will be
|
|
509
534
|
<span className="stg:font-medium"> permanently invalidated</span>.
|
|
535
|
+
{expired && (
|
|
536
|
+
<span className="stg:mt-1 stg:block stg:text-destructive">
|
|
537
|
+
This client has expired. Rotating does not extend it; edit its
|
|
538
|
+
expiry instead.
|
|
539
|
+
</span>
|
|
540
|
+
)}
|
|
510
541
|
</p>
|
|
511
542
|
<div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
|
|
512
543
|
<button
|
|
@@ -561,7 +592,7 @@ export function PlatformClientDetailPanel({
|
|
|
561
592
|
)}
|
|
562
593
|
|
|
563
594
|
{/* Delete */}
|
|
564
|
-
{confirmingDelete ? (
|
|
595
|
+
{!canDelete ? null : confirmingDelete ? (
|
|
565
596
|
<div className="stg:flex stg:items-center stg:justify-between stg:rounded-md stg:border stg:border-destructive/30 stg:bg-destructive-subtle stg:px-3 stg:py-2">
|
|
566
597
|
<div className="stg:min-w-0 stg:flex-1">
|
|
567
598
|
<p className="stg:text-xs stg:text-foreground">
|
|
@@ -632,10 +663,14 @@ function ViewMode({
|
|
|
632
663
|
spec,
|
|
633
664
|
createdAt,
|
|
634
665
|
updatedAt,
|
|
666
|
+
lastUsedAt,
|
|
667
|
+
now,
|
|
635
668
|
}: {
|
|
636
669
|
spec: PlatformClient["spec"];
|
|
637
670
|
createdAt?: Timestamp;
|
|
638
671
|
updatedAt?: Timestamp;
|
|
672
|
+
lastUsedAt?: Timestamp;
|
|
673
|
+
now?: Date;
|
|
639
674
|
}) {
|
|
640
675
|
return (
|
|
641
676
|
<dl className="stg:space-y-2.5">
|
|
@@ -654,10 +689,18 @@ function ViewMode({
|
|
|
654
689
|
{spec?.neverExpires ? (
|
|
655
690
|
<Field label="Expiry" value="Never expires" />
|
|
656
691
|
) : spec?.expiresAt ? (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
692
|
+
isPlatformClientExpired(spec) ? (
|
|
693
|
+
<Field
|
|
694
|
+
label="Expired"
|
|
695
|
+
value={formatDate(timestampDate(spec.expiresAt))}
|
|
696
|
+
tone="destructive"
|
|
697
|
+
/>
|
|
698
|
+
) : (
|
|
699
|
+
<Field
|
|
700
|
+
label="Expires"
|
|
701
|
+
value={formatDate(timestampDate(spec.expiresAt))}
|
|
702
|
+
/>
|
|
703
|
+
)
|
|
661
704
|
) : null}
|
|
662
705
|
|
|
663
706
|
{/* JIT provisioning */}
|
|
@@ -713,6 +756,14 @@ function ViewMode({
|
|
|
713
756
|
value={formatDate(timestampDate(updatedAt))}
|
|
714
757
|
/>
|
|
715
758
|
)}
|
|
759
|
+
<Field
|
|
760
|
+
label="Last used"
|
|
761
|
+
value={
|
|
762
|
+
lastUsedAt
|
|
763
|
+
? formatRelativeTime(timestampDate(lastUsedAt), now)
|
|
764
|
+
: "Never"
|
|
765
|
+
}
|
|
766
|
+
/>
|
|
716
767
|
</div>
|
|
717
768
|
</dl>
|
|
718
769
|
);
|
|
@@ -726,10 +777,13 @@ function Field({
|
|
|
726
777
|
label,
|
|
727
778
|
value,
|
|
728
779
|
mono,
|
|
780
|
+
tone,
|
|
729
781
|
}: {
|
|
730
782
|
label: string;
|
|
731
783
|
value?: string;
|
|
732
784
|
mono?: boolean;
|
|
785
|
+
/** `destructive` for a value that needs action (an expired client). */
|
|
786
|
+
tone?: "destructive";
|
|
733
787
|
}) {
|
|
734
788
|
if (!value) return null;
|
|
735
789
|
return (
|
|
@@ -739,7 +793,10 @@ function Field({
|
|
|
739
793
|
</dt>
|
|
740
794
|
<dd
|
|
741
795
|
className={cn(
|
|
742
|
-
"stg:
|
|
796
|
+
"stg:mt-0.5 stg:break-all stg:text-xs",
|
|
797
|
+
tone === "destructive"
|
|
798
|
+
? "stg:text-destructive stg:font-medium"
|
|
799
|
+
: "stg:text-foreground",
|
|
743
800
|
mono && "stg:font-mono",
|
|
744
801
|
)}
|
|
745
802
|
>
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
4
5
|
import { cn } from "@stigmer/theme";
|
|
5
6
|
import { getUserMessage } from "@stigmer/sdk";
|
|
6
7
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
7
8
|
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
9
|
+
import { PermissionGate } from "../iam-policy/PermissionGate.js";
|
|
10
|
+
import { LastUsedLabel } from "../internal/LastUsedLabel.js";
|
|
8
11
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
|
|
9
12
|
import { usePlatformClientList } from "./usePlatformClientList.js";
|
|
10
13
|
import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
|
|
11
14
|
import { SpinnerIcon } from "../internal/SpinnerIcon.js";
|
|
15
|
+
import { isPlatformClientExpired } from "./expiry.js";
|
|
12
16
|
|
|
13
17
|
/** Props for {@link PlatformClientListPanel}. */
|
|
14
18
|
export interface PlatformClientListPanelProps {
|
|
@@ -18,8 +22,22 @@ export interface PlatformClientListPanelProps {
|
|
|
18
22
|
readonly onEdit?: (pc: PlatformClient) => void;
|
|
19
23
|
/** Expose the refetch function so parents can trigger a list refresh. */
|
|
20
24
|
readonly onRefetchRef?: (refetch: () => void) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Shown when the list is empty. Defaults to "No platform clients
|
|
27
|
+
* configured." The list holds only the clients the caller may view (each
|
|
28
|
+
* client's creator and the organization's admins), so a host that knows
|
|
29
|
+
* the caller may not manage platform clients should say who does instead.
|
|
30
|
+
*/
|
|
31
|
+
readonly emptyState?: ReactNode;
|
|
21
32
|
/** Additional CSS class names for the root container. */
|
|
22
33
|
readonly className?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Reference instant for the "last used" relative stamp. Defaults to
|
|
36
|
+
* the live clock. **Deterministic hosts (documentation embeds, video
|
|
37
|
+
* export) must pass a frozen instant** — otherwise a depicted client's
|
|
38
|
+
* "2h ago" drifts with every replay.
|
|
39
|
+
*/
|
|
40
|
+
readonly now?: Date;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
/**
|
|
@@ -27,8 +45,9 @@ export interface PlatformClientListPanelProps {
|
|
|
27
45
|
* organization with inline delete confirmation.
|
|
28
46
|
*
|
|
29
47
|
* Each row shows the client name, `client_id` (monospace), secret
|
|
30
|
-
* fingerprint, expiry status,
|
|
31
|
-
*
|
|
48
|
+
* fingerprint, expiry status, creation date and when the client last
|
|
49
|
+
* minted a token. Edit and delete are offered only to a caller the
|
|
50
|
+
* server allows (`can_edit`, `can_delete`); delete confirms inline.
|
|
32
51
|
*
|
|
33
52
|
* Platform clients are admin-level resources with small cardinality
|
|
34
53
|
* (typically 1–5 per org), so the list is rendered without pagination.
|
|
@@ -53,7 +72,9 @@ export function PlatformClientListPanel({
|
|
|
53
72
|
org,
|
|
54
73
|
onEdit,
|
|
55
74
|
onRefetchRef,
|
|
75
|
+
emptyState = "No platform clients configured.",
|
|
56
76
|
className,
|
|
77
|
+
now,
|
|
57
78
|
}: PlatformClientListPanelProps) {
|
|
58
79
|
const { platformClients, isLoading, error, refetch } =
|
|
59
80
|
usePlatformClientList(org);
|
|
@@ -96,7 +117,7 @@ export function PlatformClientListPanel({
|
|
|
96
117
|
className,
|
|
97
118
|
)}
|
|
98
119
|
>
|
|
99
|
-
|
|
120
|
+
{emptyState}
|
|
100
121
|
</p>
|
|
101
122
|
);
|
|
102
123
|
}
|
|
@@ -114,6 +135,7 @@ export function PlatformClientListPanel({
|
|
|
114
135
|
key={id}
|
|
115
136
|
platformClient={pc}
|
|
116
137
|
isConfirming={confirmingId === id}
|
|
138
|
+
now={now}
|
|
117
139
|
onEdit={onEdit ? () => onEdit(pc) : undefined}
|
|
118
140
|
onConfirmDelete={() => setConfirmingId(id)}
|
|
119
141
|
onCancelDelete={() => setConfirmingId(null)}
|
|
@@ -135,6 +157,7 @@ export function PlatformClientListPanel({
|
|
|
135
157
|
function PlatformClientRow({
|
|
136
158
|
platformClient,
|
|
137
159
|
isConfirming,
|
|
160
|
+
now,
|
|
138
161
|
onEdit,
|
|
139
162
|
onConfirmDelete,
|
|
140
163
|
onCancelDelete,
|
|
@@ -142,6 +165,7 @@ function PlatformClientRow({
|
|
|
142
165
|
}: {
|
|
143
166
|
platformClient: PlatformClient;
|
|
144
167
|
isConfirming: boolean;
|
|
168
|
+
now?: Date;
|
|
145
169
|
onEdit?: () => void;
|
|
146
170
|
onConfirmDelete: () => void;
|
|
147
171
|
onCancelDelete: () => void;
|
|
@@ -157,6 +181,7 @@ function PlatformClientRow({
|
|
|
157
181
|
const fingerprint = spec?.secretFingerprint ?? "";
|
|
158
182
|
const createdAt =
|
|
159
183
|
platformClient.status?.audit?.specAudit?.createdAt;
|
|
184
|
+
const lastUsedAt = platformClient.status?.lastUsedAt;
|
|
160
185
|
|
|
161
186
|
const handleDelete = useCallback(async () => {
|
|
162
187
|
try {
|
|
@@ -268,35 +293,46 @@ function PlatformClientRow({
|
|
|
268
293
|
</TooltipContent>
|
|
269
294
|
</Tooltip>
|
|
270
295
|
)}
|
|
296
|
+
<LastUsedLabel at={lastUsedAt} now={now} />
|
|
271
297
|
</div>
|
|
272
298
|
|
|
273
299
|
<div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1">
|
|
274
300
|
{onEdit && (
|
|
301
|
+
<PermissionGate
|
|
302
|
+
resource={{ kind: "platform_client", id }}
|
|
303
|
+
relation="can_edit"
|
|
304
|
+
>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={onEdit}
|
|
308
|
+
aria-label={`Edit ${name}`}
|
|
309
|
+
className={cn(
|
|
310
|
+
"stg:shrink-0 stg:rounded stg:p-1",
|
|
311
|
+
"stg:text-muted-foreground stg:hover:text-foreground stg:hover:bg-accent-hover",
|
|
312
|
+
"stg:transition-colors",
|
|
313
|
+
)}
|
|
314
|
+
>
|
|
315
|
+
<PencilIcon />
|
|
316
|
+
</button>
|
|
317
|
+
</PermissionGate>
|
|
318
|
+
)}
|
|
319
|
+
<PermissionGate
|
|
320
|
+
resource={{ kind: "platform_client", id }}
|
|
321
|
+
relation="can_delete"
|
|
322
|
+
>
|
|
275
323
|
<button
|
|
276
324
|
type="button"
|
|
277
|
-
onClick={
|
|
278
|
-
aria-label={`
|
|
325
|
+
onClick={onConfirmDelete}
|
|
326
|
+
aria-label={`Delete ${name}`}
|
|
279
327
|
className={cn(
|
|
280
328
|
"stg:shrink-0 stg:rounded stg:p-1",
|
|
281
|
-
"stg:text-muted-foreground stg:hover:text-
|
|
329
|
+
"stg:text-muted-foreground stg:hover:text-destructive stg:hover:bg-destructive-subtle",
|
|
282
330
|
"stg:transition-colors",
|
|
283
331
|
)}
|
|
284
332
|
>
|
|
285
|
-
<
|
|
333
|
+
<TrashIcon />
|
|
286
334
|
</button>
|
|
287
|
-
|
|
288
|
-
<button
|
|
289
|
-
type="button"
|
|
290
|
-
onClick={onConfirmDelete}
|
|
291
|
-
aria-label={`Delete ${name}`}
|
|
292
|
-
className={cn(
|
|
293
|
-
"stg:shrink-0 stg:rounded stg:p-1",
|
|
294
|
-
"stg:text-muted-foreground stg:hover:text-destructive stg:hover:bg-destructive-subtle",
|
|
295
|
-
"stg:transition-colors",
|
|
296
|
-
)}
|
|
297
|
-
>
|
|
298
|
-
<TrashIcon />
|
|
299
|
-
</button>
|
|
335
|
+
</PermissionGate>
|
|
300
336
|
</div>
|
|
301
337
|
</div>
|
|
302
338
|
);
|
|
@@ -314,7 +350,7 @@ function ExpiryBadge({ spec }: { spec: PlatformClient["spec"] }) {
|
|
|
314
350
|
}
|
|
315
351
|
if (spec?.expiresAt) {
|
|
316
352
|
const date = timestampDate(spec.expiresAt);
|
|
317
|
-
const isExpired =
|
|
353
|
+
const isExpired = isPlatformClientExpired(spec);
|
|
318
354
|
return (
|
|
319
355
|
<Tooltip>
|
|
320
356
|
<TooltipTrigger
|
|
@@ -1,12 +1,14 @@
|
|
|
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,
|
|
7
|
+
PlatformClientStatusSchema,
|
|
7
8
|
type PlatformClient,
|
|
8
9
|
} from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
9
10
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
11
|
+
import type { CheckMyPermissionInput } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
10
12
|
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
11
13
|
import type { PlatformClientInput } from "@stigmer/sdk";
|
|
12
14
|
import { StigmerContext } from "../../context";
|
|
@@ -18,6 +20,18 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
|
|
|
18
20
|
* NEVER sent `environment_refs` — saving any edit silently wiped the
|
|
19
21
|
* client's credential-delivery environment bindings. The panel must
|
|
20
22
|
* spread `toPlatformClientUpdateInput` and override only what it edits.
|
|
23
|
+
*
|
|
24
|
+
* Also pins how the panel presents an expired client (#1254): the view
|
|
25
|
+
* says "Expired", and the rotate confirmation says rotating will not help,
|
|
26
|
+
* because rotation leaves the expiry as it is. A live client shows
|
|
27
|
+
* neither.
|
|
28
|
+
*
|
|
29
|
+
* And what the view offers (stigmer/stigmer#1302): Edit and Rotate secret
|
|
30
|
+
* only with `can_edit`, Delete only with `can_delete` on the client, so an
|
|
31
|
+
* organization admin who may view a client they did not create reads it
|
|
32
|
+
* with no actions at all; and when the client last minted a token, or
|
|
33
|
+
* "Never" (stigmer/stigmer#1255). The permission check runs through the
|
|
34
|
+
* real hook over a stubbed `checkMyPermission`.
|
|
21
35
|
*/
|
|
22
36
|
|
|
23
37
|
const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
|
|
@@ -41,17 +55,41 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
|
|
|
41
55
|
},
|
|
42
56
|
});
|
|
43
57
|
|
|
44
|
-
|
|
58
|
+
const ALLOW_ALL = async (_input: CheckMyPermissionInput) => ({
|
|
59
|
+
isAuthorized: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function renderPanel(
|
|
63
|
+
update: ReturnType<typeof vi.fn>,
|
|
64
|
+
platformClient: PlatformClient = PLATFORM_CLIENT,
|
|
65
|
+
checkMyPermission: (
|
|
66
|
+
input: CheckMyPermissionInput,
|
|
67
|
+
) => Promise<{ isAuthorized: boolean }> = ALLOW_ALL,
|
|
68
|
+
now?: Date,
|
|
69
|
+
) {
|
|
45
70
|
const client = {
|
|
46
71
|
platformclient: { update },
|
|
72
|
+
iamPolicy: { checkMyPermission },
|
|
47
73
|
} as never;
|
|
48
74
|
return render(
|
|
49
75
|
<StigmerContext.Provider value={client}>
|
|
50
|
-
<PlatformClientDetailPanel platformClient={
|
|
76
|
+
<PlatformClientDetailPanel platformClient={platformClient} now={now} />
|
|
51
77
|
</StigmerContext.Provider>,
|
|
52
78
|
);
|
|
53
79
|
}
|
|
54
80
|
|
|
81
|
+
/** PLATFORM_CLIENT with an expiry a day from now (+1) or a day ago (-1). */
|
|
82
|
+
function clientExpiringInDays(days: 1 | -1): PlatformClient {
|
|
83
|
+
const platformClient = clone(PlatformClientSchema, PLATFORM_CLIENT);
|
|
84
|
+
platformClient.spec!.expiresAt = timestampFromDate(
|
|
85
|
+
new Date(Date.now() + days * 86_400_000),
|
|
86
|
+
);
|
|
87
|
+
return platformClient;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ROTATE_WILL_NOT_HELP =
|
|
91
|
+
"This client has expired. Rotating does not extend it; edit its expiry instead.";
|
|
92
|
+
|
|
55
93
|
afterEach(cleanup);
|
|
56
94
|
|
|
57
95
|
describe("PlatformClientDetailPanel save payload", () => {
|
|
@@ -59,7 +97,7 @@ describe("PlatformClientDetailPanel save payload", () => {
|
|
|
59
97
|
const update = vi.fn(async (_input: PlatformClientInput) => PLATFORM_CLIENT);
|
|
60
98
|
renderPanel(update);
|
|
61
99
|
|
|
62
|
-
fireEvent.click(screen.
|
|
100
|
+
fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
|
|
63
101
|
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
|
64
102
|
|
|
65
103
|
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
|
|
@@ -80,3 +118,100 @@ describe("PlatformClientDetailPanel save payload", () => {
|
|
|
80
118
|
expect(input.name).toBe("Embed Client");
|
|
81
119
|
});
|
|
82
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("PlatformClientDetailPanel expiry", () => {
|
|
123
|
+
it("an expired client reads Expired, and its rotate confirmation says rotating will not help", async () => {
|
|
124
|
+
renderPanel(vi.fn(), clientExpiringInDays(-1));
|
|
125
|
+
|
|
126
|
+
expect(screen.getByText("Expired")).toBeTruthy();
|
|
127
|
+
expect(screen.queryByText("Expires")).toBeNull();
|
|
128
|
+
|
|
129
|
+
fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
|
|
130
|
+
expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a live client reads Expires, and its rotate confirmation carries no expiry warning", async () => {
|
|
134
|
+
renderPanel(vi.fn(), clientExpiringInDays(1));
|
|
135
|
+
|
|
136
|
+
expect(screen.getByText("Expires")).toBeTruthy();
|
|
137
|
+
expect(screen.queryByText("Expired")).toBeNull();
|
|
138
|
+
|
|
139
|
+
fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
|
|
140
|
+
expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("PlatformClientDetailPanel actions by permission", () => {
|
|
145
|
+
it("asks can_edit and can_delete on the client", async () => {
|
|
146
|
+
const checkMyPermission = vi.fn(ALLOW_ALL);
|
|
147
|
+
renderPanel(vi.fn(), PLATFORM_CLIENT, checkMyPermission);
|
|
148
|
+
|
|
149
|
+
await screen.findByRole("button", { name: "Edit" });
|
|
150
|
+
const asked = checkMyPermission.mock.calls.map(
|
|
151
|
+
([input]) =>
|
|
152
|
+
`${input.resource?.kind}:${input.resource?.id}:${input.relation}`,
|
|
153
|
+
);
|
|
154
|
+
expect(asked.sort()).toEqual([
|
|
155
|
+
"platform_client:pc-1:can_delete",
|
|
156
|
+
"platform_client:pc-1:can_edit",
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("offers Edit and Rotate secret only with can_edit, Delete only with can_delete", async () => {
|
|
161
|
+
renderPanel(vi.fn(), PLATFORM_CLIENT, async (input) => ({
|
|
162
|
+
isAuthorized: input.relation === "can_delete",
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
await screen.findByRole("button", { name: "Delete platform client" });
|
|
166
|
+
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
|
167
|
+
expect(screen.queryByRole("button", { name: "Rotate secret" })).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("an admin who did not create the client reads it with no actions at all", async () => {
|
|
171
|
+
const checkMyPermission = vi.fn(async (_input: CheckMyPermissionInput) => ({
|
|
172
|
+
isAuthorized: false,
|
|
173
|
+
}));
|
|
174
|
+
const { container } = renderPanel(vi.fn(), PLATFORM_CLIENT, checkMyPermission);
|
|
175
|
+
|
|
176
|
+
await waitFor(() => expect(checkMyPermission).toHaveBeenCalledTimes(2));
|
|
177
|
+
// The configuration stays readable; only the change affordances are withheld.
|
|
178
|
+
expect(screen.getByText("client-abc")).toBeTruthy();
|
|
179
|
+
await waitFor(() => {
|
|
180
|
+
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
|
181
|
+
expect(screen.queryByRole("button", { name: "Rotate secret" })).toBeNull();
|
|
182
|
+
expect(
|
|
183
|
+
screen.queryByRole("button", { name: "Delete platform client" }),
|
|
184
|
+
).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
const withoutActions = container.querySelectorAll("hr").length;
|
|
187
|
+
cleanup();
|
|
188
|
+
|
|
189
|
+
const allowed = renderPanel(vi.fn());
|
|
190
|
+
await screen.findByRole("button", { name: "Delete platform client" });
|
|
191
|
+
expect(
|
|
192
|
+
withoutActions,
|
|
193
|
+
"no empty actions bar is left behind: only the actions bar's divider goes",
|
|
194
|
+
).toBe(allowed.container.querySelectorAll("hr").length - 1);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("PlatformClientDetailPanel last use", () => {
|
|
199
|
+
it("reads Never for a client that never minted a token", () => {
|
|
200
|
+
renderPanel(vi.fn());
|
|
201
|
+
expect(screen.getByText("Last used").nextElementSibling?.textContent).toBe(
|
|
202
|
+
"Never",
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("reads how long ago the client last minted a token", () => {
|
|
207
|
+
const used = clone(PlatformClientSchema, PLATFORM_CLIENT);
|
|
208
|
+
const now = new Date("2026-09-28T12:00:00Z");
|
|
209
|
+
used.status = create(PlatformClientStatusSchema, {
|
|
210
|
+
lastUsedAt: timestampFromDate(new Date(now.getTime() - 5 * 60_000)),
|
|
211
|
+
});
|
|
212
|
+
renderPanel(vi.fn(), used, ALLOW_ALL, now);
|
|
213
|
+
expect(screen.getByText("Last used").nextElementSibling?.textContent).toBe(
|
|
214
|
+
"5m",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|