@stigmer/react 3.13.0 → 3.14.0-dev.20260910084630
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/cursor-accounts/CursorAccountsConsole.d.ts +3 -2
- package/cursor-accounts/CursorAccountsConsole.d.ts.map +1 -1
- package/cursor-accounts/CursorAccountsConsole.js +3 -2
- package/cursor-accounts/CursorAccountsConsole.js.map +1 -1
- package/cursor-accounts/MemberCoverageTable.d.ts +11 -3
- package/cursor-accounts/MemberCoverageTable.d.ts.map +1 -1
- package/cursor-accounts/MemberCoverageTable.js +70 -42
- package/cursor-accounts/MemberCoverageTable.js.map +1 -1
- package/cursor-accounts/MemberKeysPanel.d.ts.map +1 -1
- package/cursor-accounts/MemberKeysPanel.js +4 -1
- package/cursor-accounts/MemberKeysPanel.js.map +1 -1
- package/internal/ResizableSplit.d.ts +1 -1
- package/package.json +4 -4
- package/sidebar/SettingsSidebar.d.ts.map +1 -1
- package/sidebar/SettingsSidebar.js +3 -3
- package/sidebar/SettingsSidebar.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +1 -1
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -15
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/sidebar/chrome.d.ts +21 -7
- package/sidebar/chrome.d.ts.map +1 -1
- package/sidebar/chrome.js +25 -7
- package/sidebar/chrome.js.map +1 -1
- package/src/cursor-accounts/CursorAccountsConsole.tsx +3 -2
- package/src/cursor-accounts/MemberCoverageTable.tsx +222 -200
- package/src/cursor-accounts/MemberKeysPanel.tsx +25 -1
- package/src/cursor-accounts/__tests__/CursorAccountsConsole.test.tsx +64 -8
- package/src/cursor-accounts/__tests__/MemberCoverageTable.layout.test.tsx +212 -0
- package/src/internal/ResizableSplit.tsx +1 -1
- package/src/sidebar/SettingsSidebar.tsx +11 -5
- package/src/sidebar/WorkspaceSidebar.tsx +75 -67
- package/src/sidebar/__tests__/SettingsSidebar.test.tsx +6 -2
- package/src/sidebar/chrome.tsx +29 -15
- package/styles.css +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
|
|
3
4
|
import { useCopyFeedback } from "../internal/useCopyFeedback.js";
|
|
4
5
|
import { cn } from "@stigmer/theme";
|
|
5
6
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
type CursorTeamMemberView,
|
|
9
10
|
} from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
|
|
10
11
|
import type { CursorMemberSpend } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/cursor_account_pb";
|
|
12
|
+
import { ActionMenu } from "../action-menu/index.js";
|
|
11
13
|
import { Button } from "../button/index.js";
|
|
12
14
|
import { TruncatedText } from "../internal/truncated-text.js";
|
|
13
15
|
import { StateBadge } from "./badges.js";
|
|
@@ -17,19 +19,25 @@ import type { useCursorMemberKeyActions } from "./useCursorMemberKeyActions.js";
|
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* One grid template shared by the header and every row so the columns
|
|
20
|
-
* can never drift apart: Member ·
|
|
21
|
-
*
|
|
22
|
+
* can never drift apart: Member · First-party % · API % · Included $ ·
|
|
23
|
+
* On-demand $ · Status · actions kebab.
|
|
22
24
|
*
|
|
23
25
|
* The four numeric columns mirror Cursor's own Members page (First-Party
|
|
24
26
|
* Models %, API %, On-Demand $) plus the included-quota dollars, so an
|
|
25
27
|
* operator can read this table and the Cursor dashboard side by side.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
* The column budget is sized for the table's real canvas: both client
|
|
30
|
+
* apps render settings inside `max-w-3xl` (768px), leaving ~720px of
|
|
31
|
+
* content. Fixed tracks total ~25rem, so the flexible member column
|
|
32
|
+
* keeps ~240px — enough to render typical emails whole (stigmer#929: an
|
|
33
|
+
* earlier 8-column layout with a three-button actions column consumed
|
|
34
|
+
* the full width and collapsed the member column to zero, shattering
|
|
35
|
+
* emails one character per line). Key name/label live inside the member
|
|
36
|
+
* cell and row actions inside a kebab menu precisely to defend that
|
|
37
|
+
* budget.
|
|
30
38
|
*/
|
|
31
39
|
const ROW_GRID =
|
|
32
|
-
"stg:grid stg:grid-cols-[minmax(0,
|
|
40
|
+
"stg:grid stg:grid-cols-[minmax(0,1fr)_4rem_4rem_4.5rem_4.5rem_6rem_2.25rem] stg:items-center stg:gap-2 stg:px-3 stg:py-2";
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* The roster-coverage table: every member and every stored execution key
|
|
@@ -41,9 +49,17 @@ const ROW_GRID =
|
|
|
41
49
|
* help the operator prioritize whose key to import.
|
|
42
50
|
* 3. **Key held, not on the team** — imported keys whose owner left the
|
|
43
51
|
* team or never joined it. Resolved by a Cursor-dashboard invite:
|
|
44
|
-
* when the account carries a team invite link,
|
|
45
|
-
* one-click copy (
|
|
46
|
-
*
|
|
52
|
+
* when the account carries a team invite link, the group header
|
|
53
|
+
* offers one-click copy (one button, not one per row — the link is
|
|
54
|
+
* account-level and identical for every owner; and always a copy,
|
|
55
|
+
* never navigation, since opening an invite link would join the
|
|
56
|
+
* OPERATOR's own Cursor account to the team).
|
|
57
|
+
*
|
|
58
|
+
* The categories render as grouped sections of ONE table — deliberately
|
|
59
|
+
* not tabs. Together they answer a single question ("is this roster
|
|
60
|
+
* healthy?"), so all three counts must be visible at once; groups are
|
|
61
|
+
* team-sized (5–30 rows), never large enough to need pagination-style
|
|
62
|
+
* chrome.
|
|
47
63
|
*
|
|
48
64
|
* Before the first sync there is no roster to classify against, so keys
|
|
49
65
|
* render unclassified with a "sync now" notice instead of being falsely
|
|
@@ -64,119 +80,119 @@ export function MemberCoverageTable({
|
|
|
64
80
|
readonly onChanged: () => void;
|
|
65
81
|
}) {
|
|
66
82
|
return (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<span role="columnheader">Key</span>
|
|
81
|
-
<span role="columnheader" className="stg:text-right">
|
|
82
|
-
First-party
|
|
83
|
-
</span>
|
|
84
|
-
<span role="columnheader" className="stg:text-right">
|
|
85
|
-
API
|
|
86
|
-
</span>
|
|
87
|
-
<span role="columnheader" className="stg:text-right">
|
|
88
|
-
Included
|
|
89
|
-
</span>
|
|
90
|
-
<span role="columnheader" className="stg:text-right">
|
|
91
|
-
On-demand
|
|
92
|
-
</span>
|
|
93
|
-
<span role="columnheader">Status</span>
|
|
94
|
-
<span role="columnheader" className="stg:text-right">
|
|
95
|
-
Actions
|
|
96
|
-
</span>
|
|
97
|
-
</div>
|
|
98
|
-
|
|
99
|
-
{!coverage.hasRoster && (
|
|
100
|
-
<CoverageGroup
|
|
101
|
-
title="Execution keys — not yet classified"
|
|
102
|
-
description='No roster sync has run for this account. Run "Sync now" to load the team roster and classify these keys.'
|
|
103
|
-
count={coverage.unclassified.length}
|
|
83
|
+
// Narrow-host guard (the ResourceTable idiom): below the grid's
|
|
84
|
+
// legible minimum the table scrolls horizontally instead of letting
|
|
85
|
+
// the flexible member column collapse. The scroll container carries
|
|
86
|
+
// the card chrome; the ARIA table starts inside it so `role="row"`
|
|
87
|
+
// children stay directly owned by the table.
|
|
88
|
+
<div className="stg:overflow-x-auto stg:rounded-lg stg:border stg:border-border stg:bg-card">
|
|
89
|
+
<div role="table" aria-label="Team coverage" className="stg:min-w-[40rem]">
|
|
90
|
+
<div
|
|
91
|
+
role="row"
|
|
92
|
+
className={cn(
|
|
93
|
+
ROW_GRID,
|
|
94
|
+
"stg:border-b stg:border-border stg:text-[11px] stg:font-medium stg:text-muted-foreground",
|
|
95
|
+
)}
|
|
104
96
|
>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
97
|
+
<span role="columnheader">Member</span>
|
|
98
|
+
<span role="columnheader" className="stg:text-right">
|
|
99
|
+
First-party
|
|
100
|
+
</span>
|
|
101
|
+
<span role="columnheader" className="stg:text-right">
|
|
102
|
+
API
|
|
103
|
+
</span>
|
|
104
|
+
<span role="columnheader" className="stg:text-right">
|
|
105
|
+
Included
|
|
106
|
+
</span>
|
|
107
|
+
<span role="columnheader" className="stg:text-right">
|
|
108
|
+
On-demand
|
|
109
|
+
</span>
|
|
110
|
+
<span role="columnheader">Status</span>
|
|
111
|
+
<span role="columnheader">
|
|
112
|
+
<span className="stg:sr-only">Actions</span>
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
118
115
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
116
|
+
{!coverage.hasRoster && (
|
|
117
|
+
<CoverageGroup
|
|
118
|
+
title="Execution keys — not yet classified"
|
|
119
|
+
description='No roster sync has run for this account. Run "Sync now" to load the team roster and classify these keys.'
|
|
120
|
+
count={coverage.unclassified.length}
|
|
121
|
+
>
|
|
122
|
+
{coverage.unclassified.map((keyView) => (
|
|
123
|
+
<KeyRow
|
|
124
|
+
key={keyView.key?.keyId}
|
|
125
|
+
accountId={accountId}
|
|
126
|
+
keyView={keyView}
|
|
127
|
+
hasRoster={false}
|
|
128
|
+
actions={actions}
|
|
129
|
+
onChanged={onChanged}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</CoverageGroup>
|
|
133
|
+
)}
|
|
137
134
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)}
|
|
135
|
+
{coverage.onTeamWithKey.length > 0 && (
|
|
136
|
+
<CoverageGroup
|
|
137
|
+
title="On the team — key held"
|
|
138
|
+
count={coverage.onTeamWithKey.length}
|
|
139
|
+
>
|
|
140
|
+
{coverage.onTeamWithKey.map((keyView) => (
|
|
141
|
+
<KeyRow
|
|
142
|
+
key={keyView.key?.keyId}
|
|
143
|
+
accountId={accountId}
|
|
144
|
+
keyView={keyView}
|
|
145
|
+
hasRoster
|
|
146
|
+
actions={actions}
|
|
147
|
+
onChanged={onChanged}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
</CoverageGroup>
|
|
151
|
+
)}
|
|
156
152
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
153
|
+
{/* Rendered whenever a roster exists — an empty gap is an answer
|
|
154
|
+
("fully covered"), not an absence. Hiding the group made a
|
|
155
|
+
healthy roster indistinguishable from a broken sync. */}
|
|
156
|
+
{coverage.hasRoster && (
|
|
157
|
+
<CoverageGroup
|
|
158
|
+
title="On the team — no execution key"
|
|
159
|
+
description={
|
|
160
|
+
coverage.onTeamWithoutKey.length > 0
|
|
161
|
+
? "Sessions can never run under these members' identity or included quota. Add their user-scoped keys below to close the gap."
|
|
162
|
+
: "Every active roster member holds an execution key — the roster is fully covered."
|
|
163
|
+
}
|
|
164
|
+
count={coverage.onTeamWithoutKey.length}
|
|
165
|
+
>
|
|
166
|
+
{coverage.onTeamWithoutKey.map((memberView) => (
|
|
167
|
+
<GapRow key={memberView.member?.email} memberView={memberView} />
|
|
168
|
+
))}
|
|
169
|
+
</CoverageGroup>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{coverage.offTeamWithKey.length > 0 && (
|
|
173
|
+
<CoverageGroup
|
|
174
|
+
title="Key held — not on the team"
|
|
175
|
+
description={
|
|
176
|
+
inviteLink !== ""
|
|
177
|
+
? "These key owners must join the Cursor team before their usage counts against its quota — copy the invite link, send it out-of-band, then \u201CSync now\u201D reclassifies once they join."
|
|
178
|
+
: "These key owners must join the Cursor team before their usage counts against its quota. Invites are sent from the Cursor dashboard (cursor.com \u2192 Invite Members); paste the team's invite link into the account editor to enable one-click copying here."
|
|
179
|
+
}
|
|
180
|
+
count={coverage.offTeamWithKey.length}
|
|
181
|
+
action={inviteLink !== "" ? <CopyInviteButton inviteLink={inviteLink} /> : undefined}
|
|
182
|
+
>
|
|
183
|
+
{coverage.offTeamWithKey.map((keyView) => (
|
|
184
|
+
<KeyRow
|
|
185
|
+
key={keyView.key?.keyId}
|
|
186
|
+
accountId={accountId}
|
|
187
|
+
keyView={keyView}
|
|
188
|
+
hasRoster
|
|
189
|
+
actions={actions}
|
|
190
|
+
onChanged={onChanged}
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
193
|
+
</CoverageGroup>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
180
196
|
</div>
|
|
181
197
|
);
|
|
182
198
|
}
|
|
@@ -189,28 +205,38 @@ function CoverageGroup({
|
|
|
189
205
|
title,
|
|
190
206
|
description,
|
|
191
207
|
count,
|
|
208
|
+
action,
|
|
192
209
|
children,
|
|
193
210
|
}: {
|
|
194
211
|
readonly title: string;
|
|
195
212
|
readonly description?: string;
|
|
196
213
|
readonly count: number;
|
|
214
|
+
/** Optional group-level action (e.g. the off-team invite copy). */
|
|
215
|
+
readonly action?: React.ReactNode;
|
|
197
216
|
readonly children: React.ReactNode;
|
|
198
217
|
}) {
|
|
199
218
|
return (
|
|
200
219
|
<div role="rowgroup" className="stg:border-b stg:border-border stg:last:border-b-0">
|
|
201
220
|
<div role="row" className="stg:bg-muted-subtle stg:px-3 stg:py-1.5">
|
|
202
|
-
<div
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
221
|
+
<div
|
|
222
|
+
role="cell"
|
|
223
|
+
aria-colspan={7}
|
|
224
|
+
className="stg:flex stg:items-start stg:justify-between stg:gap-2"
|
|
225
|
+
>
|
|
226
|
+
<span className="stg:min-w-0">
|
|
227
|
+
<span className="stg:text-[11px] stg:font-semibold stg:text-foreground">
|
|
228
|
+
{title}
|
|
229
|
+
<span className="stg:ml-1.5 stg:font-normal stg:text-muted-foreground">
|
|
230
|
+
{count}
|
|
231
|
+
</span>
|
|
207
232
|
</span>
|
|
233
|
+
{description && (
|
|
234
|
+
<span className="stg:block stg:text-[11px] stg:text-muted-foreground">
|
|
235
|
+
{description}
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
208
238
|
</span>
|
|
209
|
-
{
|
|
210
|
-
<span className="stg:block stg:text-[11px] stg:text-muted-foreground">
|
|
211
|
-
{description}
|
|
212
|
-
</span>
|
|
213
|
-
)}
|
|
239
|
+
{action && <span className="stg:shrink-0">{action}</span>}
|
|
214
240
|
</div>
|
|
215
241
|
</div>
|
|
216
242
|
{children}
|
|
@@ -224,30 +250,29 @@ function CoverageGroup({
|
|
|
224
250
|
|
|
225
251
|
/**
|
|
226
252
|
* The member-identity cell shared by key rows and gap rows. The email is
|
|
227
|
-
* the row's identity
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
253
|
+
* the row's identity and truncates with the overflow-gated house tooltip
|
|
254
|
+
* (never `break-words`: with grid columns under pressure, word-breaking
|
|
255
|
+
* degenerated to one character per line — stigmer#929; the full value
|
|
256
|
+
* stays available to selection, screen readers, and the tooltip). The
|
|
257
|
+
* secondary line carries what each row type actually has: the key
|
|
258
|
+
* name/label on key rows, the roster display name on gap rows
|
|
259
|
+
* (`CursorMemberKeyView` carries no member name to show).
|
|
232
260
|
*/
|
|
233
261
|
function MemberCell({
|
|
234
262
|
email,
|
|
235
|
-
|
|
263
|
+
secondary,
|
|
236
264
|
}: {
|
|
237
265
|
readonly email: string;
|
|
238
|
-
readonly
|
|
266
|
+
readonly secondary?: string;
|
|
239
267
|
}) {
|
|
240
268
|
return (
|
|
241
269
|
<span role="cell" className="stg:min-w-0">
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
<span className="stg:block stg:truncate stg:text-[11px] stg:text-muted-foreground">
|
|
249
|
-
{name}
|
|
250
|
-
</span>
|
|
270
|
+
<TruncatedText text={email} className="stg:font-medium stg:text-foreground" />
|
|
271
|
+
{secondary && (
|
|
272
|
+
<TruncatedText
|
|
273
|
+
text={secondary}
|
|
274
|
+
className="stg:text-[11px] stg:text-muted-foreground"
|
|
275
|
+
/>
|
|
251
276
|
)}
|
|
252
277
|
</span>
|
|
253
278
|
);
|
|
@@ -258,71 +283,70 @@ function KeyRow({
|
|
|
258
283
|
accountId,
|
|
259
284
|
keyView,
|
|
260
285
|
hasRoster,
|
|
261
|
-
inviteLink,
|
|
262
286
|
actions,
|
|
263
287
|
onChanged,
|
|
264
288
|
}: {
|
|
265
289
|
readonly accountId: string;
|
|
266
290
|
readonly keyView: CursorMemberKeyView;
|
|
267
291
|
readonly hasRoster: boolean;
|
|
268
|
-
readonly inviteLink: string;
|
|
269
292
|
readonly actions: ReturnType<typeof useCursorMemberKeyActions>;
|
|
270
293
|
readonly onChanged: () => void;
|
|
271
294
|
}) {
|
|
272
295
|
const key = keyView.key;
|
|
273
296
|
if (!key) return null;
|
|
274
297
|
|
|
298
|
+
const keyMeta = [key.cursorKeyName || "unnamed key", key.label]
|
|
299
|
+
.filter((part) => part !== "")
|
|
300
|
+
.join(" · ");
|
|
301
|
+
|
|
275
302
|
return (
|
|
276
303
|
<div role="row" className={cn(ROW_GRID, "stg:border-t stg:border-border-muted stg:text-xs")}>
|
|
277
|
-
<MemberCell email={key.boundEmail} />
|
|
278
|
-
<span role="cell" className="stg:min-w-0">
|
|
279
|
-
<TruncatedText
|
|
280
|
-
text={key.cursorKeyName || "unnamed key"}
|
|
281
|
-
className="stg:block stg:text-muted-foreground"
|
|
282
|
-
/>
|
|
283
|
-
{key.label && (
|
|
284
|
-
<TruncatedText
|
|
285
|
-
text={key.label}
|
|
286
|
-
className="stg:block stg:text-[11px] stg:text-muted-foreground"
|
|
287
|
-
/>
|
|
288
|
-
)}
|
|
289
|
-
</span>
|
|
304
|
+
<MemberCell email={key.boundEmail} secondary={keyMeta} />
|
|
290
305
|
<SpendCells spend={keyView.spend} />
|
|
291
306
|
<span role="cell" className="stg:flex stg:flex-wrap stg:items-center stg:gap-1">
|
|
292
307
|
{keyView.usageGuardTripped && <StateBadge tone="warn" label="Usage guard" />}
|
|
293
308
|
<KeyStatusBadge keyView={keyView} hasRoster={hasRoster} />
|
|
294
309
|
</span>
|
|
295
|
-
<span role="cell" className="stg:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
310
|
+
<span role="cell" className="stg:text-right">
|
|
311
|
+
<ActionMenu>
|
|
312
|
+
<ActionMenu.Trigger aria-label={`Actions for ${key.boundEmail}`}>
|
|
313
|
+
<MoreHorizontal className="stg:size-4" />
|
|
314
|
+
</ActionMenu.Trigger>
|
|
315
|
+
<ActionMenu.Content>
|
|
316
|
+
{/* `disabled` guards against a re-fire while a prior key
|
|
317
|
+
mutation is still in flight; outcomes surface via
|
|
318
|
+
actions.error under the table. */}
|
|
319
|
+
<ActionMenu.Item
|
|
320
|
+
icon={key.enabled ? <Pause /> : <Play />}
|
|
321
|
+
disabled={actions.isSubmitting}
|
|
322
|
+
onSelect={() => {
|
|
323
|
+
void actions
|
|
324
|
+
.setKeyEnabled(accountId, key.keyId, !key.enabled)
|
|
325
|
+
.then(onChanged, () => {
|
|
326
|
+
// Surfaced via actions.error.
|
|
327
|
+
});
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
{key.enabled ? "Disable" : "Enable"}
|
|
331
|
+
</ActionMenu.Item>
|
|
332
|
+
<ActionMenu.Separator />
|
|
333
|
+
<ActionMenu.Item
|
|
334
|
+
icon={<Trash2 />}
|
|
335
|
+
variant="destructive"
|
|
336
|
+
disabled={actions.isSubmitting}
|
|
337
|
+
onSelect={() => {
|
|
338
|
+
void actions
|
|
339
|
+
.removeKey({ accountId, keyId: key.keyId })
|
|
340
|
+
.then(onChanged, () => {
|
|
341
|
+
// Surfaced via actions.error (incl. the live-pin guard's
|
|
342
|
+
// "disable instead, or force" message).
|
|
343
|
+
});
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
Remove
|
|
347
|
+
</ActionMenu.Item>
|
|
348
|
+
</ActionMenu.Content>
|
|
349
|
+
</ActionMenu>
|
|
326
350
|
</span>
|
|
327
351
|
</div>
|
|
328
352
|
);
|
|
@@ -335,17 +359,14 @@ function GapRow({ memberView }: { readonly memberView: CursorTeamMemberView }) {
|
|
|
335
359
|
|
|
336
360
|
return (
|
|
337
361
|
<div role="row" className={cn(ROW_GRID, "stg:border-t stg:border-border-muted stg:text-xs")}>
|
|
338
|
-
<MemberCell email={member.email}
|
|
339
|
-
<span role="cell" className="stg:text-muted-foreground">
|
|
340
|
-
—
|
|
341
|
-
</span>
|
|
362
|
+
<MemberCell email={member.email} secondary={member.name || undefined} />
|
|
342
363
|
<SpendCells spend={memberView.spend} />
|
|
343
364
|
<span role="cell">
|
|
344
365
|
<StateBadge tone="muted" label="No key" />
|
|
345
366
|
</span>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
367
|
+
{/* No key, no actions — an empty cell keeps every row at the
|
|
368
|
+
header's seven columns. */}
|
|
369
|
+
<span role="cell" />
|
|
349
370
|
</div>
|
|
350
371
|
);
|
|
351
372
|
}
|
|
@@ -415,9 +436,10 @@ function KeyStatusBadge({
|
|
|
415
436
|
// ---------------------------------------------------------------------------
|
|
416
437
|
|
|
417
438
|
/**
|
|
418
|
-
* Copies the account-level team invite link
|
|
419
|
-
*
|
|
420
|
-
*
|
|
439
|
+
* Copies the account-level team invite link, once per group (the link is
|
|
440
|
+
* identical for every off-team owner). Deliberately a copy, never a
|
|
441
|
+
* navigation: a Cursor invite link is a join link, and opening it would
|
|
442
|
+
* join the operator's own Cursor account to the team.
|
|
421
443
|
*/
|
|
422
444
|
function CopyInviteButton({ inviteLink }: { readonly inviteLink: string }) {
|
|
423
445
|
const { copy, copied } = useCopyFeedback();
|
|
@@ -143,7 +143,31 @@ export function MemberKeysPanel({
|
|
|
143
143
|
|
|
144
144
|
return (
|
|
145
145
|
<section className="stg:space-y-2">
|
|
146
|
-
<
|
|
146
|
+
<div>
|
|
147
|
+
<h4 className="stg:text-xs stg:font-semibold stg:text-foreground">Team coverage</h4>
|
|
148
|
+
{/* One-line health subtitle so the coverage answer is readable
|
|
149
|
+
even when the gap group sits below the fold. The counts are
|
|
150
|
+
the table's own groups (no re-derivation); the gap count is
|
|
151
|
+
the attention point, tinted only when it is non-zero. Before
|
|
152
|
+
the first sync there is nothing to classify, so no subtitle. */}
|
|
153
|
+
{coverage.hasRoster && (
|
|
154
|
+
<p className="stg:text-[11px] stg:text-muted-foreground">
|
|
155
|
+
{coverage.onTeamWithKey.length} covered
|
|
156
|
+
{" · "}
|
|
157
|
+
<span
|
|
158
|
+
className={cn(
|
|
159
|
+
coverage.onTeamWithoutKey.length > 0 &&
|
|
160
|
+
"stg:font-medium stg:text-destructive",
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{coverage.onTeamWithoutKey.length} without keys
|
|
164
|
+
</span>
|
|
165
|
+
{coverage.offTeamWithKey.length > 0
|
|
166
|
+
? ` · ${coverage.offTeamWithKey.length} off-team`
|
|
167
|
+
: ""}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
147
171
|
|
|
148
172
|
{keyViews.length === 0 && (
|
|
149
173
|
<p className="stg:text-xs stg:text-muted-foreground" role="status">
|