@stigmer/react 3.0.7 → 3.0.8-dev.20260612073024
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/access/ManageAccessButton.d.ts +47 -0
- package/access/ManageAccessButton.d.ts.map +1 -0
- package/access/ManageAccessButton.js +45 -0
- package/access/ManageAccessButton.js.map +1 -0
- package/access/ManageAccessDialog.d.ts +60 -0
- package/access/ManageAccessDialog.d.ts.map +1 -0
- package/access/ManageAccessDialog.js +83 -0
- package/access/ManageAccessDialog.js.map +1 -0
- package/access/index.d.ts +5 -0
- package/access/index.d.ts.map +1 -0
- package/access/index.js +7 -0
- package/access/index.js.map +1 -0
- package/access/types.d.ts +56 -0
- package/access/types.d.ts.map +1 -0
- package/access/types.js +2 -0
- package/access/types.js.map +1 -0
- package/access/useManageAccess.d.ts +60 -0
- package/access/useManageAccess.d.ts.map +1 -0
- package/access/useManageAccess.js +41 -0
- package/access/useManageAccess.js.map +1 -0
- package/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +34 -4
- package/agent/AgentDetailView.js.map +1 -1
- package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
- package/agent-instance/AgentInstanceDetailPanel.js +14 -4
- package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
- package/iam-policy/GrantAccessForm.d.ts +15 -5
- package/iam-policy/GrantAccessForm.d.ts.map +1 -1
- package/iam-policy/GrantAccessForm.js +15 -12
- package/iam-policy/GrantAccessForm.js.map +1 -1
- package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
- package/iam-policy/OrgMembersPanel.js +2 -1
- package/iam-policy/OrgMembersPanel.js.map +1 -1
- package/iam-policy/PeopleWithAccess.d.ts +34 -0
- package/iam-policy/PeopleWithAccess.d.ts.map +1 -0
- package/iam-policy/PeopleWithAccess.js +55 -0
- package/iam-policy/PeopleWithAccess.js.map +1 -0
- package/iam-policy/PrincipalPicker.d.ts +48 -0
- package/iam-policy/PrincipalPicker.d.ts.map +1 -0
- package/iam-policy/PrincipalPicker.js +139 -0
- package/iam-policy/PrincipalPicker.js.map +1 -0
- package/iam-policy/ProviderBadge.d.ts +28 -0
- package/iam-policy/ProviderBadge.d.ts.map +1 -0
- package/iam-policy/ProviderBadge.js +31 -0
- package/iam-policy/ProviderBadge.js.map +1 -0
- package/iam-policy/SharePanel.d.ts +13 -5
- package/iam-policy/SharePanel.d.ts.map +1 -1
- package/iam-policy/SharePanel.js +9 -34
- package/iam-policy/SharePanel.js.map +1 -1
- package/iam-policy/index.d.ts +3 -0
- package/iam-policy/index.d.ts.map +1 -1
- package/iam-policy/index.js +3 -0
- package/iam-policy/index.js.map +1 -1
- package/index.d.ts +8 -6
- package/index.d.ts.map +1 -1
- package/index.js +6 -3
- package/index.js.map +1 -1
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +41 -12
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/organization/OrgProvider.d.ts +9 -0
- package/organization/OrgProvider.d.ts.map +1 -1
- package/organization/OrgProvider.js +12 -0
- package/organization/OrgProvider.js.map +1 -1
- package/organization/index.d.ts +1 -1
- package/organization/index.d.ts.map +1 -1
- package/organization/index.js +1 -1
- package/organization/index.js.map +1 -1
- package/package.json +4 -4
- package/skill/SkillDetailView.d.ts.map +1 -1
- package/skill/SkillDetailView.js +31 -3
- package/skill/SkillDetailView.js.map +1 -1
- package/src/access/ManageAccessButton.tsx +115 -0
- package/src/access/ManageAccessDialog.tsx +239 -0
- package/src/access/__tests__/ManageAccessButton.test.tsx +62 -0
- package/src/access/__tests__/ManageAccessDialog.test.tsx +146 -0
- package/src/access/index.ts +21 -0
- package/src/access/types.ts +58 -0
- package/src/access/useManageAccess.tsx +101 -0
- package/src/agent/AgentDetailView.tsx +50 -21
- package/src/agent-instance/AgentInstanceDetailPanel.tsx +24 -42
- package/src/iam-policy/GrantAccessForm.tsx +30 -35
- package/src/iam-policy/OrgMembersPanel.tsx +2 -0
- package/src/iam-policy/PeopleWithAccess.tsx +220 -0
- package/src/iam-policy/PrincipalPicker.tsx +347 -0
- package/src/iam-policy/ProviderBadge.tsx +53 -0
- package/src/iam-policy/SharePanel.tsx +20 -165
- package/src/iam-policy/index.ts +17 -0
- package/src/index.ts +31 -0
- package/src/mcp-server/McpServerDetailView.tsx +37 -9
- package/src/organization/OrgProvider.tsx +13 -0
- package/src/organization/index.ts +1 -1
- package/src/session/__tests__/execution-target.test.ts +18 -0
- package/src/skill/SkillDetailView.tsx +34 -9
- package/src/workflow/WorkflowDetailView.tsx +49 -22
- package/src/workflow/WorkflowExecutionHeader.tsx +12 -1
- package/src/workflow/WorkflowExecutionViewer.tsx +8 -1
- package/src/workflow/index.ts +4 -0
- package/src/workflow/instance/RunVisibilityControl.tsx +116 -0
- package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +55 -42
- package/src/workflow/instance/index.ts +5 -0
- package/src/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.ts +74 -0
- package/styles.css +1 -1
- package/workflow/WorkflowDetailView.d.ts.map +1 -1
- package/workflow/WorkflowDetailView.js +31 -3
- package/workflow/WorkflowDetailView.js.map +1 -1
- package/workflow/WorkflowExecutionHeader.d.ts +7 -0
- package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
- package/workflow/WorkflowExecutionHeader.js +2 -2
- package/workflow/WorkflowExecutionHeader.js.map +1 -1
- package/workflow/WorkflowExecutionViewer.d.ts +6 -1
- package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
- package/workflow/WorkflowExecutionViewer.js +2 -2
- package/workflow/WorkflowExecutionViewer.js.map +1 -1
- package/workflow/index.d.ts +1 -1
- package/workflow/index.d.ts.map +1 -1
- package/workflow/index.js +1 -1
- package/workflow/index.js.map +1 -1
- package/workflow/instance/RunVisibilityControl.d.ts +25 -0
- package/workflow/instance/RunVisibilityControl.d.ts.map +1 -0
- package/workflow/instance/RunVisibilityControl.js +56 -0
- package/workflow/instance/RunVisibilityControl.js.map +1 -0
- package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
- package/workflow/instance/WorkflowInstanceDetailPanel.js +30 -4
- package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
- package/workflow/instance/index.d.ts +2 -0
- package/workflow/instance/index.d.ts.map +1 -1
- package/workflow/instance/index.js +2 -0
- package/workflow/instance/index.js.map +1 -1
- package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.d.ts +30 -0
- package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.d.ts.map +1 -0
- package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.js +39 -0
- package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.js.map +1 -0
|
@@ -22,6 +22,7 @@ import { useRevokeOrgAccess } from "./useRevokeOrgAccess";
|
|
|
22
22
|
import { useCreateIamPolicy } from "./useCreateIamPolicy";
|
|
23
23
|
import { useDeleteIamPolicy } from "./useDeleteIamPolicy";
|
|
24
24
|
import { RoleSelector } from "./RoleSelector";
|
|
25
|
+
import { ProviderBadge } from "./ProviderBadge";
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Public API
|
|
@@ -233,6 +234,7 @@ function MemberRow({
|
|
|
233
234
|
You
|
|
234
235
|
</span>
|
|
235
236
|
)}
|
|
237
|
+
<ProviderBadge principal={principal} />
|
|
236
238
|
</div>
|
|
237
239
|
{email && email !== name && (
|
|
238
240
|
<span className="block truncate text-xs text-muted-foreground">
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
5
|
+
import type { PrincipalAccess } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
6
|
+
import { cn } from "@stigmer/theme";
|
|
7
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
8
|
+
import { useShareFlow, type ShareFlowResource } from "./useShareFlow";
|
|
9
|
+
import { GrantAccessForm } from "./GrantAccessForm";
|
|
10
|
+
import { PermissionGate } from "./PermissionGate";
|
|
11
|
+
|
|
12
|
+
/** Props for {@link PeopleWithAccess}. */
|
|
13
|
+
export interface PeopleWithAccessProps {
|
|
14
|
+
/** The resource whose access list is shown. */
|
|
15
|
+
readonly resource: ShareFlowResource;
|
|
16
|
+
/** Resource kind string for the API ref (e.g. "agent", "session"). */
|
|
17
|
+
readonly resourceKindString: string;
|
|
18
|
+
/** ApiResourceKind enum value for grantable-role lookup. */
|
|
19
|
+
readonly resourceKind: ApiResourceKind;
|
|
20
|
+
/**
|
|
21
|
+
* Organization the resource belongs to (`metadata.org`). Drives the
|
|
22
|
+
* org-member typeahead in the grant form.
|
|
23
|
+
*/
|
|
24
|
+
readonly orgId: string;
|
|
25
|
+
/** Additional CSS class names for the root container. */
|
|
26
|
+
readonly className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The "people with access" body shared by {@link SharePanel} and the unified
|
|
31
|
+
* Manage access dialog: the list of principals with their roles, plus the
|
|
32
|
+
* inline grant form.
|
|
33
|
+
*
|
|
34
|
+
* Reading the access list requires `can_view_access`; this component assumes
|
|
35
|
+
* the caller has already gated rendering on it (e.g. via `PermissionGate` or
|
|
36
|
+
* a parent trigger). Mutations are gated here, in proportion to the action:
|
|
37
|
+
* the grant form and per-row revoke buttons render only behind
|
|
38
|
+
* `can_grant_access`, so a viewer sees *who* has access without being offered
|
|
39
|
+
* controls the server would reject. The backend remains the enforcer.
|
|
40
|
+
*
|
|
41
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
42
|
+
*/
|
|
43
|
+
export function PeopleWithAccess({
|
|
44
|
+
resource,
|
|
45
|
+
resourceKindString,
|
|
46
|
+
resourceKind,
|
|
47
|
+
orgId,
|
|
48
|
+
className,
|
|
49
|
+
}: PeopleWithAccessProps) {
|
|
50
|
+
const {
|
|
51
|
+
accessList,
|
|
52
|
+
isLoading,
|
|
53
|
+
fetchError,
|
|
54
|
+
revokeAccess,
|
|
55
|
+
isRevoking,
|
|
56
|
+
revokeError,
|
|
57
|
+
refetch,
|
|
58
|
+
hasGrantableRoles,
|
|
59
|
+
} = useShareFlow(resource);
|
|
60
|
+
|
|
61
|
+
const [showGrantForm, setShowGrantForm] = useState(false);
|
|
62
|
+
|
|
63
|
+
const existingPrincipalIds = accessList
|
|
64
|
+
.map((entry) => entry.principal?.id)
|
|
65
|
+
.filter((id): id is string => Boolean(id));
|
|
66
|
+
|
|
67
|
+
const grantGate = { kind: resourceKindString, id: resource.id };
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={cn("flex flex-col gap-4", className)}>
|
|
71
|
+
{/* Access list */}
|
|
72
|
+
<div className="space-y-1">
|
|
73
|
+
<p className="text-xs text-muted-foreground">
|
|
74
|
+
{isLoading
|
|
75
|
+
? "Loading access list..."
|
|
76
|
+
: `${accessList.length} ${accessList.length === 1 ? "person" : "people"} with access`}
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
{fetchError && (
|
|
80
|
+
<p className="text-destructive text-[0.65rem]" role="alert">
|
|
81
|
+
{getUserMessage(fetchError)}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{!isLoading && accessList.length > 0 && (
|
|
86
|
+
<ul className="space-y-1 mt-2" aria-label="People with access">
|
|
87
|
+
{accessList.map((entry) => (
|
|
88
|
+
<AccessEntry
|
|
89
|
+
key={entry.principal?.id ?? "unknown"}
|
|
90
|
+
entry={entry}
|
|
91
|
+
grantGate={grantGate}
|
|
92
|
+
onRevoke={revokeAccess}
|
|
93
|
+
isRevoking={isRevoking}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{revokeError && (
|
|
100
|
+
<p className="text-destructive text-[0.65rem]" role="alert">
|
|
101
|
+
{getUserMessage(revokeError)}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Grant form — only for users who can grant access. */}
|
|
107
|
+
{hasGrantableRoles && (
|
|
108
|
+
<PermissionGate resource={grantGate} relation="can_grant_access">
|
|
109
|
+
<div className="border-t border-border pt-3">
|
|
110
|
+
{showGrantForm ? (
|
|
111
|
+
<GrantAccessForm
|
|
112
|
+
resourceKind={resourceKind}
|
|
113
|
+
resourceKindString={resourceKindString}
|
|
114
|
+
resourceId={resource.id}
|
|
115
|
+
orgId={orgId}
|
|
116
|
+
excludePrincipalIds={existingPrincipalIds}
|
|
117
|
+
onGranted={() => {
|
|
118
|
+
setShowGrantForm(false);
|
|
119
|
+
refetch();
|
|
120
|
+
}}
|
|
121
|
+
onCancel={() => setShowGrantForm(false)}
|
|
122
|
+
/>
|
|
123
|
+
) : (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => setShowGrantForm(true)}
|
|
127
|
+
className={cn(
|
|
128
|
+
"w-full rounded-md px-3 py-1.5 text-xs font-medium text-center",
|
|
129
|
+
"border border-dashed border-border",
|
|
130
|
+
"text-muted-foreground hover:text-foreground hover:border-foreground/30",
|
|
131
|
+
"hover:bg-accent-hover transition-colors",
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
+ Add people
|
|
135
|
+
</button>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</PermissionGate>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Internal subcomponents
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function AccessEntry({
|
|
149
|
+
entry,
|
|
150
|
+
grantGate,
|
|
151
|
+
onRevoke,
|
|
152
|
+
isRevoking,
|
|
153
|
+
}: {
|
|
154
|
+
readonly entry: PrincipalAccess;
|
|
155
|
+
readonly grantGate: { readonly kind: string; readonly id: string };
|
|
156
|
+
readonly onRevoke: (principalId: string, role: string) => Promise<void>;
|
|
157
|
+
readonly isRevoking: boolean;
|
|
158
|
+
}) {
|
|
159
|
+
const principal = entry.principal;
|
|
160
|
+
const roles = entry.roles;
|
|
161
|
+
|
|
162
|
+
const displayName = principal?.name || principal?.email || principal?.id || "Unknown";
|
|
163
|
+
const primaryRole = roles[0]?.role;
|
|
164
|
+
|
|
165
|
+
const handleRevoke = useCallback(async () => {
|
|
166
|
+
if (!principal?.id || !primaryRole?.code) return;
|
|
167
|
+
await onRevoke(principal.id, primaryRole.code);
|
|
168
|
+
}, [principal?.id, primaryRole?.code, onRevoke]);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<li className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-accent-hover group">
|
|
172
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
173
|
+
<div
|
|
174
|
+
className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-[0.6rem] font-medium text-muted-foreground shrink-0"
|
|
175
|
+
aria-hidden="true"
|
|
176
|
+
>
|
|
177
|
+
{(principal?.name?.[0] ?? principal?.email?.[0] ?? "?").toUpperCase()}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="min-w-0">
|
|
180
|
+
<p className="text-xs text-foreground truncate">{displayName}</p>
|
|
181
|
+
{principal?.email && principal.name && (
|
|
182
|
+
<p className="text-[0.6rem] text-muted-foreground truncate">
|
|
183
|
+
{principal.email}
|
|
184
|
+
</p>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
190
|
+
<span className="text-[0.6rem] text-muted-foreground capitalize">
|
|
191
|
+
{primaryRole?.name ?? primaryRole?.code ?? "—"}
|
|
192
|
+
</span>
|
|
193
|
+
<PermissionGate resource={grantGate} relation="can_grant_access">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={handleRevoke}
|
|
197
|
+
disabled={isRevoking}
|
|
198
|
+
aria-label={`Remove ${displayName}'s access`}
|
|
199
|
+
className={cn(
|
|
200
|
+
"rounded p-0.5 text-muted-foreground opacity-0 group-hover:opacity-100",
|
|
201
|
+
"hover:text-destructive hover:bg-destructive/10",
|
|
202
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
203
|
+
"transition-opacity",
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<RemoveIcon />
|
|
207
|
+
</button>
|
|
208
|
+
</PermissionGate>
|
|
209
|
+
</div>
|
|
210
|
+
</li>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function RemoveIcon() {
|
|
215
|
+
return (
|
|
216
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
|
217
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
218
|
+
</svg>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { ApiResourceRefView } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
5
|
+
import { cn } from "@stigmer/theme";
|
|
6
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
7
|
+
import { useResourceAccess } from "./useResourceAccess";
|
|
8
|
+
import { ProviderBadge, providerLabel } from "./ProviderBadge";
|
|
9
|
+
|
|
10
|
+
/** A principal selected through {@link PrincipalPicker}. */
|
|
11
|
+
export interface SelectedPrincipal {
|
|
12
|
+
/** identity_account ID (`ida_...`). */
|
|
13
|
+
readonly id: string;
|
|
14
|
+
/** Display name (falls back to email, then ID). */
|
|
15
|
+
readonly name: string;
|
|
16
|
+
/** Email address, if known. */
|
|
17
|
+
readonly email: string;
|
|
18
|
+
/** Full view for richer rendering (avatar, provider). */
|
|
19
|
+
readonly view: ApiResourceRefView;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Props for {@link PrincipalPicker}. */
|
|
23
|
+
export interface PrincipalPickerProps {
|
|
24
|
+
/** Organization whose members are selectable. */
|
|
25
|
+
readonly orgId: string;
|
|
26
|
+
/** Currently selected principal, or `null`. Controlled. */
|
|
27
|
+
readonly value: SelectedPrincipal | null;
|
|
28
|
+
/** Fired when the selection changes. */
|
|
29
|
+
readonly onChange: (principal: SelectedPrincipal | null) => void;
|
|
30
|
+
/** Principal IDs to hide/disable because they already have access. */
|
|
31
|
+
readonly excludePrincipalIds?: readonly string[];
|
|
32
|
+
/** Disable the control. */
|
|
33
|
+
readonly disabled?: boolean;
|
|
34
|
+
/** Additional CSS class names for the root container. */
|
|
35
|
+
readonly className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Accessible combobox for picking an organization member to share with.
|
|
40
|
+
*
|
|
41
|
+
* Replaces raw account-ID entry: users type a name or email and choose a
|
|
42
|
+
* person from the org's member list. Because email is not unique across
|
|
43
|
+
* identity sources, each candidate shows a {@link ProviderBadge} so accounts
|
|
44
|
+
* that share an email (e.g. a direct account and a federated one) can be told
|
|
45
|
+
* apart. The resolved `identity_account` ID is carried internally — the user
|
|
46
|
+
* never sees it.
|
|
47
|
+
*
|
|
48
|
+
* Members who already have access are shown disabled, so the same person is
|
|
49
|
+
* not granted twice.
|
|
50
|
+
*
|
|
51
|
+
* Search is over the org member list the caller can already see
|
|
52
|
+
* (`listResourceAccessByPrincipal` on the organization), so it introduces no
|
|
53
|
+
* new account-enumeration surface.
|
|
54
|
+
*
|
|
55
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
56
|
+
*/
|
|
57
|
+
export function PrincipalPicker({
|
|
58
|
+
orgId,
|
|
59
|
+
value,
|
|
60
|
+
onChange,
|
|
61
|
+
excludePrincipalIds,
|
|
62
|
+
disabled = false,
|
|
63
|
+
className,
|
|
64
|
+
}: PrincipalPickerProps) {
|
|
65
|
+
const listboxId = useId();
|
|
66
|
+
const { members, isLoading, error } = useResourceAccess(
|
|
67
|
+
orgId ? { kind: "organization", id: orgId } : null,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const [query, setQuery] = useState("");
|
|
71
|
+
const [open, setOpen] = useState(false);
|
|
72
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
73
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
74
|
+
|
|
75
|
+
const excluded = useMemo(
|
|
76
|
+
() => new Set(excludePrincipalIds ?? []),
|
|
77
|
+
[excludePrincipalIds],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Org members that are identity accounts, deduped by ID. The org list groups
|
|
81
|
+
// by principal, so each ID appears once already; the dedupe guards against
|
|
82
|
+
// inherited-role duplicates.
|
|
83
|
+
const candidates = useMemo(() => {
|
|
84
|
+
const byId = new Map<string, ApiResourceRefView>();
|
|
85
|
+
for (const entry of members) {
|
|
86
|
+
const p = entry.principal;
|
|
87
|
+
if (!p || p.kind !== "identity_account" || !p.id) continue;
|
|
88
|
+
if (!byId.has(p.id)) byId.set(p.id, p);
|
|
89
|
+
}
|
|
90
|
+
return [...byId.values()];
|
|
91
|
+
}, [members]);
|
|
92
|
+
|
|
93
|
+
// Emails that appear on more than one candidate — these are the rows where
|
|
94
|
+
// the provider badge is doing real disambiguation work.
|
|
95
|
+
const duplicatedEmails = useMemo(() => {
|
|
96
|
+
const counts = new Map<string, number>();
|
|
97
|
+
for (const c of candidates) {
|
|
98
|
+
const email = c.email?.toLowerCase();
|
|
99
|
+
if (email) counts.set(email, (counts.get(email) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
return new Set(
|
|
102
|
+
[...counts.entries()].filter(([, n]) => n > 1).map(([email]) => email),
|
|
103
|
+
);
|
|
104
|
+
}, [candidates]);
|
|
105
|
+
|
|
106
|
+
const filtered = useMemo(() => {
|
|
107
|
+
const q = query.trim().toLowerCase();
|
|
108
|
+
if (!q) return candidates;
|
|
109
|
+
return candidates.filter((c) => {
|
|
110
|
+
const name = (c.name ?? "").toLowerCase();
|
|
111
|
+
const email = (c.email ?? "").toLowerCase();
|
|
112
|
+
return name.includes(q) || email.includes(q);
|
|
113
|
+
});
|
|
114
|
+
}, [candidates, query]);
|
|
115
|
+
|
|
116
|
+
const commitSelection = useCallback(
|
|
117
|
+
(view: ApiResourceRefView) => {
|
|
118
|
+
if (excluded.has(view.id)) return;
|
|
119
|
+
onChange({
|
|
120
|
+
id: view.id,
|
|
121
|
+
name: view.name || view.email || view.id,
|
|
122
|
+
email: view.email,
|
|
123
|
+
view,
|
|
124
|
+
});
|
|
125
|
+
setQuery("");
|
|
126
|
+
setOpen(false);
|
|
127
|
+
},
|
|
128
|
+
[excluded, onChange],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleKeyDown = useCallback(
|
|
132
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
133
|
+
if (e.key === "ArrowDown") {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
setOpen(true);
|
|
136
|
+
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
|
|
137
|
+
} else if (e.key === "ArrowUp") {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
140
|
+
} else if (e.key === "Enter") {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const choice = filtered[activeIndex];
|
|
143
|
+
if (choice && !excluded.has(choice.id)) commitSelection(choice);
|
|
144
|
+
} else if (e.key === "Escape") {
|
|
145
|
+
setOpen(false);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[filtered, activeIndex, excluded, commitSelection],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Selected state: show a chip with a clear affordance instead of the input.
|
|
152
|
+
if (value) {
|
|
153
|
+
return (
|
|
154
|
+
<div className={cn("space-y-1", className)}>
|
|
155
|
+
<span className="block text-xs font-medium text-foreground">Person</span>
|
|
156
|
+
<div className="flex items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 py-1.5">
|
|
157
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
158
|
+
<div
|
|
159
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[0.6rem] font-medium text-muted-foreground"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
>
|
|
162
|
+
{(value.name[0] ?? "?").toUpperCase()}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="min-w-0">
|
|
165
|
+
<div className="flex items-center gap-1.5">
|
|
166
|
+
<span className="truncate text-xs text-foreground">{value.name}</span>
|
|
167
|
+
<ProviderBadge principal={value.view} />
|
|
168
|
+
</div>
|
|
169
|
+
{value.email && value.email !== value.name && (
|
|
170
|
+
<span className="block truncate text-[0.6rem] text-muted-foreground">
|
|
171
|
+
{value.email}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => onChange(null)}
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
aria-label="Clear selected person"
|
|
181
|
+
className={cn(
|
|
182
|
+
"shrink-0 rounded p-0.5 text-muted-foreground",
|
|
183
|
+
"hover:text-foreground hover:bg-accent-hover",
|
|
184
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<ClearIcon />
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className={cn("space-y-1", className)}>
|
|
196
|
+
<label
|
|
197
|
+
htmlFor={`${listboxId}-input`}
|
|
198
|
+
className="block text-xs font-medium text-foreground"
|
|
199
|
+
>
|
|
200
|
+
Person
|
|
201
|
+
</label>
|
|
202
|
+
<div className="relative">
|
|
203
|
+
<input
|
|
204
|
+
id={`${listboxId}-input`}
|
|
205
|
+
ref={inputRef}
|
|
206
|
+
type="text"
|
|
207
|
+
role="combobox"
|
|
208
|
+
aria-expanded={open}
|
|
209
|
+
aria-controls={listboxId}
|
|
210
|
+
aria-autocomplete="list"
|
|
211
|
+
autoComplete="off"
|
|
212
|
+
value={query}
|
|
213
|
+
onChange={(e) => {
|
|
214
|
+
setQuery(e.target.value);
|
|
215
|
+
setOpen(true);
|
|
216
|
+
setActiveIndex(0);
|
|
217
|
+
}}
|
|
218
|
+
onFocus={() => setOpen(true)}
|
|
219
|
+
onBlur={() => {
|
|
220
|
+
// Delay so option mousedown can register before close.
|
|
221
|
+
window.setTimeout(() => setOpen(false), 120);
|
|
222
|
+
}}
|
|
223
|
+
onKeyDown={handleKeyDown}
|
|
224
|
+
placeholder="Search by name or email"
|
|
225
|
+
disabled={disabled || isLoading}
|
|
226
|
+
autoFocus
|
|
227
|
+
className={cn(
|
|
228
|
+
"w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground",
|
|
229
|
+
"placeholder:text-muted-foreground",
|
|
230
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
231
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
232
|
+
)}
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
{open && (
|
|
236
|
+
<ul
|
|
237
|
+
id={listboxId}
|
|
238
|
+
role="listbox"
|
|
239
|
+
aria-label="Organization members"
|
|
240
|
+
className={cn(
|
|
241
|
+
"absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-popover py-1 shadow-md",
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{isLoading && (
|
|
245
|
+
<li className="px-2.5 py-2 text-xs text-muted-foreground">
|
|
246
|
+
Loading members…
|
|
247
|
+
</li>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{!isLoading && error && (
|
|
251
|
+
<li className="px-2.5 py-2 text-[0.65rem] text-destructive" role="alert">
|
|
252
|
+
{getUserMessage(error)}
|
|
253
|
+
</li>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{!isLoading && !error && filtered.length === 0 && (
|
|
257
|
+
<li className="px-2.5 py-2 text-xs text-muted-foreground">
|
|
258
|
+
{query.trim()
|
|
259
|
+
? "No members match your search."
|
|
260
|
+
: "No members to share with."}
|
|
261
|
+
</li>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{!isLoading &&
|
|
265
|
+
!error &&
|
|
266
|
+
filtered.map((c, index) => {
|
|
267
|
+
const isExcluded = excluded.has(c.id);
|
|
268
|
+
const isActive = index === activeIndex;
|
|
269
|
+
const name = c.name || c.email || c.id;
|
|
270
|
+
// Show the provider badge when it disambiguates: an external
|
|
271
|
+
// identity source, or a shared email across candidates.
|
|
272
|
+
const showBadge =
|
|
273
|
+
!!providerLabel(c) &&
|
|
274
|
+
(c.identityOrigin?.providerDisplayName !== "Stigmer" ||
|
|
275
|
+
(!!c.email && duplicatedEmails.has(c.email.toLowerCase())));
|
|
276
|
+
return (
|
|
277
|
+
<li
|
|
278
|
+
key={c.id}
|
|
279
|
+
role="option"
|
|
280
|
+
aria-selected={isActive}
|
|
281
|
+
aria-disabled={isExcluded}
|
|
282
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
283
|
+
onMouseDown={(e) => {
|
|
284
|
+
// Prevent input blur before selection commits.
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
if (!isExcluded) commitSelection(c);
|
|
287
|
+
}}
|
|
288
|
+
className={cn(
|
|
289
|
+
"flex items-center justify-between gap-2 px-2.5 py-1.5",
|
|
290
|
+
isExcluded
|
|
291
|
+
? "cursor-not-allowed opacity-50"
|
|
292
|
+
: "cursor-pointer",
|
|
293
|
+
isActive && !isExcluded && "bg-accent-hover",
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
297
|
+
<div
|
|
298
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[0.6rem] font-medium text-muted-foreground"
|
|
299
|
+
aria-hidden="true"
|
|
300
|
+
>
|
|
301
|
+
{(name[0] ?? "?").toUpperCase()}
|
|
302
|
+
</div>
|
|
303
|
+
<div className="min-w-0">
|
|
304
|
+
<div className="flex items-center gap-1.5">
|
|
305
|
+
<span className="truncate text-xs text-foreground">
|
|
306
|
+
{name}
|
|
307
|
+
</span>
|
|
308
|
+
{showBadge && <ProviderBadge principal={c} />}
|
|
309
|
+
</div>
|
|
310
|
+
{c.email && c.email !== name && (
|
|
311
|
+
<span className="block truncate text-[0.6rem] text-muted-foreground">
|
|
312
|
+
{c.email}
|
|
313
|
+
</span>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
{isExcluded && (
|
|
318
|
+
<span className="shrink-0 text-[0.6rem] text-muted-foreground">
|
|
319
|
+
Has access
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</li>
|
|
323
|
+
);
|
|
324
|
+
})}
|
|
325
|
+
</ul>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function ClearIcon() {
|
|
333
|
+
return (
|
|
334
|
+
<svg
|
|
335
|
+
width="12"
|
|
336
|
+
height="12"
|
|
337
|
+
viewBox="0 0 16 16"
|
|
338
|
+
fill="none"
|
|
339
|
+
stroke="currentColor"
|
|
340
|
+
strokeWidth="2"
|
|
341
|
+
strokeLinecap="round"
|
|
342
|
+
aria-hidden="true"
|
|
343
|
+
>
|
|
344
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
345
|
+
</svg>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ApiResourceRefView } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/io_pb";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The human-readable identity-source label for a principal, or `""` when none
|
|
8
|
+
* is known.
|
|
9
|
+
*
|
|
10
|
+
* Email is not an identity key in Stigmer — a person can have a direct account
|
|
11
|
+
* and federated accounts that share one email. This label (e.g. "Stigmer" for
|
|
12
|
+
* direct sign-ups, or an IdentityProvider's display name for federated ones)
|
|
13
|
+
* is what lets the UI tell those accounts apart.
|
|
14
|
+
*/
|
|
15
|
+
export function providerLabel(principal: ApiResourceRefView | undefined): string {
|
|
16
|
+
return principal?.identityOrigin?.providerDisplayName ?? "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Props for {@link ProviderBadge}. */
|
|
20
|
+
export interface ProviderBadgeProps {
|
|
21
|
+
/** The principal whose identity source to display. */
|
|
22
|
+
readonly principal: ApiResourceRefView | undefined;
|
|
23
|
+
/** Additional CSS class names. */
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A subtle pill naming the identity source an account belongs to.
|
|
29
|
+
*
|
|
30
|
+
* Renders nothing when no provider label is available (e.g. legacy accounts
|
|
31
|
+
* with unspecified provisioning mode), so it never adds empty chrome.
|
|
32
|
+
*
|
|
33
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
34
|
+
*/
|
|
35
|
+
export function ProviderBadge({ principal, className }: ProviderBadgeProps) {
|
|
36
|
+
const label = providerLabel(principal);
|
|
37
|
+
if (!label) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<span
|
|
43
|
+
className={cn(
|
|
44
|
+
"inline-flex items-center rounded px-1.5 py-0.5 text-[0.6rem] font-medium",
|
|
45
|
+
"bg-muted-subtle text-muted-foreground",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
title={`Identity provider: ${label}`}
|
|
49
|
+
>
|
|
50
|
+
{label}
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|