@stigmer/react 3.30.0 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/iam-policy/OrgMembersPanel.d.ts +7 -0
  2. package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
  3. package/iam-policy/OrgMembersPanel.js +28 -12
  4. package/iam-policy/OrgMembersPanel.js.map +1 -1
  5. package/iam-policy/RoleSelector.d.ts +7 -1
  6. package/iam-policy/RoleSelector.d.ts.map +1 -1
  7. package/iam-policy/RoleSelector.js +2 -2
  8. package/iam-policy/RoleSelector.js.map +1 -1
  9. package/iam-policy/useOwnerAssignment.d.ts +23 -0
  10. package/iam-policy/useOwnerAssignment.d.ts.map +1 -0
  11. package/iam-policy/useOwnerAssignment.js +23 -0
  12. package/iam-policy/useOwnerAssignment.js.map +1 -0
  13. package/iam-policy/useRoleSelector.d.ts +6 -1
  14. package/iam-policy/useRoleSelector.d.ts.map +1 -1
  15. package/iam-policy/useRoleSelector.js +11 -3
  16. package/iam-policy/useRoleSelector.js.map +1 -1
  17. package/invitation/InvitationManager.d.ts.map +1 -1
  18. package/invitation/InvitationManager.js +5 -1
  19. package/invitation/InvitationManager.js.map +1 -1
  20. package/oauth-app/OAuthAppDetailPanel.d.ts +5 -1
  21. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
  22. package/oauth-app/OAuthAppDetailPanel.js +10 -5
  23. package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
  24. package/oauth-app/OAuthAppListPanel.d.ts +11 -3
  25. package/oauth-app/OAuthAppListPanel.d.ts.map +1 -1
  26. package/oauth-app/OAuthAppListPanel.js +4 -4
  27. package/oauth-app/OAuthAppListPanel.js.map +1 -1
  28. package/oauth-app/copy.d.ts +15 -0
  29. package/oauth-app/copy.d.ts.map +1 -0
  30. package/oauth-app/copy.js +15 -0
  31. package/oauth-app/copy.js.map +1 -0
  32. package/oauth-app/useDeleteOAuthApp.d.ts +4 -3
  33. package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -1
  34. package/oauth-app/useDeleteOAuthApp.js +4 -3
  35. package/oauth-app/useDeleteOAuthApp.js.map +1 -1
  36. package/oauth-app/useOAuthAppList.d.ts +6 -4
  37. package/oauth-app/useOAuthAppList.d.ts.map +1 -1
  38. package/oauth-app/useOAuthAppList.js +6 -4
  39. package/oauth-app/useOAuthAppList.js.map +1 -1
  40. package/package.json +5 -5
  41. package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
  42. package/platform-client/PlatformClientDetailPanel.js +9 -4
  43. package/platform-client/PlatformClientDetailPanel.js.map +1 -1
  44. package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
  45. package/platform-client/PlatformClientListPanel.js +2 -1
  46. package/platform-client/PlatformClientListPanel.js.map +1 -1
  47. package/platform-client/expiry.d.ts +4 -0
  48. package/platform-client/expiry.d.ts.map +1 -0
  49. package/platform-client/expiry.js +18 -0
  50. package/platform-client/expiry.js.map +1 -0
  51. package/settings/MembersSection.d.ts.map +1 -1
  52. package/settings/MembersSection.js +1 -1
  53. package/settings/MembersSection.js.map +1 -1
  54. package/settings/OAuthAppsSection.d.ts +9 -1
  55. package/settings/OAuthAppsSection.d.ts.map +1 -1
  56. package/settings/OAuthAppsSection.js +17 -3
  57. package/settings/OAuthAppsSection.js.map +1 -1
  58. package/src/iam-policy/OrgMembersPanel.tsx +38 -8
  59. package/src/iam-policy/RoleSelector.tsx +8 -0
  60. package/src/iam-policy/__tests__/OrgMembersPanel.test.tsx +139 -0
  61. package/src/iam-policy/__tests__/useOwnerAssignment.test.tsx +55 -0
  62. package/src/iam-policy/useOwnerAssignment.ts +40 -0
  63. package/src/iam-policy/useRoleSelector.ts +14 -3
  64. package/src/invitation/InvitationManager.tsx +5 -0
  65. package/src/oauth-app/OAuthAppDetailPanel.tsx +39 -24
  66. package/src/oauth-app/OAuthAppListPanel.tsx +12 -3
  67. package/src/oauth-app/__tests__/OAuthAppDetailPanel.test.tsx +57 -9
  68. package/src/oauth-app/copy.ts +16 -0
  69. package/src/oauth-app/useDeleteOAuthApp.ts +4 -3
  70. package/src/oauth-app/useOAuthAppList.ts +6 -4
  71. package/src/platform-client/PlatformClientDetailPanel.tsx +28 -5
  72. package/src/platform-client/PlatformClientListPanel.tsx +2 -1
  73. package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +45 -3
  74. package/src/platform-client/__tests__/expiry.test.ts +37 -0
  75. package/src/platform-client/expiry.ts +22 -0
  76. package/src/settings/MembersSection.tsx +2 -1
  77. package/src/settings/OAuthAppsSection.tsx +21 -3
  78. package/src/settings/__tests__/OAuthAppsSection.test.tsx +133 -0
@@ -12,6 +12,9 @@ import {
12
12
  type GranteeKind,
13
13
  } from "@stigmer/sdk";
14
14
 
15
+ /** The default for `omit`: one shared empty list, so the options never recompute for it. */
16
+ const NOTHING_OMITTED: readonly IamRole[] = [];
17
+
15
18
  /** A single role option with display metadata. */
16
19
  export interface RoleOption {
17
20
  /** The IamRole enum value. */
@@ -52,6 +55,11 @@ export interface UseRoleSelectorReturn {
52
55
  * @param defaultRole - Optional initial selection.
53
56
  * @param granteeKind - Who the role is for: a person (the default) may
54
57
  * hold any grantable role, a team only the kind's team roles.
58
+ * @param omit - Roles to leave out even though the kind grants them: the
59
+ * ones the caller may not assign (an organization's `owner`, for anyone
60
+ * who is not its owner). The server refuses them anyway; leaving them out
61
+ * keeps a choice off the screen that could only fail. Pass a stable list
62
+ * (a constant, or a memoized one): the options follow its identity.
55
63
  *
56
64
  * @example
57
65
  * ```tsx
@@ -68,6 +76,7 @@ export function useRoleSelector(
68
76
  kind: ApiResourceKind | null,
69
77
  defaultRole?: IamRole,
70
78
  granteeKind: GranteeKind = "identity_account",
79
+ omit: readonly IamRole[] = NOTHING_OMITTED,
71
80
  ): UseRoleSelectorReturn {
72
81
  const [selected, setSelected] = useState<IamRole | null>(
73
82
  defaultRole ?? null,
@@ -75,15 +84,17 @@ export function useRoleSelector(
75
84
 
76
85
  const options = useMemo<readonly RoleOption[]>(() => {
77
86
  if (kind === null) return [];
78
- const roles =
79
- granteeKind === "team" ? getTeamGrantableRoles(kind) : getGrantableRoles(kind);
87
+ const left = new Set(omit);
88
+ const roles = (
89
+ granteeKind === "team" ? getTeamGrantableRoles(kind) : getGrantableRoles(kind)
90
+ ).filter((role) => !left.has(role));
80
91
  return roles.map((role) => ({
81
92
  role,
82
93
  label: iamRoleDisplayName(role),
83
94
  description: iamRoleDescription(role),
84
95
  value: iamRoleToString(role),
85
96
  }));
86
- }, [kind, granteeKind]);
97
+ }, [kind, granteeKind, omit]);
87
98
 
88
99
  const selectedValue = useMemo(
89
100
  () => (selected !== null ? iamRoleToString(selected) : ""),
@@ -18,6 +18,7 @@ import { useRevokeInvitation } from "./useRevokeInvitation.js";
18
18
  import { InvitationCreatedAlert } from "./InvitationCreatedAlert.js";
19
19
  import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
20
20
  import { RoleSelector } from "../iam-policy/RoleSelector.js";
21
+ import { useOwnerAssignment } from "../iam-policy/useOwnerAssignment.js";
21
22
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
22
23
  import { useCopyFeedback } from "../internal/useCopyFeedback.js";
23
24
 
@@ -237,6 +238,9 @@ function CreateInvitationForm({
237
238
  onCancel: () => void;
238
239
  }) {
239
240
  const { create, isCreating, error, clearError } = useCreateInvitation();
241
+ // An invitation grants with its creator's authority: only an owner may
242
+ // invite an owner, and the server refuses anyone else.
243
+ const { unassignable } = useOwnerAssignment(org);
240
244
  const baseId = useId();
241
245
 
242
246
  const [label, setLabel] = useState("");
@@ -303,6 +307,7 @@ function CreateInvitationForm({
303
307
  kind={ApiResourceKind.organization}
304
308
  selected={role}
305
309
  onSelect={(r) => setRole(r)}
310
+ omitRoles={unassignable}
306
311
  disabled={isCreating}
307
312
  />
308
313
 
@@ -11,6 +11,7 @@ import {
11
11
  import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
12
12
  import { useUpdateOAuthApp } from "./useUpdateOAuthApp.js";
13
13
  import { useDeleteOAuthApp } from "./useDeleteOAuthApp.js";
14
+ import { PermissionGate } from "../iam-policy/PermissionGate.js";
14
15
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -35,7 +36,11 @@ export interface OAuthAppDetailPanelProps {
35
36
  * View and edit panel for an existing OAuth app.
36
37
  *
37
38
  * In **view mode**, displays all OAuth configuration fields in a
38
- * structured label/value layout with "Edit" and "Delete" buttons.
39
+ * structured label/value layout, with an "Edit" button for a caller who
40
+ * may edit the app (`can_edit`) and a "Delete" button for one who may
41
+ * delete it (`can_delete`). Both belong to the app's creator alone: the
42
+ * organization's admins may view an app they did not create, never change
43
+ * it.
39
44
  *
40
45
  * In **edit mode**, fields become editable inputs. The client secret
41
46
  * field shows a placeholder — leave it empty to keep the existing
@@ -223,31 +228,41 @@ export function OAuthAppDetailPanel({
223
228
 
224
229
  {mode === "view" && (
225
230
  <div className="stg:flex stg:items-center stg:gap-1">
226
- <button
227
- type="button"
228
- onClick={enterEdit}
229
- className={cn(
230
- "stg:shrink-0 stg:rounded-md stg:px-2.5 stg:py-1.5 stg:text-xs stg:font-medium",
231
- "stg:text-muted-foreground stg:hover:text-foreground stg:hover:bg-accent-hover",
232
- "stg:transition-colors",
233
- )}
231
+ <PermissionGate
232
+ resource={{ kind: "oauth_app", id: meta?.id ?? "" }}
233
+ relation="can_edit"
234
234
  >
235
- Edit
236
- </button>
237
- <button
238
- type="button"
239
- onClick={() => {
240
- clearDeleteError();
241
- setConfirmingDelete(true);
242
- }}
243
- className={cn(
244
- "stg:shrink-0 stg:rounded-md stg:px-2.5 stg:py-1.5 stg:text-xs stg:font-medium",
245
- "stg:text-destructive-muted stg:hover:text-destructive stg:hover:bg-destructive-subtle",
246
- "stg:transition-colors",
247
- )}
235
+ <button
236
+ type="button"
237
+ onClick={enterEdit}
238
+ className={cn(
239
+ "stg:shrink-0 stg:rounded-md stg:px-2.5 stg:py-1.5 stg:text-xs stg:font-medium",
240
+ "stg:text-muted-foreground stg:hover:text-foreground stg:hover:bg-accent-hover",
241
+ "stg:transition-colors",
242
+ )}
243
+ >
244
+ Edit
245
+ </button>
246
+ </PermissionGate>
247
+ <PermissionGate
248
+ resource={{ kind: "oauth_app", id: meta?.id ?? "" }}
249
+ relation="can_delete"
248
250
  >
249
- Delete
250
- </button>
251
+ <button
252
+ type="button"
253
+ onClick={() => {
254
+ clearDeleteError();
255
+ setConfirmingDelete(true);
256
+ }}
257
+ className={cn(
258
+ "stg:shrink-0 stg:rounded-md stg:px-2.5 stg:py-1.5 stg:text-xs stg:font-medium",
259
+ "stg:text-destructive-muted stg:hover:text-destructive stg:hover:bg-destructive-subtle",
260
+ "stg:transition-colors",
261
+ )}
262
+ >
263
+ Delete
264
+ </button>
265
+ </PermissionGate>
251
266
  </div>
252
267
  )}
253
268
  </div>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { ReactNode } from "react";
3
4
  import { cn } from "@stigmer/theme";
4
5
  import { getUserMessage } from "@stigmer/sdk";
5
6
  import { timestampDate } from "@bufbuild/protobuf/wkt";
@@ -23,13 +24,20 @@ export interface OAuthAppListPanelProps {
23
24
  readonly onEdit?: (app: OAuthApp) => void;
24
25
  /** Re-expose refetch so parents can trigger a list refresh. */
25
26
  readonly onRefetchRef?: (refetch: () => void) => void;
27
+ /**
28
+ * Shown when the list is empty. Defaults to "No OAuth apps configured
29
+ * yet." The list holds only the apps the caller may view (each app's
30
+ * creator and the organization's admins), so a host that knows the
31
+ * caller may not manage OAuth apps should say who does instead.
32
+ */
33
+ readonly emptyState?: ReactNode;
26
34
  /** Additional CSS class names for the root container. */
27
35
  readonly className?: string;
28
36
  }
29
37
 
30
38
  /**
31
- * Displays a list of {@link OAuthApp} resources owned by an
32
- * organization.
39
+ * Displays the {@link OAuthApp} resources of an organization that the
40
+ * caller may view.
33
41
  *
34
42
  * Each row shows the provider name, client ID (non-secret), and
35
43
  * creation date. When `onEdit` is provided, rows include a pencil
@@ -56,6 +64,7 @@ export function OAuthAppListPanel({
56
64
  org,
57
65
  onEdit,
58
66
  onRefetchRef,
67
+ emptyState = "No OAuth apps configured yet.",
59
68
  className,
60
69
  }: OAuthAppListPanelProps) {
61
70
  const { oauthApps, isLoading, error, refetch } = useOAuthAppList(org);
@@ -97,7 +106,7 @@ export function OAuthAppListPanel({
97
106
  className,
98
107
  )}
99
108
  >
100
- No OAuth apps configured yet.
109
+ {emptyState}
101
110
  </p>
102
111
  );
103
112
  }
@@ -6,17 +6,24 @@ import {
6
6
  type OAuthApp,
7
7
  } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/api_pb";
8
8
  import { TokenEndpointAuthMethod } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/spec_pb";
9
+ import type { CheckMyPermissionInput } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
9
10
  import type { OAuthAppInput } from "@stigmer/sdk";
10
11
  import { StigmerContext } from "../../context";
11
12
  import { OAuthAppDetailPanel } from "../OAuthAppDetailPanel";
12
13
 
13
14
  /**
14
- * Regression suite for the full-spec-replace wipe bug: the panel must
15
- * spread `toOAuthAppUpdateInput` and override only what it edits, so a
16
- * spec field the form does not know about survives the save. The secret
17
- * has its own contract: an empty secret input sends the fetched value —
18
- * the server's redaction marker — which the update pipeline treats as
19
- * "keep the stored secret" (oss#395 pins the marker/ciphertext boundary).
15
+ * Pins two things about the detail panel:
16
+ *
17
+ * - the full-spec-replace wipe bug: the panel must spread
18
+ * `toOAuthAppUpdateInput` and override only what it edits, so a spec
19
+ * field the form does not know about survives the save. The secret has
20
+ * its own contract: an empty secret input sends the fetched value — the
21
+ * server's redaction marker — which the update pipeline treats as "keep
22
+ * the stored secret" (oss#395 pins the marker/ciphertext boundary);
23
+ * - Edit is offered only with `can_edit` and Delete only with
24
+ * `can_delete` on the app, so an organization admin who may view an app
25
+ * they did not create is never handed a control the server would
26
+ * refuse (both belong to the creator; stigmer/stigmer#1257).
20
27
  */
21
28
 
22
29
  const REDACTED = "***REDACTED***";
@@ -39,9 +46,15 @@ const APP: OAuthApp = create(OAuthAppSchema, {
39
46
  },
40
47
  });
41
48
 
42
- function renderPanel(update: ReturnType<typeof vi.fn>) {
49
+ function renderPanel(
50
+ update: ReturnType<typeof vi.fn>,
51
+ checkMyPermission: ReturnType<typeof vi.fn> = vi.fn(async () => ({
52
+ isAuthorized: true,
53
+ })),
54
+ ) {
43
55
  const client = {
44
56
  oauthapp: { update },
57
+ iamPolicy: { checkMyPermission },
45
58
  } as never;
46
59
  return render(
47
60
  <StigmerContext.Provider value={client}>
@@ -57,7 +70,7 @@ describe("OAuthAppDetailPanel save payload", () => {
57
70
  const update = vi.fn(async (_input: OAuthAppInput) => APP);
58
71
  renderPanel(update);
59
72
 
60
- fireEvent.click(screen.getByRole("button", { name: "Edit" }));
73
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
61
74
  const provider = await screen.findByLabelText("Provider");
62
75
  fireEvent.change(provider, { target: { value: "github-enterprise" } });
63
76
  fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
@@ -83,7 +96,7 @@ describe("OAuthAppDetailPanel save payload", () => {
83
96
  const update = vi.fn(async (_input: OAuthAppInput) => APP);
84
97
  renderPanel(update);
85
98
 
86
- fireEvent.click(screen.getByRole("button", { name: "Edit" }));
99
+ fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
87
100
  const secret = await screen.findByLabelText("Client secret");
88
101
  fireEvent.change(secret, { target: { value: "new-secret" } });
89
102
  fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
@@ -92,3 +105,38 @@ describe("OAuthAppDetailPanel save payload", () => {
92
105
  expect(update.mock.calls[0]![0].clientSecret).toBe("new-secret");
93
106
  });
94
107
  });
108
+
109
+ describe("OAuthAppDetailPanel edit and delete gates", () => {
110
+ it("offers Edit only with can_edit and Delete only with can_delete on the app", async () => {
111
+ const checkMyPermission = vi.fn(async (input: CheckMyPermissionInput) => ({
112
+ isAuthorized: input.relation === "can_delete",
113
+ }));
114
+ renderPanel(vi.fn(), checkMyPermission);
115
+
116
+ await screen.findByRole("button", { name: "Delete" });
117
+ expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
118
+ const asked = checkMyPermission.mock.calls.map(
119
+ ([input]) =>
120
+ `${input.resource?.kind}:${input.resource?.id}:${input.relation}`,
121
+ );
122
+ expect(asked.sort()).toEqual([
123
+ "oauth_app:oa-1:can_delete",
124
+ "oauth_app:oa-1:can_edit",
125
+ ]);
126
+ });
127
+
128
+ it("an admin who did not create the app reads it with neither control", async () => {
129
+ const checkMyPermission = vi.fn(async (_input: CheckMyPermissionInput) => ({
130
+ isAuthorized: false,
131
+ }));
132
+ renderPanel(vi.fn(), checkMyPermission);
133
+
134
+ await waitFor(() => expect(checkMyPermission).toHaveBeenCalledTimes(2));
135
+ // The configuration stays readable; only the change affordances are withheld.
136
+ expect(screen.getByRole("heading", { name: "github" })).toBeTruthy();
137
+ await waitFor(() =>
138
+ expect(screen.queryByRole("button", { name: "Edit" })).toBeNull(),
139
+ );
140
+ expect(screen.queryByRole("button", { name: "Delete" })).toBeNull();
141
+ });
142
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OAuth-app copy shared by more than one surface, so the settings section
3
+ * and anything else that lists an organization's OAuth apps say the same
4
+ * thing.
5
+ *
6
+ * OAuth apps are administrative infrastructure: an app carries its
7
+ * organization's vendor credentials and endpoints, so its creator and the
8
+ * organization's admins see it and other members may not see it at all
9
+ * (stigmer/stigmer#1257). An empty list is therefore not evidence that none
10
+ * is configured, so a caller who may not create OAuth apps is told who
11
+ * manages them instead of being told that nothing exists.
12
+ */
13
+
14
+ /** Shown in place of the empty state to a caller who may not create OAuth apps. */
15
+ export const OAUTH_APPS_MANAGED_BY_ADMINS =
16
+ "OAuth apps are managed by your organization's admins.";
@@ -22,9 +22,10 @@ export interface UseDeleteOAuthAppReturn {
22
22
  * state.
23
23
  *
24
24
  * Deletes an OAuth app by its resource ID. Returns the deleted
25
- * {@link OAuthApp} on success so callers can confirm which app was
26
- * removed. The deletion is permanent — any MCP server overrides
27
- * referencing this app will lose their binding.
25
+ * {@link OAuthApp} on success, its client secret redacted like every
26
+ * read, so callers can confirm which app was removed. The deletion is
27
+ * permanent, and the server refuses it while an MCP server's
28
+ * `oauth_app_ref` still resolves to the app.
28
29
  *
29
30
  * @example
30
31
  * ```tsx
@@ -21,11 +21,13 @@ export interface UseOAuthAppListReturn {
21
21
  }
22
22
 
23
23
  /**
24
- * Data hook that fetches all {@link OAuthApp} entries for an organization.
24
+ * Data hook that fetches the {@link OAuthApp} entries of an organization
25
+ * that the caller may view.
25
26
  *
26
- * Returns every OAuthApp whose `metadata.org` matches the input. In
27
- * practice these are the BYOA OAuth apps created through the "Bring
28
- * your own app" flow on MCP server detail pages.
27
+ * Returns the organization's OAuthApps that a `get` would return to the
28
+ * caller: each app's creator and the organization's admins see it, other
29
+ * members and viewers see none. In practice these are the BYOA OAuth apps
30
+ * created through the "Bring your own app" flow on MCP server detail pages.
29
31
  *
30
32
  * Pass `null` for `org` to skip fetching (stable no-op). Useful when
31
33
  * the active organization has not been resolved yet.
@@ -12,6 +12,7 @@ import { useUpdatePlatformClient } from "./useUpdatePlatformClient.js";
12
12
  import { useRotatePlatformClientSecret } from "./useRotatePlatformClientSecret.js";
13
13
  import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
14
14
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
15
+ import { isPlatformClientExpired } from "./expiry.js";
15
16
 
16
17
  /** Props for {@link PlatformClientDetailPanel}. */
17
18
  export interface PlatformClientDetailPanelProps {
@@ -87,6 +88,8 @@ export function PlatformClientDetailPanel({
87
88
  const [confirmingRotate, setConfirmingRotate] = useState(false);
88
89
 
89
90
  const isBusy = isUpdating || isRotating || isDeleting;
91
+ // Rotating never revives an expired client: only a later expiry does.
92
+ const expired = isPlatformClientExpired(spec);
90
93
 
91
94
  // Edit form state
92
95
  const [neverExpires, setNeverExpires] = useState(
@@ -507,6 +510,12 @@ export function PlatformClientDetailPanel({
507
510
  <p className="stg:text-xs stg:text-foreground">
508
511
  Rotate secret? The current secret will be
509
512
  <span className="stg:font-medium"> permanently invalidated</span>.
513
+ {expired && (
514
+ <span className="stg:mt-1 stg:block stg:text-destructive">
515
+ This client has expired. Rotating does not extend it; edit its
516
+ expiry instead.
517
+ </span>
518
+ )}
510
519
  </p>
511
520
  <div className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
512
521
  <button
@@ -654,10 +663,18 @@ function ViewMode({
654
663
  {spec?.neverExpires ? (
655
664
  <Field label="Expiry" value="Never expires" />
656
665
  ) : spec?.expiresAt ? (
657
- <Field
658
- label="Expires"
659
- value={formatDate(timestampDate(spec.expiresAt))}
660
- />
666
+ isPlatformClientExpired(spec) ? (
667
+ <Field
668
+ label="Expired"
669
+ value={formatDate(timestampDate(spec.expiresAt))}
670
+ tone="destructive"
671
+ />
672
+ ) : (
673
+ <Field
674
+ label="Expires"
675
+ value={formatDate(timestampDate(spec.expiresAt))}
676
+ />
677
+ )
661
678
  ) : null}
662
679
 
663
680
  {/* JIT provisioning */}
@@ -726,10 +743,13 @@ function Field({
726
743
  label,
727
744
  value,
728
745
  mono,
746
+ tone,
729
747
  }: {
730
748
  label: string;
731
749
  value?: string;
732
750
  mono?: boolean;
751
+ /** `destructive` for a value that needs action (an expired client). */
752
+ tone?: "destructive";
733
753
  }) {
734
754
  if (!value) return null;
735
755
  return (
@@ -739,7 +759,10 @@ function Field({
739
759
  </dt>
740
760
  <dd
741
761
  className={cn(
742
- "stg:text-foreground stg:mt-0.5 stg:break-all stg:text-xs",
762
+ "stg:mt-0.5 stg:break-all stg:text-xs",
763
+ tone === "destructive"
764
+ ? "stg:text-destructive stg:font-medium"
765
+ : "stg:text-foreground",
743
766
  mono && "stg:font-mono",
744
767
  )}
745
768
  >
@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js"
9
9
  import { usePlatformClientList } from "./usePlatformClientList.js";
10
10
  import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
11
11
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
12
+ import { isPlatformClientExpired } from "./expiry.js";
12
13
 
13
14
  /** Props for {@link PlatformClientListPanel}. */
14
15
  export interface PlatformClientListPanelProps {
@@ -314,7 +315,7 @@ function ExpiryBadge({ spec }: { spec: PlatformClient["spec"] }) {
314
315
  }
315
316
  if (spec?.expiresAt) {
316
317
  const date = timestampDate(spec.expiresAt);
317
- const isExpired = date < new Date();
318
+ const isExpired = isPlatformClientExpired(spec);
318
319
  return (
319
320
  <Tooltip>
320
321
  <TooltipTrigger
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
- import { create } from "@bufbuild/protobuf";
3
+ import { clone, create } from "@bufbuild/protobuf";
4
4
  import { timestampFromDate } from "@bufbuild/protobuf/wkt";
5
5
  import {
6
6
  PlatformClientSchema,
@@ -18,6 +18,11 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
18
18
  * NEVER sent `environment_refs` — saving any edit silently wiped the
19
19
  * client's credential-delivery environment bindings. The panel must
20
20
  * spread `toPlatformClientUpdateInput` and override only what it edits.
21
+ *
22
+ * Also pins how the panel presents an expired client (#1254): the view
23
+ * says "Expired", and the rotate confirmation says rotating will not help,
24
+ * because rotation leaves the expiry as it is. A live client shows
25
+ * neither.
21
26
  */
22
27
 
23
28
  const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
@@ -41,17 +46,32 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
41
46
  },
42
47
  });
43
48
 
44
- function renderPanel(update: ReturnType<typeof vi.fn>) {
49
+ function renderPanel(
50
+ update: ReturnType<typeof vi.fn>,
51
+ platformClient: PlatformClient = PLATFORM_CLIENT,
52
+ ) {
45
53
  const client = {
46
54
  platformclient: { update },
47
55
  } as never;
48
56
  return render(
49
57
  <StigmerContext.Provider value={client}>
50
- <PlatformClientDetailPanel platformClient={PLATFORM_CLIENT} />
58
+ <PlatformClientDetailPanel platformClient={platformClient} />
51
59
  </StigmerContext.Provider>,
52
60
  );
53
61
  }
54
62
 
63
+ /** PLATFORM_CLIENT with an expiry a day from now (+1) or a day ago (-1). */
64
+ function clientExpiringInDays(days: 1 | -1): PlatformClient {
65
+ const platformClient = clone(PlatformClientSchema, PLATFORM_CLIENT);
66
+ platformClient.spec!.expiresAt = timestampFromDate(
67
+ new Date(Date.now() + days * 86_400_000),
68
+ );
69
+ return platformClient;
70
+ }
71
+
72
+ const ROTATE_WILL_NOT_HELP =
73
+ "This client has expired. Rotating does not extend it; edit its expiry instead.";
74
+
55
75
  afterEach(cleanup);
56
76
 
57
77
  describe("PlatformClientDetailPanel save payload", () => {
@@ -80,3 +100,25 @@ describe("PlatformClientDetailPanel save payload", () => {
80
100
  expect(input.name).toBe("Embed Client");
81
101
  });
82
102
  });
103
+
104
+ describe("PlatformClientDetailPanel expiry", () => {
105
+ it("an expired client reads Expired, and its rotate confirmation says rotating will not help", () => {
106
+ renderPanel(vi.fn(), clientExpiringInDays(-1));
107
+
108
+ expect(screen.getByText("Expired")).toBeTruthy();
109
+ expect(screen.queryByText("Expires")).toBeNull();
110
+
111
+ fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
112
+ expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
113
+ });
114
+
115
+ it("a live client reads Expires, and its rotate confirmation carries no expiry warning", () => {
116
+ renderPanel(vi.fn(), clientExpiringInDays(1));
117
+
118
+ expect(screen.getByText("Expires")).toBeTruthy();
119
+ expect(screen.queryByText("Expired")).toBeNull();
120
+
121
+ fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
122
+ expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pins the one expiry rule the platform-client panels share (#1254): it is
3
+ * the server's rule, so a client reads expired exactly when the mint would
4
+ * refuse it — an expiry set, never_expires off, and the expiry passed.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { create } from "@bufbuild/protobuf";
8
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
9
+ import { PlatformClientSpecSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/spec_pb";
10
+
11
+ import { isPlatformClientExpired } from "../expiry";
12
+
13
+ const NOW = new Date("2026-09-28T12:00:00Z");
14
+ const before = timestampFromDate(new Date(NOW.getTime() - 1000));
15
+ const after = timestampFromDate(new Date(NOW.getTime() + 1000));
16
+
17
+ describe("isPlatformClientExpired", () => {
18
+ it("is true only for a passed expiry with never_expires off", () => {
19
+ expect(
20
+ isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: before }), NOW),
21
+ ).toBe(true);
22
+ expect(
23
+ isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: after }), NOW),
24
+ ).toBe(false);
25
+ });
26
+
27
+ it("never_expires, an unset expiry and a missing spec never read expired", () => {
28
+ expect(
29
+ isPlatformClientExpired(
30
+ create(PlatformClientSpecSchema, { expiresAt: before, neverExpires: true }),
31
+ NOW,
32
+ ),
33
+ ).toBe(false);
34
+ expect(isPlatformClientExpired(create(PlatformClientSpecSchema, {}), NOW)).toBe(false);
35
+ expect(isPlatformClientExpired(undefined, NOW)).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one rule for "this PlatformClient has expired", shared by the list's
3
+ * expiry badge, the detail view and the rotate confirmation so the three
4
+ * never disagree. It is the server's rule, the one the mint refuses by: an
5
+ * expiry is set, `never_expires` is off, and the expiry has passed.
6
+ *
7
+ * Package-internal on purpose: every name the barrel exports is a public
8
+ * API contract, and no platform builder has asked for this one.
9
+ */
10
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
11
+ import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
12
+
13
+ /** Whether the client's secret no longer mints (its expiry has passed). */
14
+ export function isPlatformClientExpired(
15
+ spec: PlatformClient["spec"],
16
+ now: Date = new Date(),
17
+ ): boolean {
18
+ if (spec === undefined || spec.neverExpires || spec.expiresAt === undefined) {
19
+ return false;
20
+ }
21
+ return timestampDate(spec.expiresAt).getTime() < now.getTime();
22
+ }
@@ -45,7 +45,8 @@ export function MembersSection() {
45
45
  </h2>
46
46
  <p className="stg:text-muted-foreground stg:mb-4 stg:text-xs">
47
47
  Manage who has access to this organization and what they can do.
48
- Members can be granted owner, admin, member, or viewer roles.
48
+ Members can be granted owner, admin, member, or viewer roles; only
49
+ an owner grants or removes owner.
49
50
  {!invitationsAvailable && (
50
51
  <>
51
52
  {" "}