@stigmer/react 3.0.7 → 3.0.8-dev.20260612062921

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 (132) hide show
  1. package/access/ManageAccessButton.d.ts +47 -0
  2. package/access/ManageAccessButton.d.ts.map +1 -0
  3. package/access/ManageAccessButton.js +45 -0
  4. package/access/ManageAccessButton.js.map +1 -0
  5. package/access/ManageAccessDialog.d.ts +60 -0
  6. package/access/ManageAccessDialog.d.ts.map +1 -0
  7. package/access/ManageAccessDialog.js +83 -0
  8. package/access/ManageAccessDialog.js.map +1 -0
  9. package/access/index.d.ts +5 -0
  10. package/access/index.d.ts.map +1 -0
  11. package/access/index.js +7 -0
  12. package/access/index.js.map +1 -0
  13. package/access/types.d.ts +56 -0
  14. package/access/types.d.ts.map +1 -0
  15. package/access/types.js +2 -0
  16. package/access/types.js.map +1 -0
  17. package/access/useManageAccess.d.ts +60 -0
  18. package/access/useManageAccess.d.ts.map +1 -0
  19. package/access/useManageAccess.js +41 -0
  20. package/access/useManageAccess.js.map +1 -0
  21. package/agent/AgentDetailView.d.ts.map +1 -1
  22. package/agent/AgentDetailView.js +34 -4
  23. package/agent/AgentDetailView.js.map +1 -1
  24. package/agent-instance/AgentInstanceDetailPanel.d.ts.map +1 -1
  25. package/agent-instance/AgentInstanceDetailPanel.js +14 -4
  26. package/agent-instance/AgentInstanceDetailPanel.js.map +1 -1
  27. package/iam-policy/GrantAccessForm.d.ts +15 -5
  28. package/iam-policy/GrantAccessForm.d.ts.map +1 -1
  29. package/iam-policy/GrantAccessForm.js +15 -12
  30. package/iam-policy/GrantAccessForm.js.map +1 -1
  31. package/iam-policy/OrgMembersPanel.d.ts.map +1 -1
  32. package/iam-policy/OrgMembersPanel.js +2 -1
  33. package/iam-policy/OrgMembersPanel.js.map +1 -1
  34. package/iam-policy/PeopleWithAccess.d.ts +34 -0
  35. package/iam-policy/PeopleWithAccess.d.ts.map +1 -0
  36. package/iam-policy/PeopleWithAccess.js +55 -0
  37. package/iam-policy/PeopleWithAccess.js.map +1 -0
  38. package/iam-policy/PrincipalPicker.d.ts +48 -0
  39. package/iam-policy/PrincipalPicker.d.ts.map +1 -0
  40. package/iam-policy/PrincipalPicker.js +139 -0
  41. package/iam-policy/PrincipalPicker.js.map +1 -0
  42. package/iam-policy/ProviderBadge.d.ts +28 -0
  43. package/iam-policy/ProviderBadge.d.ts.map +1 -0
  44. package/iam-policy/ProviderBadge.js +31 -0
  45. package/iam-policy/ProviderBadge.js.map +1 -0
  46. package/iam-policy/SharePanel.d.ts +13 -5
  47. package/iam-policy/SharePanel.d.ts.map +1 -1
  48. package/iam-policy/SharePanel.js +9 -34
  49. package/iam-policy/SharePanel.js.map +1 -1
  50. package/iam-policy/index.d.ts +3 -0
  51. package/iam-policy/index.d.ts.map +1 -1
  52. package/iam-policy/index.js +3 -0
  53. package/iam-policy/index.js.map +1 -1
  54. package/index.d.ts +7 -5
  55. package/index.d.ts.map +1 -1
  56. package/index.js +6 -3
  57. package/index.js.map +1 -1
  58. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  59. package/mcp-server/McpServerDetailView.js +41 -12
  60. package/mcp-server/McpServerDetailView.js.map +1 -1
  61. package/organization/OrgProvider.d.ts +9 -0
  62. package/organization/OrgProvider.d.ts.map +1 -1
  63. package/organization/OrgProvider.js +12 -0
  64. package/organization/OrgProvider.js.map +1 -1
  65. package/organization/index.d.ts +1 -1
  66. package/organization/index.d.ts.map +1 -1
  67. package/organization/index.js +1 -1
  68. package/organization/index.js.map +1 -1
  69. package/package.json +12 -12
  70. package/skill/SkillDetailView.d.ts.map +1 -1
  71. package/skill/SkillDetailView.js +31 -3
  72. package/skill/SkillDetailView.js.map +1 -1
  73. package/src/access/ManageAccessButton.tsx +115 -0
  74. package/src/access/ManageAccessDialog.tsx +239 -0
  75. package/src/access/__tests__/ManageAccessButton.test.tsx +62 -0
  76. package/src/access/__tests__/ManageAccessDialog.test.tsx +146 -0
  77. package/src/access/index.ts +21 -0
  78. package/src/access/types.ts +58 -0
  79. package/src/access/useManageAccess.tsx +101 -0
  80. package/src/agent/AgentDetailView.tsx +50 -21
  81. package/src/agent-instance/AgentInstanceDetailPanel.tsx +24 -42
  82. package/src/iam-policy/GrantAccessForm.tsx +30 -35
  83. package/src/iam-policy/OrgMembersPanel.tsx +2 -0
  84. package/src/iam-policy/PeopleWithAccess.tsx +220 -0
  85. package/src/iam-policy/PrincipalPicker.tsx +347 -0
  86. package/src/iam-policy/ProviderBadge.tsx +53 -0
  87. package/src/iam-policy/SharePanel.tsx +20 -165
  88. package/src/iam-policy/index.ts +17 -0
  89. package/src/index.ts +30 -0
  90. package/src/mcp-server/McpServerDetailView.tsx +37 -9
  91. package/src/organization/OrgProvider.tsx +13 -0
  92. package/src/organization/index.ts +1 -1
  93. package/src/skill/SkillDetailView.tsx +34 -9
  94. package/src/workflow/WorkflowDetailView.tsx +49 -22
  95. package/src/workflow/WorkflowExecutionHeader.tsx +12 -1
  96. package/src/workflow/WorkflowExecutionViewer.tsx +8 -1
  97. package/src/workflow/index.ts +4 -0
  98. package/src/workflow/instance/RunVisibilityControl.tsx +116 -0
  99. package/src/workflow/instance/WorkflowInstanceDetailPanel.tsx +55 -42
  100. package/src/workflow/instance/index.ts +5 -0
  101. package/src/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.ts +74 -0
  102. package/styles.css +1 -1
  103. package/workflow/WorkflowDetailView.d.ts.map +1 -1
  104. package/workflow/WorkflowDetailView.js +31 -3
  105. package/workflow/WorkflowDetailView.js.map +1 -1
  106. package/workflow/WorkflowExecutionHeader.d.ts +7 -0
  107. package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
  108. package/workflow/WorkflowExecutionHeader.js +2 -2
  109. package/workflow/WorkflowExecutionHeader.js.map +1 -1
  110. package/workflow/WorkflowExecutionViewer.d.ts +6 -1
  111. package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
  112. package/workflow/WorkflowExecutionViewer.js +2 -2
  113. package/workflow/WorkflowExecutionViewer.js.map +1 -1
  114. package/workflow/index.d.ts +1 -1
  115. package/workflow/index.d.ts.map +1 -1
  116. package/workflow/index.js +1 -1
  117. package/workflow/index.js.map +1 -1
  118. package/workflow/instance/RunVisibilityControl.d.ts +25 -0
  119. package/workflow/instance/RunVisibilityControl.d.ts.map +1 -0
  120. package/workflow/instance/RunVisibilityControl.js +56 -0
  121. package/workflow/instance/RunVisibilityControl.js.map +1 -0
  122. package/workflow/instance/WorkflowInstanceDetailPanel.d.ts.map +1 -1
  123. package/workflow/instance/WorkflowInstanceDetailPanel.js +30 -4
  124. package/workflow/instance/WorkflowInstanceDetailPanel.js.map +1 -1
  125. package/workflow/instance/index.d.ts +2 -0
  126. package/workflow/instance/index.d.ts.map +1 -1
  127. package/workflow/instance/index.js +2 -0
  128. package/workflow/instance/index.js.map +1 -1
  129. package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.d.ts +30 -0
  130. package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.d.ts.map +1 -0
  131. package/workflow/instance/useUpdateWorkflowInstanceExecutionVisibility.js +39 -0
  132. 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
+ }