@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.
Files changed (68) hide show
  1. package/api-key/ApiKeyListPanel.js +2 -4
  2. package/api-key/ApiKeyListPanel.js.map +1 -1
  3. package/iam-policy/OrgMembersPanel.d.ts +7 -0
  4. package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
  5. package/iam-policy/OrgMembersPanel.js +28 -12
  6. package/iam-policy/OrgMembersPanel.js.map +1 -1
  7. package/iam-policy/RoleSelector.d.ts +7 -1
  8. package/iam-policy/RoleSelector.d.ts.map +1 -1
  9. package/iam-policy/RoleSelector.js +2 -2
  10. package/iam-policy/RoleSelector.js.map +1 -1
  11. package/iam-policy/useOwnerAssignment.d.ts +23 -0
  12. package/iam-policy/useOwnerAssignment.d.ts.map +1 -0
  13. package/iam-policy/useOwnerAssignment.js +23 -0
  14. package/iam-policy/useOwnerAssignment.js.map +1 -0
  15. package/iam-policy/useRoleSelector.d.ts +6 -1
  16. package/iam-policy/useRoleSelector.d.ts.map +1 -1
  17. package/iam-policy/useRoleSelector.js +11 -3
  18. package/iam-policy/useRoleSelector.js.map +1 -1
  19. package/internal/LastUsedLabel.d.ts +20 -0
  20. package/internal/LastUsedLabel.d.ts.map +1 -0
  21. package/internal/LastUsedLabel.js +23 -0
  22. package/internal/LastUsedLabel.js.map +1 -0
  23. package/invitation/InvitationManager.d.ts.map +1 -1
  24. package/invitation/InvitationManager.js +5 -1
  25. package/invitation/InvitationManager.js.map +1 -1
  26. package/package.json +5 -5
  27. package/platform-client/PlatformClientDetailPanel.d.ts +13 -3
  28. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
  29. package/platform-client/PlatformClientDetailPanel.js +28 -10
  30. package/platform-client/PlatformClientDetailPanel.js.map +1 -1
  31. package/platform-client/PlatformClientListPanel.d.ts +19 -3
  32. package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
  33. package/platform-client/PlatformClientListPanel.js +13 -8
  34. package/platform-client/PlatformClientListPanel.js.map +1 -1
  35. package/platform-client/copy.d.ts +14 -0
  36. package/platform-client/copy.d.ts.map +1 -0
  37. package/platform-client/copy.js +14 -0
  38. package/platform-client/copy.js.map +1 -0
  39. package/platform-client/expiry.d.ts +4 -0
  40. package/platform-client/expiry.d.ts.map +1 -0
  41. package/platform-client/expiry.js +18 -0
  42. package/platform-client/expiry.js.map +1 -0
  43. package/settings/MembersSection.d.ts.map +1 -1
  44. package/settings/MembersSection.js +1 -1
  45. package/settings/MembersSection.js.map +1 -1
  46. package/settings/PlatformClientsSection.d.ts +7 -0
  47. package/settings/PlatformClientsSection.d.ts.map +1 -1
  48. package/settings/PlatformClientsSection.js +18 -2
  49. package/settings/PlatformClientsSection.js.map +1 -1
  50. package/src/api-key/ApiKeyListPanel.tsx +2 -6
  51. package/src/iam-policy/OrgMembersPanel.tsx +38 -8
  52. package/src/iam-policy/RoleSelector.tsx +8 -0
  53. package/src/iam-policy/__tests__/OrgMembersPanel.test.tsx +139 -0
  54. package/src/iam-policy/__tests__/useOwnerAssignment.test.tsx +55 -0
  55. package/src/iam-policy/useOwnerAssignment.ts +40 -0
  56. package/src/iam-policy/useRoleSelector.ts +14 -3
  57. package/src/internal/LastUsedLabel.tsx +41 -0
  58. package/src/invitation/InvitationManager.tsx +5 -0
  59. package/src/platform-client/PlatformClientDetailPanel.tsx +69 -12
  60. package/src/platform-client/PlatformClientListPanel.tsx +57 -21
  61. package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +139 -4
  62. package/src/platform-client/__tests__/PlatformClientListPanel.test.tsx +140 -0
  63. package/src/platform-client/__tests__/expiry.test.ts +37 -0
  64. package/src/platform-client/copy.ts +15 -0
  65. package/src/platform-client/expiry.ts +22 -0
  66. package/src/settings/MembersSection.tsx +2 -1
  67. package/src/settings/PlatformClientsSection.tsx +23 -2
  68. 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 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) : ""),
@@ -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 "Edit", "Rotate Secret", and "Delete"
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
- <Field
658
- label="Expires"
659
- value={formatDate(timestampDate(spec.expiresAt))}
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:text-foreground stg:mt-0.5 stg:break-all stg:text-xs",
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, and creation date. A delete button
31
- * triggers an inline confirmation flow.
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
- No platform clients configured.
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={onEdit}
278
- aria-label={`Edit ${name}`}
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-foreground stg:hover:bg-accent-hover",
329
+ "stg:text-muted-foreground stg:hover:text-destructive stg:hover:bg-destructive-subtle",
282
330
  "stg:transition-colors",
283
331
  )}
284
332
  >
285
- <PencilIcon />
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 = date < new Date();
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
- function renderPanel(update: ReturnType<typeof vi.fn>) {
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={PLATFORM_CLIENT} />
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.getByRole("button", { name: "Edit" }));
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
+ });