@stigmer/react 3.32.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 (32) hide show
  1. package/api-key/ApiKeyListPanel.js +2 -4
  2. package/api-key/ApiKeyListPanel.js.map +1 -1
  3. package/internal/LastUsedLabel.d.ts +20 -0
  4. package/internal/LastUsedLabel.d.ts.map +1 -0
  5. package/internal/LastUsedLabel.js +23 -0
  6. package/internal/LastUsedLabel.js.map +1 -0
  7. package/package.json +5 -5
  8. package/platform-client/PlatformClientDetailPanel.d.ts +13 -3
  9. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
  10. package/platform-client/PlatformClientDetailPanel.js +21 -8
  11. package/platform-client/PlatformClientDetailPanel.js.map +1 -1
  12. package/platform-client/PlatformClientListPanel.d.ts +19 -3
  13. package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
  14. package/platform-client/PlatformClientListPanel.js +11 -7
  15. package/platform-client/PlatformClientListPanel.js.map +1 -1
  16. package/platform-client/copy.d.ts +14 -0
  17. package/platform-client/copy.d.ts.map +1 -0
  18. package/platform-client/copy.js +14 -0
  19. package/platform-client/copy.js.map +1 -0
  20. package/settings/PlatformClientsSection.d.ts +7 -0
  21. package/settings/PlatformClientsSection.d.ts.map +1 -1
  22. package/settings/PlatformClientsSection.js +18 -2
  23. package/settings/PlatformClientsSection.js.map +1 -1
  24. package/src/api-key/ApiKeyListPanel.tsx +2 -6
  25. package/src/internal/LastUsedLabel.tsx +41 -0
  26. package/src/platform-client/PlatformClientDetailPanel.tsx +41 -7
  27. package/src/platform-client/PlatformClientListPanel.tsx +55 -20
  28. package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +99 -6
  29. package/src/platform-client/__tests__/PlatformClientListPanel.test.tsx +140 -0
  30. package/src/platform-client/copy.ts +15 -0
  31. package/src/settings/PlatformClientsSection.tsx +23 -2
  32. package/src/settings/__tests__/PlatformClientsSection.test.tsx +92 -5
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copy.js","sourceRoot":"","sources":["../../src/platform-client/copy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,yFAAyF;AACzF,MAAM,CAAC,MAAM,kCAAkC,GAC7C,6DAA6D,CAAC"}
@@ -8,6 +8,13 @@
8
8
  * A server too old to say either way is offered the button, and its own
9
9
  * answer to the create decides: the section never claims a limitation the
10
10
  * server did not report. Until the server has answered, it offers neither.
11
+ *
12
+ * The button also needs the caller to hold `can_create_platform_client` on
13
+ * the organization, which the model gives its admins. The list shows only
14
+ * the clients the caller may view (each client's creator and the
15
+ * organization's admins), so a caller who may not create them is told that
16
+ * the organization's admins manage them rather than that none is
17
+ * configured.
11
18
  */
12
19
  export declare function PlatformClientsSection(): import("react/jsx-runtime").JSX.Element;
13
20
  //# sourceMappingURL=PlatformClientsSection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PlatformClientsSection.d.ts","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"AAwBA;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,4CAkIrC"}
1
+ {"version":3,"file":"PlatformClientsSection.d.ts","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"AA0BA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,4CA8IrC"}
@@ -5,8 +5,10 @@ import { PlatformClientListPanel } from "../platform-client/PlatformClientListPa
5
5
  import { CreatePlatformClientForm } from "../platform-client/CreatePlatformClientForm.js";
6
6
  import { PlatformClientDetailPanel } from "../platform-client/PlatformClientDetailPanel.js";
7
7
  import { PlatformClientSecretAlert } from "../platform-client/PlatformClientSecretAlert.js";
8
+ import { PLATFORM_CLIENTS_MANAGED_BY_ADMINS } from "../platform-client/copy.js";
9
+ import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
8
10
  import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
9
- import { useActiveOrgSlug } from "../organization/OrgProvider.js";
11
+ import { useActiveOrgId, useActiveOrgSlug } from "../organization/OrgProvider.js";
10
12
  import { useServerInfo } from "../server-info.js";
11
13
  /**
12
14
  * Settings section for creating and maintaining platform clients.
@@ -18,13 +20,27 @@ import { useServerInfo } from "../server-info.js";
18
20
  * A server too old to say either way is offered the button, and its own
19
21
  * answer to the create decides: the section never claims a limitation the
20
22
  * server did not report. Until the server has answered, it offers neither.
23
+ *
24
+ * The button also needs the caller to hold `can_create_platform_client` on
25
+ * the organization, which the model gives its admins. The list shows only
26
+ * the clients the caller may view (each client's creator and the
27
+ * organization's admins), so a caller who may not create them is told that
28
+ * the organization's admins manage them rather than that none is
29
+ * configured.
21
30
  */
22
31
  export function PlatformClientsSection() {
23
32
  const headingId = useId();
24
33
  const org = useActiveOrgSlug();
34
+ const orgId = useActiveOrgId();
25
35
  const { serverInfo } = useServerInfo();
26
36
  const trustsEveryRequest = serverInfo?.authenticationRequired === false;
27
37
  const canMint = serverInfo !== null && !trustsEveryRequest;
38
+ const createCheck = useCheckPermission(orgId ? { kind: "organization", id: orgId } : null, "can_create_platform_client");
39
+ const canCreate = !createCheck.isLoading && createCheck.allowed;
40
+ const deniedCreate = !createCheck.isLoading && !createCheck.allowed;
41
+ const emptyState = deniedCreate
42
+ ? PLATFORM_CLIENTS_MANAGED_BY_ADMINS
43
+ : undefined;
28
44
  const [flow, setFlow] = useState({ phase: "idle" });
29
45
  const listRefetchRef = useRef(null);
30
46
  const handleRefetchRef = useCallback((refetch) => {
@@ -56,6 +72,6 @@ export function PlatformClientsSection() {
56
72
  listRefetchRef.current?.();
57
73
  setFlow({ phase: "idle" });
58
74
  }, []);
59
- return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsxs("div", { className: "stg:mb-3 stg:flex stg:items-center stg:justify-between", children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:text-sm stg:font-semibold", children: "Platform Clients" }), canMint && org && flow.phase === "idle" && (_jsx("button", { type: "button", onClick: () => setFlow({ phase: "creating" }), className: "stg:text-primary stg:hover:text-foreground stg:text-xs stg:font-medium stg:transition-colors", children: "+ New platform client" }))] }), _jsx("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: "Platform clients let your backend mint Stigmer user tokens so you can embed Stigmer components in your application without requiring OIDC federation." }), trustsEveryRequest && (_jsxs(CloudFeatureNotice, { className: "stg:mb-4", children: ["This server trusts every request, so nothing would verify a platform client's tokens and it does not mint them. Configure an identity provider (", _jsx("code", { children: "STIGMER_OIDC_ISSUER" }), ") to create platform clients."] })), flow.phase === "creating" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(CreatePlatformClientForm, { org: org, onCreated: handleCreated, onCancel: () => setFlow({ phase: "idle" }) }) })) : flow.phase === "revealing" ? (_jsxs("div", { className: "stg:space-y-4", children: [_jsx(PlatformClientSecretAlert, { clientId: flow.clientId, clientSecret: flow.clientSecret, context: flow.context, onDismiss: () => setFlow({ phase: "idle" }) }), _jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef })] })) : flow.phase === "editing" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(PlatformClientDetailPanel, { platformClient: flow.platformClient, onUpdated: handleUpdated, onSecretRotated: handleSecretRotated, onDeleted: handleDeleted, onBack: () => setFlow({ phase: "idle" }) }) })) : (_jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef }))] }));
75
+ return (_jsxs("section", { "aria-labelledby": headingId, children: [_jsxs("div", { className: "stg:mb-3 stg:flex stg:items-center stg:justify-between", children: [_jsx("h2", { id: headingId, className: "stg:text-foreground stg:text-sm stg:font-semibold", children: "Platform Clients" }), canMint && canCreate && org && flow.phase === "idle" && (_jsx("button", { type: "button", onClick: () => setFlow({ phase: "creating" }), className: "stg:text-primary stg:hover:text-foreground stg:text-xs stg:font-medium stg:transition-colors", children: "+ New platform client" }))] }), _jsx("p", { className: "stg:text-muted-foreground stg:mb-4 stg:text-xs", children: "Platform clients let your backend mint Stigmer user tokens so you can embed Stigmer components in your application without requiring OIDC federation." }), trustsEveryRequest && (_jsxs(CloudFeatureNotice, { className: "stg:mb-4", children: ["This server trusts every request, so nothing would verify a platform client's tokens and it does not mint them. Configure an identity provider (", _jsx("code", { children: "STIGMER_OIDC_ISSUER" }), ") to create platform clients."] })), flow.phase === "creating" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(CreatePlatformClientForm, { org: org, onCreated: handleCreated, onCancel: () => setFlow({ phase: "idle" }) }) })) : flow.phase === "revealing" ? (_jsxs("div", { className: "stg:space-y-4", children: [_jsx(PlatformClientSecretAlert, { clientId: flow.clientId, clientSecret: flow.clientSecret, context: flow.context, onDismiss: () => setFlow({ phase: "idle" }) }), _jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef, emptyState: emptyState })] })) : flow.phase === "editing" ? (_jsx("div", { className: "stg:border-border stg:bg-card stg:rounded-lg stg:border stg:p-4", children: _jsx(PlatformClientDetailPanel, { platformClient: flow.platformClient, onUpdated: handleUpdated, onSecretRotated: handleSecretRotated, onDeleted: handleDeleted, onBack: () => setFlow({ phase: "idle" }) }) })) : (_jsx(PlatformClientListPanel, { org: org, onEdit: (pc) => setFlow({ phase: "editing", platformClient: pc }), onRefetchRef: handleRefetchRef, emptyState: emptyState }))] }));
60
76
  }
61
77
  //# sourceMappingURL=PlatformClientsSection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"PlatformClientsSection.js","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PlatformClientsSection.js","sourceRoot":"","sources":["../../src/settings/PlatformClientsSection.tsx"],"names":[],"mappings":"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"}
@@ -9,7 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js"
9
9
  import { useApiKeyList } from "./useApiKeyList.js";
10
10
  import { useDeleteApiKey } from "./useDeleteApiKey.js";
11
11
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
12
- import { formatRelativeTime } from "../activity/format-relative-time.js";
12
+ import { LastUsedLabel } from "../internal/LastUsedLabel.js";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Public API
@@ -263,11 +263,7 @@ function ApiKeyRow({
263
263
  ? `Expires ${formatShortDate(timestampDate(expiresAt))}`
264
264
  : "No expiry"}
265
265
  </span>
266
- <span>
267
- {lastUsedAt
268
- ? formatRelativeTime(timestampDate(lastUsedAt), now)
269
- : "Never used"}
270
- </span>
266
+ <LastUsedLabel at={lastUsedAt} now={now} />
271
267
  </div>
272
268
 
273
269
  {/* Delete button */}
@@ -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
+ }
@@ -11,6 +11,8 @@ 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";
15
17
  import { isPlatformClientExpired } from "./expiry.js";
16
18
 
@@ -33,14 +35,24 @@ export interface PlatformClientDetailPanelProps {
33
35
  readonly onBack?: () => void;
34
36
  /** Additional CSS class names for the root container. */
35
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;
36
45
  }
37
46
 
38
47
  /**
39
48
  * View and edit panel for an existing platform client.
40
49
  *
41
50
  * In **view mode**, displays all configuration fields in a structured
42
- * label/value layout with "Edit", "Rotate Secret", and "Delete"
43
- * 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.
44
56
  *
45
57
  * In **edit mode**, mutable spec fields become editable: JIT
46
58
  * provisioning toggles, expiry, auto-grant role, and allowed
@@ -71,11 +83,18 @@ export function PlatformClientDetailPanel({
71
83
  onDeleted,
72
84
  onBack,
73
85
  className,
86
+ now,
74
87
  }: PlatformClientDetailPanelProps) {
75
88
  const baseId = useId();
76
89
  const spec = platformClient.spec;
77
90
  const meta = platformClient.metadata;
78
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
+
79
98
  const { update, isUpdating, error: updateError, clearError: clearUpdateError } =
80
99
  useUpdatePlatformClient();
81
100
  const { rotateSecret, isRotating, error: rotateError, clearError: clearRotateError } =
@@ -239,6 +258,7 @@ export function PlatformClientDetailPanel({
239
258
 
240
259
  const createdAt = platformClient.status?.audit?.specAudit?.createdAt;
241
260
  const updatedAt = platformClient.status?.audit?.specAudit?.updatedAt;
261
+ const lastUsedAt = platformClient.status?.lastUsedAt;
242
262
 
243
263
  return (
244
264
  <div className={cn("stg:space-y-4", className)}>
@@ -272,7 +292,7 @@ export function PlatformClientDetailPanel({
272
292
  </div>
273
293
  </div>
274
294
 
275
- {mode === "view" && (
295
+ {mode === "view" && canEdit && (
276
296
  <button
277
297
  type="button"
278
298
  onClick={enterEdit}
@@ -293,6 +313,8 @@ export function PlatformClientDetailPanel({
293
313
  spec={spec}
294
314
  createdAt={createdAt}
295
315
  updatedAt={updatedAt}
316
+ lastUsedAt={lastUsedAt}
317
+ now={now}
296
318
  />
297
319
  ) : (
298
320
  <form onSubmit={handleSave} className="stg:space-y-3">
@@ -499,13 +521,13 @@ export function PlatformClientDetailPanel({
499
521
  </form>
500
522
  )}
501
523
 
502
- {/* Actions bar (view mode only) */}
503
- {mode === "view" && (
524
+ {/* Actions bar (view mode only, and only the actions the caller may take) */}
525
+ {mode === "view" && (canEdit || canDelete) && (
504
526
  <div className="stg:space-y-2 stg:pt-2">
505
527
  <hr className="stg:border-border-muted" />
506
528
 
507
529
  {/* Rotate Secret */}
508
- {confirmingRotate ? (
530
+ {!canEdit ? null : confirmingRotate ? (
509
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">
510
532
  <p className="stg:text-xs stg:text-foreground">
511
533
  Rotate secret? The current secret will be
@@ -570,7 +592,7 @@ export function PlatformClientDetailPanel({
570
592
  )}
571
593
 
572
594
  {/* Delete */}
573
- {confirmingDelete ? (
595
+ {!canDelete ? null : confirmingDelete ? (
574
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">
575
597
  <div className="stg:min-w-0 stg:flex-1">
576
598
  <p className="stg:text-xs stg:text-foreground">
@@ -641,10 +663,14 @@ function ViewMode({
641
663
  spec,
642
664
  createdAt,
643
665
  updatedAt,
666
+ lastUsedAt,
667
+ now,
644
668
  }: {
645
669
  spec: PlatformClient["spec"];
646
670
  createdAt?: Timestamp;
647
671
  updatedAt?: Timestamp;
672
+ lastUsedAt?: Timestamp;
673
+ now?: Date;
648
674
  }) {
649
675
  return (
650
676
  <dl className="stg:space-y-2.5">
@@ -730,6 +756,14 @@ function ViewMode({
730
756
  value={formatDate(timestampDate(updatedAt))}
731
757
  />
732
758
  )}
759
+ <Field
760
+ label="Last used"
761
+ value={
762
+ lastUsedAt
763
+ ? formatRelativeTime(timestampDate(lastUsedAt), now)
764
+ : "Never"
765
+ }
766
+ />
733
767
  </div>
734
768
  </dl>
735
769
  );
@@ -1,10 +1,13 @@
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";
@@ -19,8 +22,22 @@ export interface PlatformClientListPanelProps {
19
22
  readonly onEdit?: (pc: PlatformClient) => void;
20
23
  /** Expose the refetch function so parents can trigger a list refresh. */
21
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;
22
32
  /** Additional CSS class names for the root container. */
23
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;
24
41
  }
25
42
 
26
43
  /**
@@ -28,8 +45,9 @@ export interface PlatformClientListPanelProps {
28
45
  * organization with inline delete confirmation.
29
46
  *
30
47
  * Each row shows the client name, `client_id` (monospace), secret
31
- * fingerprint, expiry status, and creation date. A delete button
32
- * 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.
33
51
  *
34
52
  * Platform clients are admin-level resources with small cardinality
35
53
  * (typically 1–5 per org), so the list is rendered without pagination.
@@ -54,7 +72,9 @@ export function PlatformClientListPanel({
54
72
  org,
55
73
  onEdit,
56
74
  onRefetchRef,
75
+ emptyState = "No platform clients configured.",
57
76
  className,
77
+ now,
58
78
  }: PlatformClientListPanelProps) {
59
79
  const { platformClients, isLoading, error, refetch } =
60
80
  usePlatformClientList(org);
@@ -97,7 +117,7 @@ export function PlatformClientListPanel({
97
117
  className,
98
118
  )}
99
119
  >
100
- No platform clients configured.
120
+ {emptyState}
101
121
  </p>
102
122
  );
103
123
  }
@@ -115,6 +135,7 @@ export function PlatformClientListPanel({
115
135
  key={id}
116
136
  platformClient={pc}
117
137
  isConfirming={confirmingId === id}
138
+ now={now}
118
139
  onEdit={onEdit ? () => onEdit(pc) : undefined}
119
140
  onConfirmDelete={() => setConfirmingId(id)}
120
141
  onCancelDelete={() => setConfirmingId(null)}
@@ -136,6 +157,7 @@ export function PlatformClientListPanel({
136
157
  function PlatformClientRow({
137
158
  platformClient,
138
159
  isConfirming,
160
+ now,
139
161
  onEdit,
140
162
  onConfirmDelete,
141
163
  onCancelDelete,
@@ -143,6 +165,7 @@ function PlatformClientRow({
143
165
  }: {
144
166
  platformClient: PlatformClient;
145
167
  isConfirming: boolean;
168
+ now?: Date;
146
169
  onEdit?: () => void;
147
170
  onConfirmDelete: () => void;
148
171
  onCancelDelete: () => void;
@@ -158,6 +181,7 @@ function PlatformClientRow({
158
181
  const fingerprint = spec?.secretFingerprint ?? "";
159
182
  const createdAt =
160
183
  platformClient.status?.audit?.specAudit?.createdAt;
184
+ const lastUsedAt = platformClient.status?.lastUsedAt;
161
185
 
162
186
  const handleDelete = useCallback(async () => {
163
187
  try {
@@ -269,35 +293,46 @@ function PlatformClientRow({
269
293
  </TooltipContent>
270
294
  </Tooltip>
271
295
  )}
296
+ <LastUsedLabel at={lastUsedAt} now={now} />
272
297
  </div>
273
298
 
274
299
  <div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1">
275
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
+ >
276
323
  <button
277
324
  type="button"
278
- onClick={onEdit}
279
- aria-label={`Edit ${name}`}
325
+ onClick={onConfirmDelete}
326
+ aria-label={`Delete ${name}`}
280
327
  className={cn(
281
328
  "stg:shrink-0 stg:rounded stg:p-1",
282
- "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",
283
330
  "stg:transition-colors",
284
331
  )}
285
332
  >
286
- <PencilIcon />
333
+ <TrashIcon />
287
334
  </button>
288
- )}
289
- <button
290
- type="button"
291
- onClick={onConfirmDelete}
292
- aria-label={`Delete ${name}`}
293
- className={cn(
294
- "stg:shrink-0 stg:rounded stg:p-1",
295
- "stg:text-muted-foreground stg:hover:text-destructive stg:hover:bg-destructive-subtle",
296
- "stg:transition-colors",
297
- )}
298
- >
299
- <TrashIcon />
300
- </button>
335
+ </PermissionGate>
301
336
  </div>
302
337
  </div>
303
338
  );
@@ -4,9 +4,11 @@ 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";
@@ -23,6 +25,13 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
23
25
  * says "Expired", and the rotate confirmation says rotating will not help,
24
26
  * because rotation leaves the expiry as it is. A live client shows
25
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`.
26
35
  */
27
36
 
28
37
  const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
@@ -46,16 +55,25 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
46
55
  },
47
56
  });
48
57
 
58
+ const ALLOW_ALL = async (_input: CheckMyPermissionInput) => ({
59
+ isAuthorized: true,
60
+ });
61
+
49
62
  function renderPanel(
50
63
  update: ReturnType<typeof vi.fn>,
51
64
  platformClient: PlatformClient = PLATFORM_CLIENT,
65
+ checkMyPermission: (
66
+ input: CheckMyPermissionInput,
67
+ ) => Promise<{ isAuthorized: boolean }> = ALLOW_ALL,
68
+ now?: Date,
52
69
  ) {
53
70
  const client = {
54
71
  platformclient: { update },
72
+ iamPolicy: { checkMyPermission },
55
73
  } as never;
56
74
  return render(
57
75
  <StigmerContext.Provider value={client}>
58
- <PlatformClientDetailPanel platformClient={platformClient} />
76
+ <PlatformClientDetailPanel platformClient={platformClient} now={now} />
59
77
  </StigmerContext.Provider>,
60
78
  );
61
79
  }
@@ -79,7 +97,7 @@ describe("PlatformClientDetailPanel save payload", () => {
79
97
  const update = vi.fn(async (_input: PlatformClientInput) => PLATFORM_CLIENT);
80
98
  renderPanel(update);
81
99
 
82
- fireEvent.click(screen.getByRole("button", { name: "Edit" }));
100
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
83
101
  fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
84
102
 
85
103
  await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
@@ -102,23 +120,98 @@ describe("PlatformClientDetailPanel save payload", () => {
102
120
  });
103
121
 
104
122
  describe("PlatformClientDetailPanel expiry", () => {
105
- it("an expired client reads Expired, and its rotate confirmation says rotating will not help", () => {
123
+ it("an expired client reads Expired, and its rotate confirmation says rotating will not help", async () => {
106
124
  renderPanel(vi.fn(), clientExpiringInDays(-1));
107
125
 
108
126
  expect(screen.getByText("Expired")).toBeTruthy();
109
127
  expect(screen.queryByText("Expires")).toBeNull();
110
128
 
111
- fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
129
+ fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
112
130
  expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
113
131
  });
114
132
 
115
- it("a live client reads Expires, and its rotate confirmation carries no expiry warning", () => {
133
+ it("a live client reads Expires, and its rotate confirmation carries no expiry warning", async () => {
116
134
  renderPanel(vi.fn(), clientExpiringInDays(1));
117
135
 
118
136
  expect(screen.getByText("Expires")).toBeTruthy();
119
137
  expect(screen.queryByText("Expired")).toBeNull();
120
138
 
121
- fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
139
+ fireEvent.click(await screen.findByRole("button", { name: "Rotate secret" }));
122
140
  expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
123
141
  });
124
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
+ });