@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.
- package/iam-policy/OrgMembersPanel.d.ts +7 -0
- package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
- package/iam-policy/OrgMembersPanel.js +28 -12
- package/iam-policy/OrgMembersPanel.js.map +1 -1
- package/iam-policy/RoleSelector.d.ts +7 -1
- package/iam-policy/RoleSelector.d.ts.map +1 -1
- package/iam-policy/RoleSelector.js +2 -2
- package/iam-policy/RoleSelector.js.map +1 -1
- package/iam-policy/useOwnerAssignment.d.ts +23 -0
- package/iam-policy/useOwnerAssignment.d.ts.map +1 -0
- package/iam-policy/useOwnerAssignment.js +23 -0
- package/iam-policy/useOwnerAssignment.js.map +1 -0
- package/iam-policy/useRoleSelector.d.ts +6 -1
- package/iam-policy/useRoleSelector.d.ts.map +1 -1
- package/iam-policy/useRoleSelector.js +11 -3
- package/iam-policy/useRoleSelector.js.map +1 -1
- package/invitation/InvitationManager.d.ts.map +1 -1
- package/invitation/InvitationManager.js +5 -1
- package/invitation/InvitationManager.js.map +1 -1
- package/oauth-app/OAuthAppDetailPanel.d.ts +5 -1
- package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
- package/oauth-app/OAuthAppDetailPanel.js +10 -5
- package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
- package/oauth-app/OAuthAppListPanel.d.ts +11 -3
- package/oauth-app/OAuthAppListPanel.d.ts.map +1 -1
- package/oauth-app/OAuthAppListPanel.js +4 -4
- package/oauth-app/OAuthAppListPanel.js.map +1 -1
- package/oauth-app/copy.d.ts +15 -0
- package/oauth-app/copy.d.ts.map +1 -0
- package/oauth-app/copy.js +15 -0
- package/oauth-app/copy.js.map +1 -0
- package/oauth-app/useDeleteOAuthApp.d.ts +4 -3
- package/oauth-app/useDeleteOAuthApp.d.ts.map +1 -1
- package/oauth-app/useDeleteOAuthApp.js +4 -3
- package/oauth-app/useDeleteOAuthApp.js.map +1 -1
- package/oauth-app/useOAuthAppList.d.ts +6 -4
- package/oauth-app/useOAuthAppList.d.ts.map +1 -1
- package/oauth-app/useOAuthAppList.js +6 -4
- package/oauth-app/useOAuthAppList.js.map +1 -1
- package/package.json +5 -5
- package/platform-client/PlatformClientDetailPanel.d.ts.map +1 -1
- package/platform-client/PlatformClientDetailPanel.js +9 -4
- package/platform-client/PlatformClientDetailPanel.js.map +1 -1
- package/platform-client/PlatformClientListPanel.d.ts.map +1 -1
- package/platform-client/PlatformClientListPanel.js +2 -1
- package/platform-client/PlatformClientListPanel.js.map +1 -1
- package/platform-client/expiry.d.ts +4 -0
- package/platform-client/expiry.d.ts.map +1 -0
- package/platform-client/expiry.js +18 -0
- package/platform-client/expiry.js.map +1 -0
- package/settings/MembersSection.d.ts.map +1 -1
- package/settings/MembersSection.js +1 -1
- package/settings/MembersSection.js.map +1 -1
- package/settings/OAuthAppsSection.d.ts +9 -1
- package/settings/OAuthAppsSection.d.ts.map +1 -1
- package/settings/OAuthAppsSection.js +17 -3
- package/settings/OAuthAppsSection.js.map +1 -1
- package/src/iam-policy/OrgMembersPanel.tsx +38 -8
- package/src/iam-policy/RoleSelector.tsx +8 -0
- package/src/iam-policy/__tests__/OrgMembersPanel.test.tsx +139 -0
- package/src/iam-policy/__tests__/useOwnerAssignment.test.tsx +55 -0
- package/src/iam-policy/useOwnerAssignment.ts +40 -0
- package/src/iam-policy/useRoleSelector.ts +14 -3
- package/src/invitation/InvitationManager.tsx +5 -0
- package/src/oauth-app/OAuthAppDetailPanel.tsx +39 -24
- package/src/oauth-app/OAuthAppListPanel.tsx +12 -3
- package/src/oauth-app/__tests__/OAuthAppDetailPanel.test.tsx +57 -9
- package/src/oauth-app/copy.ts +16 -0
- package/src/oauth-app/useDeleteOAuthApp.ts +4 -3
- package/src/oauth-app/useOAuthAppList.ts +6 -4
- package/src/platform-client/PlatformClientDetailPanel.tsx +28 -5
- package/src/platform-client/PlatformClientListPanel.tsx +2 -1
- package/src/platform-client/__tests__/PlatformClientDetailPanel.test.tsx +45 -3
- package/src/platform-client/__tests__/expiry.test.ts +37 -0
- package/src/platform-client/expiry.ts +22 -0
- package/src/settings/MembersSection.tsx +2 -1
- package/src/settings/OAuthAppsSection.tsx +21 -3
- 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
|
|
79
|
-
|
|
87
|
+
const left = new Set(omit);
|
|
88
|
+
const roles = (
|
|
89
|
+
granteeKind === "team" ? getTeamGrantableRoles(kind) : getGrantableRoles(kind)
|
|
90
|
+
).filter((role) => !left.has(role));
|
|
80
91
|
return roles.map((role) => ({
|
|
81
92
|
role,
|
|
82
93
|
label: iamRoleDisplayName(role),
|
|
83
94
|
description: iamRoleDescription(role),
|
|
84
95
|
value: iamRoleToString(role),
|
|
85
96
|
}));
|
|
86
|
-
}, [kind, granteeKind]);
|
|
97
|
+
}, [kind, granteeKind, omit]);
|
|
87
98
|
|
|
88
99
|
const selectedValue = useMemo(
|
|
89
100
|
() => (selected !== null ? iamRoleToString(selected) : ""),
|
|
@@ -18,6 +18,7 @@ import { useRevokeInvitation } from "./useRevokeInvitation.js";
|
|
|
18
18
|
import { InvitationCreatedAlert } from "./InvitationCreatedAlert.js";
|
|
19
19
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js";
|
|
20
20
|
import { RoleSelector } from "../iam-policy/RoleSelector.js";
|
|
21
|
+
import { useOwnerAssignment } from "../iam-policy/useOwnerAssignment.js";
|
|
21
22
|
import { SpinnerIcon } from "../internal/SpinnerIcon.js";
|
|
22
23
|
import { useCopyFeedback } from "../internal/useCopyFeedback.js";
|
|
23
24
|
|
|
@@ -237,6 +238,9 @@ function CreateInvitationForm({
|
|
|
237
238
|
onCancel: () => void;
|
|
238
239
|
}) {
|
|
239
240
|
const { create, isCreating, error, clearError } = useCreateInvitation();
|
|
241
|
+
// An invitation grants with its creator's authority: only an owner may
|
|
242
|
+
// invite an owner, and the server refuses anyone else.
|
|
243
|
+
const { unassignable } = useOwnerAssignment(org);
|
|
240
244
|
const baseId = useId();
|
|
241
245
|
|
|
242
246
|
const [label, setLabel] = useState("");
|
|
@@ -303,6 +307,7 @@ function CreateInvitationForm({
|
|
|
303
307
|
kind={ApiResourceKind.organization}
|
|
304
308
|
selected={role}
|
|
305
309
|
onSelect={(r) => setRole(r)}
|
|
310
|
+
omitRoles={unassignable}
|
|
306
311
|
disabled={isCreating}
|
|
307
312
|
/>
|
|
308
313
|
|
|
@@ -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"
|
|
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
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
19
|
-
*
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
24
|
+
* Data hook that fetches the {@link OAuthApp} entries of an organization
|
|
25
|
+
* that the caller may view.
|
|
25
26
|
*
|
|
26
|
-
* Returns
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
666
|
+
isPlatformClientExpired(spec) ? (
|
|
667
|
+
<Field
|
|
668
|
+
label="Expired"
|
|
669
|
+
value={formatDate(timestampDate(spec.expiresAt))}
|
|
670
|
+
tone="destructive"
|
|
671
|
+
/>
|
|
672
|
+
) : (
|
|
673
|
+
<Field
|
|
674
|
+
label="Expires"
|
|
675
|
+
value={formatDate(timestampDate(spec.expiresAt))}
|
|
676
|
+
/>
|
|
677
|
+
)
|
|
661
678
|
) : null}
|
|
662
679
|
|
|
663
680
|
{/* JIT provisioning */}
|
|
@@ -726,10 +743,13 @@ function Field({
|
|
|
726
743
|
label,
|
|
727
744
|
value,
|
|
728
745
|
mono,
|
|
746
|
+
tone,
|
|
729
747
|
}: {
|
|
730
748
|
label: string;
|
|
731
749
|
value?: string;
|
|
732
750
|
mono?: boolean;
|
|
751
|
+
/** `destructive` for a value that needs action (an expired client). */
|
|
752
|
+
tone?: "destructive";
|
|
733
753
|
}) {
|
|
734
754
|
if (!value) return null;
|
|
735
755
|
return (
|
|
@@ -739,7 +759,10 @@ function Field({
|
|
|
739
759
|
</dt>
|
|
740
760
|
<dd
|
|
741
761
|
className={cn(
|
|
742
|
-
"stg:
|
|
762
|
+
"stg:mt-0.5 stg:break-all stg:text-xs",
|
|
763
|
+
tone === "destructive"
|
|
764
|
+
? "stg:text-destructive stg:font-medium"
|
|
765
|
+
: "stg:text-foreground",
|
|
743
766
|
mono && "stg:font-mono",
|
|
744
767
|
)}
|
|
745
768
|
>
|
|
@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../internal/tooltip.js"
|
|
|
9
9
|
import { usePlatformClientList } from "./usePlatformClientList.js";
|
|
10
10
|
import { useDeletePlatformClient } from "./useDeletePlatformClient.js";
|
|
11
11
|
import { SpinnerIcon } from "../internal/SpinnerIcon.js";
|
|
12
|
+
import { isPlatformClientExpired } from "./expiry.js";
|
|
12
13
|
|
|
13
14
|
/** Props for {@link PlatformClientListPanel}. */
|
|
14
15
|
export interface PlatformClientListPanelProps {
|
|
@@ -314,7 +315,7 @@ function ExpiryBadge({ spec }: { spec: PlatformClient["spec"] }) {
|
|
|
314
315
|
}
|
|
315
316
|
if (spec?.expiresAt) {
|
|
316
317
|
const date = timestampDate(spec.expiresAt);
|
|
317
|
-
const isExpired =
|
|
318
|
+
const isExpired = isPlatformClientExpired(spec);
|
|
318
319
|
return (
|
|
319
320
|
<Tooltip>
|
|
320
321
|
<TooltipTrigger
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
-
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { clone, create } from "@bufbuild/protobuf";
|
|
4
4
|
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
5
5
|
import {
|
|
6
6
|
PlatformClientSchema,
|
|
@@ -18,6 +18,11 @@ import { PlatformClientDetailPanel } from "../PlatformClientDetailPanel";
|
|
|
18
18
|
* NEVER sent `environment_refs` — saving any edit silently wiped the
|
|
19
19
|
* client's credential-delivery environment bindings. The panel must
|
|
20
20
|
* spread `toPlatformClientUpdateInput` and override only what it edits.
|
|
21
|
+
*
|
|
22
|
+
* Also pins how the panel presents an expired client (#1254): the view
|
|
23
|
+
* says "Expired", and the rotate confirmation says rotating will not help,
|
|
24
|
+
* because rotation leaves the expiry as it is. A live client shows
|
|
25
|
+
* neither.
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
|
|
@@ -41,17 +46,32 @@ const PLATFORM_CLIENT: PlatformClient = create(PlatformClientSchema, {
|
|
|
41
46
|
},
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
function renderPanel(
|
|
49
|
+
function renderPanel(
|
|
50
|
+
update: ReturnType<typeof vi.fn>,
|
|
51
|
+
platformClient: PlatformClient = PLATFORM_CLIENT,
|
|
52
|
+
) {
|
|
45
53
|
const client = {
|
|
46
54
|
platformclient: { update },
|
|
47
55
|
} as never;
|
|
48
56
|
return render(
|
|
49
57
|
<StigmerContext.Provider value={client}>
|
|
50
|
-
<PlatformClientDetailPanel platformClient={
|
|
58
|
+
<PlatformClientDetailPanel platformClient={platformClient} />
|
|
51
59
|
</StigmerContext.Provider>,
|
|
52
60
|
);
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/** PLATFORM_CLIENT with an expiry a day from now (+1) or a day ago (-1). */
|
|
64
|
+
function clientExpiringInDays(days: 1 | -1): PlatformClient {
|
|
65
|
+
const platformClient = clone(PlatformClientSchema, PLATFORM_CLIENT);
|
|
66
|
+
platformClient.spec!.expiresAt = timestampFromDate(
|
|
67
|
+
new Date(Date.now() + days * 86_400_000),
|
|
68
|
+
);
|
|
69
|
+
return platformClient;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ROTATE_WILL_NOT_HELP =
|
|
73
|
+
"This client has expired. Rotating does not extend it; edit its expiry instead.";
|
|
74
|
+
|
|
55
75
|
afterEach(cleanup);
|
|
56
76
|
|
|
57
77
|
describe("PlatformClientDetailPanel save payload", () => {
|
|
@@ -80,3 +100,25 @@ describe("PlatformClientDetailPanel save payload", () => {
|
|
|
80
100
|
expect(input.name).toBe("Embed Client");
|
|
81
101
|
});
|
|
82
102
|
});
|
|
103
|
+
|
|
104
|
+
describe("PlatformClientDetailPanel expiry", () => {
|
|
105
|
+
it("an expired client reads Expired, and its rotate confirmation says rotating will not help", () => {
|
|
106
|
+
renderPanel(vi.fn(), clientExpiringInDays(-1));
|
|
107
|
+
|
|
108
|
+
expect(screen.getByText("Expired")).toBeTruthy();
|
|
109
|
+
expect(screen.queryByText("Expires")).toBeNull();
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
|
|
112
|
+
expect(screen.getByText(ROTATE_WILL_NOT_HELP)).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a live client reads Expires, and its rotate confirmation carries no expiry warning", () => {
|
|
116
|
+
renderPanel(vi.fn(), clientExpiringInDays(1));
|
|
117
|
+
|
|
118
|
+
expect(screen.getByText("Expires")).toBeTruthy();
|
|
119
|
+
expect(screen.queryByText("Expired")).toBeNull();
|
|
120
|
+
|
|
121
|
+
fireEvent.click(screen.getByRole("button", { name: "Rotate secret" }));
|
|
122
|
+
expect(screen.queryByText(ROTATE_WILL_NOT_HELP)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the one expiry rule the platform-client panels share (#1254): it is
|
|
3
|
+
* the server's rule, so a client reads expired exactly when the mint would
|
|
4
|
+
* refuse it — an expiry set, never_expires off, and the expiry passed.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { create } from "@bufbuild/protobuf";
|
|
8
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
9
|
+
import { PlatformClientSpecSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/spec_pb";
|
|
10
|
+
|
|
11
|
+
import { isPlatformClientExpired } from "../expiry";
|
|
12
|
+
|
|
13
|
+
const NOW = new Date("2026-09-28T12:00:00Z");
|
|
14
|
+
const before = timestampFromDate(new Date(NOW.getTime() - 1000));
|
|
15
|
+
const after = timestampFromDate(new Date(NOW.getTime() + 1000));
|
|
16
|
+
|
|
17
|
+
describe("isPlatformClientExpired", () => {
|
|
18
|
+
it("is true only for a passed expiry with never_expires off", () => {
|
|
19
|
+
expect(
|
|
20
|
+
isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: before }), NOW),
|
|
21
|
+
).toBe(true);
|
|
22
|
+
expect(
|
|
23
|
+
isPlatformClientExpired(create(PlatformClientSpecSchema, { expiresAt: after }), NOW),
|
|
24
|
+
).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("never_expires, an unset expiry and a missing spec never read expired", () => {
|
|
28
|
+
expect(
|
|
29
|
+
isPlatformClientExpired(
|
|
30
|
+
create(PlatformClientSpecSchema, { expiresAt: before, neverExpires: true }),
|
|
31
|
+
NOW,
|
|
32
|
+
),
|
|
33
|
+
).toBe(false);
|
|
34
|
+
expect(isPlatformClientExpired(create(PlatformClientSpecSchema, {}), NOW)).toBe(false);
|
|
35
|
+
expect(isPlatformClientExpired(undefined, NOW)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule for "this PlatformClient has expired", shared by the list's
|
|
3
|
+
* expiry badge, the detail view and the rotate confirmation so the three
|
|
4
|
+
* never disagree. It is the server's rule, the one the mint refuses by: an
|
|
5
|
+
* expiry is set, `never_expires` is off, and the expiry has passed.
|
|
6
|
+
*
|
|
7
|
+
* Package-internal on purpose: every name the barrel exports is a public
|
|
8
|
+
* API contract, and no platform builder has asked for this one.
|
|
9
|
+
*/
|
|
10
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
11
|
+
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
12
|
+
|
|
13
|
+
/** Whether the client's secret no longer mints (its expiry has passed). */
|
|
14
|
+
export function isPlatformClientExpired(
|
|
15
|
+
spec: PlatformClient["spec"],
|
|
16
|
+
now: Date = new Date(),
|
|
17
|
+
): boolean {
|
|
18
|
+
if (spec === undefined || spec.neverExpires || spec.expiresAt === undefined) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return timestampDate(spec.expiresAt).getTime() < now.getTime();
|
|
22
|
+
}
|
|
@@ -45,7 +45,8 @@ export function MembersSection() {
|
|
|
45
45
|
</h2>
|
|
46
46
|
<p className="stg:text-muted-foreground stg:mb-4 stg:text-xs">
|
|
47
47
|
Manage who has access to this organization and what they can do.
|
|
48
|
-
Members can be granted owner, admin, member, or viewer roles
|
|
48
|
+
Members can be granted owner, admin, member, or viewer roles; only
|
|
49
|
+
an owner grants or removes owner.
|
|
49
50
|
{!invitationsAvailable && (
|
|
50
51
|
<>
|
|
51
52
|
{" "}
|