@stigmer/react 3.3.0 → 3.4.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/billing/index.d.ts +0 -18
- package/billing/index.d.ts.map +1 -1
- package/billing/index.js +4 -9
- package/billing/index.js.map +1 -1
- package/cursor-accounts/CursorAccountEditor.d.ts +7 -1
- package/cursor-accounts/CursorAccountEditor.d.ts.map +1 -1
- package/cursor-accounts/CursorAccountEditor.js +21 -5
- package/cursor-accounts/CursorAccountEditor.js.map +1 -1
- package/cursor-accounts/CursorAccountsConsole.d.ts +9 -5
- package/cursor-accounts/CursorAccountsConsole.d.ts.map +1 -1
- package/cursor-accounts/CursorAccountsConsole.js +34 -140
- package/cursor-accounts/CursorAccountsConsole.js.map +1 -1
- package/cursor-accounts/MemberCoverageTable.d.ts +29 -0
- package/cursor-accounts/MemberCoverageTable.d.ts.map +1 -0
- package/cursor-accounts/MemberCoverageTable.js +150 -0
- package/cursor-accounts/MemberCoverageTable.js.map +1 -0
- package/cursor-accounts/MemberKeysPanel.d.ts +25 -0
- package/cursor-accounts/MemberKeysPanel.d.ts.map +1 -0
- package/cursor-accounts/MemberKeysPanel.js +115 -0
- package/cursor-accounts/MemberKeysPanel.js.map +1 -0
- package/cursor-accounts/badges.d.ts +9 -0
- package/cursor-accounts/badges.d.ts.map +1 -0
- package/cursor-accounts/badges.js +10 -0
- package/cursor-accounts/badges.js.map +1 -0
- package/cursor-accounts/cursor-account-coverage.d.ts +41 -0
- package/cursor-accounts/cursor-account-coverage.d.ts.map +1 -0
- package/cursor-accounts/cursor-account-coverage.js +26 -0
- package/cursor-accounts/cursor-account-coverage.js.map +1 -0
- package/cursor-accounts/cursor-account-format.d.ts +13 -0
- package/cursor-accounts/cursor-account-format.d.ts.map +1 -1
- package/cursor-accounts/cursor-account-format.js +16 -1
- package/cursor-accounts/cursor-account-format.js.map +1 -1
- package/datastore/CollectionRecordsBrowser.js +18 -4
- package/datastore/CollectionRecordsBrowser.js.map +1 -1
- package/datastore/RecordFilterBuilder.d.ts +8 -1
- package/datastore/RecordFilterBuilder.d.ts.map +1 -1
- package/datastore/RecordFilterBuilder.js +2 -2
- package/datastore/RecordFilterBuilder.js.map +1 -1
- package/datastore/recordFilter.d.ts +9 -1
- package/datastore/recordFilter.d.ts.map +1 -1
- package/datastore/recordFilter.js +12 -1
- package/datastore/recordFilter.js.map +1 -1
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +13 -4
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +10 -2
- package/execution/MessageThread.js.map +1 -1
- package/execution/useCreateAgentExecution.d.ts +31 -1
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +2 -0
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +6 -2
- package/index.js.map +1 -1
- package/internal/form-primitives.d.ts +10 -0
- package/internal/form-primitives.d.ts.map +1 -0
- package/{billing → internal}/form-primitives.js +5 -9
- package/internal/form-primitives.js.map +1 -0
- package/package.json +4 -4
- package/pricing-governance/BaselineEditor.d.ts.map +1 -0
- package/{billing → pricing-governance}/BaselineEditor.js +19 -2
- package/pricing-governance/BaselineEditor.js.map +1 -0
- package/pricing-governance/ModelCatalogPanel.d.ts.map +1 -0
- package/pricing-governance/ModelCatalogPanel.js.map +1 -0
- package/pricing-governance/ModelGovernanceDetail.d.ts.map +1 -0
- package/pricing-governance/ModelGovernanceDetail.js.map +1 -0
- package/pricing-governance/OperatorAccessNotice.d.ts.map +1 -0
- package/pricing-governance/OperatorAccessNotice.js.map +1 -0
- package/pricing-governance/PricingGovernanceConsole.d.ts.map +1 -0
- package/{billing → pricing-governance}/PricingGovernanceConsole.js +1 -1
- package/pricing-governance/PricingGovernanceConsole.js.map +1 -0
- package/pricing-governance/PricingGovernancePanel.d.ts.map +1 -0
- package/pricing-governance/PricingGovernancePanel.js.map +1 -0
- package/pricing-governance/RetireConfirm.d.ts.map +1 -0
- package/{billing → pricing-governance}/RetireConfirm.js +1 -1
- package/pricing-governance/RetireConfirm.js.map +1 -0
- package/pricing-governance/governance-primitives.d.ts.map +1 -0
- package/pricing-governance/governance-primitives.js.map +1 -0
- package/pricing-governance/index.d.ts +19 -0
- package/pricing-governance/index.d.ts.map +1 -0
- package/pricing-governance/index.js +20 -0
- package/pricing-governance/index.js.map +1 -0
- package/pricing-governance/pricing-format.d.ts.map +1 -0
- package/pricing-governance/pricing-format.js.map +1 -0
- package/pricing-governance/useDecidePricingOverride.d.ts.map +1 -0
- package/pricing-governance/useDecidePricingOverride.js.map +1 -0
- package/pricing-governance/useModelGovernanceView.d.ts.map +1 -0
- package/pricing-governance/useModelGovernanceView.js.map +1 -0
- package/pricing-governance/useModelPricingBaselines.d.ts.map +1 -0
- package/pricing-governance/useModelPricingBaselines.js.map +1 -0
- package/pricing-governance/usePricingGovernance.d.ts.map +1 -0
- package/pricing-governance/usePricingGovernance.js.map +1 -0
- package/pricing-governance/useRetireModelPricingBaseline.d.ts.map +1 -0
- package/pricing-governance/useRetireModelPricingBaseline.js.map +1 -0
- package/pricing-governance/useUpsertModelPricingBaseline.d.ts.map +1 -0
- package/pricing-governance/useUpsertModelPricingBaseline.js.map +1 -0
- package/session/useCreateSession.d.ts +28 -0
- package/session/useCreateSession.d.ts.map +1 -1
- package/session/useCreateSession.js +2 -1
- package/session/useCreateSession.js.map +1 -1
- package/session/useNewSessionFlow.d.ts +30 -0
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +7 -1
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/useUpdateSession.d.ts +29 -10
- package/session/useUpdateSession.d.ts.map +1 -1
- package/session/useUpdateSession.js +29 -10
- package/session/useUpdateSession.js.map +1 -1
- package/src/billing/index.ts +5 -37
- package/src/cursor-accounts/CursorAccountEditor.tsx +61 -13
- package/src/cursor-accounts/CursorAccountsConsole.tsx +44 -400
- package/src/cursor-accounts/MemberCoverageTable.tsx +439 -0
- package/src/cursor-accounts/MemberKeysPanel.tsx +278 -0
- package/src/cursor-accounts/__tests__/CursorAccountsConsole.test.tsx +306 -10
- package/src/cursor-accounts/__tests__/cursor-account-coverage.test.ts +73 -0
- package/src/cursor-accounts/badges.tsx +26 -0
- package/src/cursor-accounts/cursor-account-coverage.ts +71 -0
- package/src/cursor-accounts/cursor-account-format.ts +17 -1
- package/src/datastore/CollectionRecordsBrowser.tsx +29 -3
- package/src/datastore/RecordFilterBuilder.tsx +12 -1
- package/src/datastore/__tests__/CollectionRecordsBrowser.test.tsx +38 -0
- package/src/datastore/__tests__/RecordFilterBuilder.test.tsx +14 -0
- package/src/datastore/__tests__/recordFilter.test.ts +13 -0
- package/src/datastore/recordFilter.ts +15 -1
- package/src/execution/ExecutionProgress.tsx +14 -4
- package/src/execution/MessageThread.tsx +10 -2
- package/src/execution/__tests__/ExecutionProgress.test.tsx +67 -0
- package/src/execution/__tests__/MessageThread.test.tsx +44 -0
- package/src/execution/__tests__/useCreateAgentExecution.test.tsx +86 -0
- package/src/execution/useCreateAgentExecution.ts +41 -6
- package/src/index.ts +32 -24
- package/src/{billing → internal}/form-primitives.tsx +4 -32
- package/src/{billing → pricing-governance}/BaselineEditor.tsx +36 -3
- package/src/{billing → pricing-governance}/PricingGovernanceConsole.tsx +1 -1
- package/src/{billing → pricing-governance}/RetireConfirm.tsx +1 -1
- package/src/pricing-governance/index.ts +50 -0
- package/src/session/__tests__/useCreateSession.test.tsx +94 -0
- package/src/session/__tests__/useNewSessionFlow.test.tsx +33 -0
- package/src/session/useCreateSession.ts +30 -0
- package/src/session/useNewSessionFlow.ts +45 -1
- package/src/session/useUpdateSession.ts +29 -10
- package/styles.css +1 -1
- package/billing/BaselineEditor.d.ts.map +0 -1
- package/billing/BaselineEditor.js.map +0 -1
- package/billing/ModelCatalogPanel.d.ts.map +0 -1
- package/billing/ModelCatalogPanel.js.map +0 -1
- package/billing/ModelGovernanceDetail.d.ts.map +0 -1
- package/billing/ModelGovernanceDetail.js.map +0 -1
- package/billing/OperatorAccessNotice.d.ts.map +0 -1
- package/billing/OperatorAccessNotice.js.map +0 -1
- package/billing/PricingGovernanceConsole.d.ts.map +0 -1
- package/billing/PricingGovernanceConsole.js.map +0 -1
- package/billing/PricingGovernancePanel.d.ts.map +0 -1
- package/billing/PricingGovernancePanel.js.map +0 -1
- package/billing/RetireConfirm.d.ts.map +0 -1
- package/billing/RetireConfirm.js.map +0 -1
- package/billing/form-primitives.d.ts +0 -18
- package/billing/form-primitives.d.ts.map +0 -1
- package/billing/form-primitives.js.map +0 -1
- package/billing/governance-primitives.d.ts.map +0 -1
- package/billing/governance-primitives.js.map +0 -1
- package/billing/pricing-format.d.ts.map +0 -1
- package/billing/pricing-format.js.map +0 -1
- package/billing/useDecidePricingOverride.d.ts.map +0 -1
- package/billing/useDecidePricingOverride.js.map +0 -1
- package/billing/useModelGovernanceView.d.ts.map +0 -1
- package/billing/useModelGovernanceView.js.map +0 -1
- package/billing/useModelPricingBaselines.d.ts.map +0 -1
- package/billing/useModelPricingBaselines.js.map +0 -1
- package/billing/usePricingGovernance.d.ts.map +0 -1
- package/billing/usePricingGovernance.js.map +0 -1
- package/billing/useRetireModelPricingBaseline.d.ts.map +0 -1
- package/billing/useRetireModelPricingBaseline.js.map +0 -1
- package/billing/useUpsertModelPricingBaseline.d.ts.map +0 -1
- package/billing/useUpsertModelPricingBaseline.js.map +0 -1
- /package/{billing → pricing-governance}/BaselineEditor.d.ts +0 -0
- /package/{billing → pricing-governance}/ModelCatalogPanel.d.ts +0 -0
- /package/{billing → pricing-governance}/ModelCatalogPanel.js +0 -0
- /package/{billing → pricing-governance}/ModelGovernanceDetail.d.ts +0 -0
- /package/{billing → pricing-governance}/ModelGovernanceDetail.js +0 -0
- /package/{billing → pricing-governance}/OperatorAccessNotice.d.ts +0 -0
- /package/{billing → pricing-governance}/OperatorAccessNotice.js +0 -0
- /package/{billing → pricing-governance}/PricingGovernanceConsole.d.ts +0 -0
- /package/{billing → pricing-governance}/PricingGovernancePanel.d.ts +0 -0
- /package/{billing → pricing-governance}/PricingGovernancePanel.js +0 -0
- /package/{billing → pricing-governance}/RetireConfirm.d.ts +0 -0
- /package/{billing → pricing-governance}/governance-primitives.d.ts +0 -0
- /package/{billing → pricing-governance}/governance-primitives.js +0 -0
- /package/{billing → pricing-governance}/pricing-format.d.ts +0 -0
- /package/{billing → pricing-governance}/pricing-format.js +0 -0
- /package/{billing → pricing-governance}/useDecidePricingOverride.d.ts +0 -0
- /package/{billing → pricing-governance}/useDecidePricingOverride.js +0 -0
- /package/{billing → pricing-governance}/useModelGovernanceView.d.ts +0 -0
- /package/{billing → pricing-governance}/useModelGovernanceView.js +0 -0
- /package/{billing → pricing-governance}/useModelPricingBaselines.d.ts +0 -0
- /package/{billing → pricing-governance}/useModelPricingBaselines.js +0 -0
- /package/{billing → pricing-governance}/usePricingGovernance.d.ts +0 -0
- /package/{billing → pricing-governance}/usePricingGovernance.js +0 -0
- /package/{billing → pricing-governance}/useRetireModelPricingBaseline.d.ts +0 -0
- /package/{billing → pricing-governance}/useRetireModelPricingBaseline.js +0 -0
- /package/{billing → pricing-governance}/useUpsertModelPricingBaseline.d.ts +0 -0
- /package/{billing → pricing-governance}/useUpsertModelPricingBaseline.js +0 -0
- /package/src/{billing → pricing-governance}/ModelCatalogPanel.tsx +0 -0
- /package/src/{billing → pricing-governance}/ModelGovernanceDetail.tsx +0 -0
- /package/src/{billing → pricing-governance}/OperatorAccessNotice.tsx +0 -0
- /package/src/{billing → pricing-governance}/PricingGovernancePanel.tsx +0 -0
- /package/src/{billing → pricing-governance}/__tests__/ModelCatalogPanel.test.tsx +0 -0
- /package/src/{billing → pricing-governance}/__tests__/PricingGovernanceConsole.test.tsx +0 -0
- /package/src/{billing → pricing-governance}/__tests__/useModelGovernanceView.test.tsx +0 -0
- /package/src/{billing → pricing-governance}/governance-primitives.tsx +0 -0
- /package/src/{billing → pricing-governance}/pricing-format.ts +0 -0
- /package/src/{billing → pricing-governance}/useDecidePricingOverride.ts +0 -0
- /package/src/{billing → pricing-governance}/useModelGovernanceView.ts +0 -0
- /package/src/{billing → pricing-governance}/useModelPricingBaselines.ts +0 -0
- /package/src/{billing → pricing-governance}/usePricingGovernance.ts +0 -0
- /package/src/{billing → pricing-governance}/useRetireModelPricingBaseline.ts +0 -0
- /package/src/{billing → pricing-governance}/useUpsertModelPricingBaseline.ts +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
import { getUserMessage } from "@stigmer/sdk";
|
|
6
|
+
import type { CursorAccountView } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
|
|
7
|
+
import { Button } from "../button/index.js";
|
|
8
|
+
import { INPUT_CLASSES } from "../internal/form-primitives.js";
|
|
9
|
+
import { toError } from "../internal/toError.js";
|
|
10
|
+
import type { CursorAccountCoverage } from "./cursor-account-coverage.js";
|
|
11
|
+
import { MemberCoverageTable } from "./MemberCoverageTable.js";
|
|
12
|
+
import type { useCursorMemberKeyActions } from "./useCursorMemberKeyActions.js";
|
|
13
|
+
|
|
14
|
+
/** One line's outcome from a bulk key import. */
|
|
15
|
+
interface ImportLineResult {
|
|
16
|
+
/** Masked identification of the key (never the full material). */
|
|
17
|
+
readonly keyPreview: string;
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
/** Bound email on success; the server/Cursor error on failure. */
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse bulk-import text: one key per line, trimmed, deduped. */
|
|
24
|
+
function parseImportKeys(text: string): string[] {
|
|
25
|
+
return [...new Set(text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== ""))];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function maskKey(key: string, index: number): string {
|
|
29
|
+
return key.length >= 8 ? `key ${index + 1} (…${key.slice(-4)})` : `key ${index + 1}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The team-coverage panel of one Cursor account: the roster-coverage
|
|
34
|
+
* table (every member and key in three explicit categories) plus the
|
|
35
|
+
* add-key and bulk-import forms.
|
|
36
|
+
*
|
|
37
|
+
* Bulk import is sequential on purpose: each key is validated live
|
|
38
|
+
* against Cursor's /v1/me, and per-key errors (wrong key class, revoked,
|
|
39
|
+
* duplicate) must be attributable to their line. Keys whose owner turns
|
|
40
|
+
* out not to be on the team are still added — the post-import sync lands
|
|
41
|
+
* them in the table's "not on the team" category with invite guidance.
|
|
42
|
+
*/
|
|
43
|
+
export function MemberKeysPanel({
|
|
44
|
+
accountId,
|
|
45
|
+
view,
|
|
46
|
+
coverage,
|
|
47
|
+
actions,
|
|
48
|
+
onChanged,
|
|
49
|
+
onImported,
|
|
50
|
+
}: {
|
|
51
|
+
readonly accountId: string;
|
|
52
|
+
readonly view: CursorAccountView;
|
|
53
|
+
/** Derived once by the parent (also feeds its header member count). */
|
|
54
|
+
readonly coverage: CursorAccountCoverage;
|
|
55
|
+
readonly actions: ReturnType<typeof useCursorMemberKeyActions>;
|
|
56
|
+
readonly onChanged: () => void;
|
|
57
|
+
/** Called after a bulk import added at least one key (triggers sync). */
|
|
58
|
+
readonly onImported: () => void;
|
|
59
|
+
}) {
|
|
60
|
+
const [newKey, setNewKey] = useState("");
|
|
61
|
+
const [newLabel, setNewLabel] = useState("");
|
|
62
|
+
const [showImport, setShowImport] = useState(false);
|
|
63
|
+
const [importText, setImportText] = useState("");
|
|
64
|
+
const [isImporting, setIsImporting] = useState(false);
|
|
65
|
+
const [importProgress, setImportProgress] = useState<
|
|
66
|
+
{ readonly done: number; readonly total: number } | null
|
|
67
|
+
>(null);
|
|
68
|
+
const [importResults, setImportResults] = useState<readonly ImportLineResult[] | null>(
|
|
69
|
+
null,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const keyViews = view.keyViews;
|
|
73
|
+
const hasRows =
|
|
74
|
+
coverage.unclassified.length > 0 ||
|
|
75
|
+
coverage.onTeamWithKey.length > 0 ||
|
|
76
|
+
coverage.onTeamWithoutKey.length > 0 ||
|
|
77
|
+
coverage.offTeamWithKey.length > 0;
|
|
78
|
+
|
|
79
|
+
const submitNewKey = (e: React.FormEvent) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
if (newKey.trim() === "" || actions.isSubmitting) return;
|
|
82
|
+
void actions
|
|
83
|
+
.addKey({ accountId, apiKey: newKey.trim(), label: newLabel.trim() || undefined })
|
|
84
|
+
.then(() => {
|
|
85
|
+
setNewKey("");
|
|
86
|
+
setNewLabel("");
|
|
87
|
+
onChanged();
|
|
88
|
+
}, () => {
|
|
89
|
+
// Surfaced via actions.error (incl. Cursor's own key-class 401 text).
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const importKeyCount = parseImportKeys(importText).length;
|
|
94
|
+
|
|
95
|
+
const submitImport = async (e: React.FormEvent) => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
const keys = parseImportKeys(importText);
|
|
98
|
+
if (keys.length === 0 || isImporting) return;
|
|
99
|
+
|
|
100
|
+
setIsImporting(true);
|
|
101
|
+
setImportResults(null);
|
|
102
|
+
|
|
103
|
+
// Sequential on purpose: each key is validated live against Cursor's
|
|
104
|
+
// /v1/me, and per-key errors (wrong key class, revoked, duplicate)
|
|
105
|
+
// must be attributable to their line.
|
|
106
|
+
const knownKeyIds = new Set(
|
|
107
|
+
keyViews.map((kv) => kv.key?.keyId).filter((id): id is string => !!id),
|
|
108
|
+
);
|
|
109
|
+
const results: ImportLineResult[] = [];
|
|
110
|
+
const failedKeys: string[] = [];
|
|
111
|
+
for (const [i, key] of keys.entries()) {
|
|
112
|
+
setImportProgress({ done: i, total: keys.length });
|
|
113
|
+
try {
|
|
114
|
+
const account = await actions.addKey({ accountId, apiKey: key });
|
|
115
|
+
const addedKey = account.memberKeys.find((mk) => !knownKeyIds.has(mk.keyId));
|
|
116
|
+
if (addedKey) knownKeyIds.add(addedKey.keyId);
|
|
117
|
+
results.push({
|
|
118
|
+
keyPreview: maskKey(key, i),
|
|
119
|
+
ok: true,
|
|
120
|
+
message: addedKey?.boundEmail
|
|
121
|
+
? `added — bound to ${addedKey.boundEmail}`
|
|
122
|
+
: "added",
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
actions.clearError();
|
|
126
|
+
failedKeys.push(key);
|
|
127
|
+
results.push({
|
|
128
|
+
keyPreview: maskKey(key, i),
|
|
129
|
+
ok: false,
|
|
130
|
+
message: getUserMessage(toError(err)),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setImportProgress(null);
|
|
136
|
+
setImportResults(results);
|
|
137
|
+
// Failed lines stay in the box so the operator can fix and retry.
|
|
138
|
+
setImportText(failedKeys.join("\n"));
|
|
139
|
+
setIsImporting(false);
|
|
140
|
+
if (failedKeys.length < keys.length) onImported();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<section className="space-y-2">
|
|
145
|
+
<h4 className="text-xs font-semibold text-foreground">Team coverage</h4>
|
|
146
|
+
|
|
147
|
+
{keyViews.length === 0 && (
|
|
148
|
+
<p className="text-xs text-muted-foreground" role="status">
|
|
149
|
+
No execution keys — this account is <strong>not routable</strong>.
|
|
150
|
+
Add a member's user-scoped API key below.
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{hasRows && (
|
|
155
|
+
<MemberCoverageTable
|
|
156
|
+
accountId={accountId}
|
|
157
|
+
coverage={coverage}
|
|
158
|
+
inviteLink={view.account?.teamInviteLink ?? ""}
|
|
159
|
+
actions={actions}
|
|
160
|
+
onChanged={onChanged}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{actions.error && (
|
|
165
|
+
<p className="text-destructive text-xs" role="alert">
|
|
166
|
+
{getUserMessage(actions.error)}
|
|
167
|
+
</p>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
<form className="flex flex-wrap items-end gap-2" onSubmit={submitNewKey}>
|
|
171
|
+
<label className="block min-w-56 flex-1 space-y-1">
|
|
172
|
+
<span className="text-[11px] font-medium text-muted-foreground">
|
|
173
|
+
User-scoped API key
|
|
174
|
+
</span>
|
|
175
|
+
<input
|
|
176
|
+
className={INPUT_CLASSES}
|
|
177
|
+
type="password"
|
|
178
|
+
value={newKey}
|
|
179
|
+
onChange={(e) => setNewKey(e.target.value)}
|
|
180
|
+
placeholder="the member's personal Cursor API key"
|
|
181
|
+
disabled={actions.isSubmitting}
|
|
182
|
+
autoComplete="off"
|
|
183
|
+
/>
|
|
184
|
+
</label>
|
|
185
|
+
<label className="block min-w-40 space-y-1">
|
|
186
|
+
<span className="text-[11px] font-medium text-muted-foreground">
|
|
187
|
+
Label (optional)
|
|
188
|
+
</span>
|
|
189
|
+
<input
|
|
190
|
+
className={INPUT_CLASSES}
|
|
191
|
+
value={newLabel}
|
|
192
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
193
|
+
placeholder="e.g. zane — prod"
|
|
194
|
+
disabled={actions.isSubmitting}
|
|
195
|
+
/>
|
|
196
|
+
</label>
|
|
197
|
+
<Button type="submit" size="sm" disabled={newKey.trim() === "" || actions.isSubmitting}>
|
|
198
|
+
{actions.isSubmitting ? "Verifying…" : "Add key"}
|
|
199
|
+
</Button>
|
|
200
|
+
<Button
|
|
201
|
+
type="button"
|
|
202
|
+
size="sm"
|
|
203
|
+
variant="outline"
|
|
204
|
+
disabled={isImporting}
|
|
205
|
+
onClick={() => {
|
|
206
|
+
setShowImport((v) => !v);
|
|
207
|
+
setImportResults(null);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{showImport ? "Hide import" : "Import keys"}
|
|
211
|
+
</Button>
|
|
212
|
+
</form>
|
|
213
|
+
|
|
214
|
+
{showImport && (
|
|
215
|
+
<form className="space-y-2 rounded-md border border-border bg-card p-3" onSubmit={submitImport}>
|
|
216
|
+
<label className="block space-y-1">
|
|
217
|
+
<span className="text-[11px] font-medium text-muted-foreground">
|
|
218
|
+
Bulk import — one user-scoped API key per line
|
|
219
|
+
</span>
|
|
220
|
+
<textarea
|
|
221
|
+
className={cn(INPUT_CLASSES, "min-h-24 font-mono")}
|
|
222
|
+
value={importText}
|
|
223
|
+
onChange={(e) => setImportText(e.target.value)}
|
|
224
|
+
placeholder={"key_...\nkey_...\nkey_..."}
|
|
225
|
+
disabled={isImporting}
|
|
226
|
+
autoComplete="off"
|
|
227
|
+
spellCheck={false}
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
<p className="text-[11px] text-muted-foreground">
|
|
231
|
+
Each key is verified against Cursor and bound to its owning team
|
|
232
|
+
member, one at a time. Lines that fail stay in the box so you can
|
|
233
|
+
fix and retry them. A roster sync runs automatically afterwards,
|
|
234
|
+
so the coverage table reflects current team membership.
|
|
235
|
+
</p>
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
237
|
+
<Button type="submit" size="sm" disabled={importKeyCount === 0 || isImporting}>
|
|
238
|
+
{isImporting && importProgress
|
|
239
|
+
? `Importing ${importProgress.done + 1} of ${importProgress.total}…`
|
|
240
|
+
: importKeyCount > 0
|
|
241
|
+
? `Import ${importKeyCount} ${importKeyCount === 1 ? "key" : "keys"}`
|
|
242
|
+
: "Import keys"}
|
|
243
|
+
</Button>
|
|
244
|
+
<Button
|
|
245
|
+
type="button"
|
|
246
|
+
size="sm"
|
|
247
|
+
variant="outline"
|
|
248
|
+
disabled={isImporting}
|
|
249
|
+
onClick={() => {
|
|
250
|
+
setShowImport(false);
|
|
251
|
+
setImportText("");
|
|
252
|
+
setImportResults(null);
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
Close
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
{importResults && (
|
|
259
|
+
<ul
|
|
260
|
+
role="list"
|
|
261
|
+
aria-label="Import results"
|
|
262
|
+
className="m-0 list-none space-y-0.5 p-0 text-[11px]"
|
|
263
|
+
>
|
|
264
|
+
{importResults.map((result) => (
|
|
265
|
+
<li
|
|
266
|
+
key={result.keyPreview}
|
|
267
|
+
className={result.ok ? "text-muted-foreground" : "text-destructive"}
|
|
268
|
+
>
|
|
269
|
+
{result.keyPreview}: {result.message}
|
|
270
|
+
</li>
|
|
271
|
+
))}
|
|
272
|
+
</ul>
|
|
273
|
+
)}
|
|
274
|
+
</form>
|
|
275
|
+
)}
|
|
276
|
+
</section>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
CursorAccountViewSchema,
|
|
18
18
|
CursorMemberKeyViewSchema,
|
|
19
19
|
CursorMemberKeyState,
|
|
20
|
+
CursorTeamMemberViewSchema,
|
|
20
21
|
} from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
|
|
21
22
|
import { StigmerContext } from "../../context";
|
|
22
23
|
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
@@ -108,6 +109,14 @@ function scenarView() {
|
|
|
108
109
|
name: "Uncovered U",
|
|
109
110
|
role: "member",
|
|
110
111
|
}),
|
|
112
|
+
// Cursor marks departed seats role:"removed" in place; they stay
|
|
113
|
+
// in the roster snapshot but never in the coverage categories.
|
|
114
|
+
create(CursorTeamMemberSchema, {
|
|
115
|
+
userId: "u3",
|
|
116
|
+
email: "departed@scenar.ai",
|
|
117
|
+
name: "Departed D",
|
|
118
|
+
role: "removed",
|
|
119
|
+
}),
|
|
111
120
|
],
|
|
112
121
|
spend: [
|
|
113
122
|
create(CursorMemberSpendSchema, {
|
|
@@ -128,6 +137,8 @@ function scenarView() {
|
|
|
128
137
|
}),
|
|
129
138
|
}),
|
|
130
139
|
],
|
|
140
|
+
// The server populates both the deprecated flat list and the
|
|
141
|
+
// spend-joined views; the console reads only the views.
|
|
131
142
|
membersWithoutKeys: [
|
|
132
143
|
create(CursorTeamMemberSchema, {
|
|
133
144
|
userId: "u2",
|
|
@@ -136,6 +147,21 @@ function scenarView() {
|
|
|
136
147
|
role: "member",
|
|
137
148
|
}),
|
|
138
149
|
],
|
|
150
|
+
membersWithoutKeysViews: [
|
|
151
|
+
create(CursorTeamMemberViewSchema, {
|
|
152
|
+
member: create(CursorTeamMemberSchema, {
|
|
153
|
+
userId: "u2",
|
|
154
|
+
email: "uncovered@scenar.ai",
|
|
155
|
+
name: "Uncovered U",
|
|
156
|
+
role: "member",
|
|
157
|
+
}),
|
|
158
|
+
spend: create(CursorMemberSpendSchema, {
|
|
159
|
+
userId: "u2",
|
|
160
|
+
email: "uncovered@scenar.ai",
|
|
161
|
+
includedSpendUsdMicros: 42000n,
|
|
162
|
+
}),
|
|
163
|
+
}),
|
|
164
|
+
],
|
|
139
165
|
});
|
|
140
166
|
}
|
|
141
167
|
|
|
@@ -153,14 +179,15 @@ describe("CursorAccountsConsole", () => {
|
|
|
153
179
|
);
|
|
154
180
|
});
|
|
155
181
|
|
|
156
|
-
it("lists accounts with routability at a glance", async () => {
|
|
182
|
+
it("lists accounts with routability and the derived pool class at a glance", async () => {
|
|
157
183
|
const notRoutable = create(CursorAccountSummarySchema, {
|
|
158
184
|
account: scenarAccount({
|
|
159
185
|
accountId: "acc-2",
|
|
160
186
|
displayName: "empty team",
|
|
161
187
|
memberKeys: [],
|
|
188
|
+
// No org assignment = shared-pool account (DD-008): the class is
|
|
189
|
+
// derived from org_ids, never from the deprecated default flag.
|
|
162
190
|
orgIds: [],
|
|
163
|
-
isPlatformDefault: true,
|
|
164
191
|
}),
|
|
165
192
|
enabledKeyCount: 0,
|
|
166
193
|
});
|
|
@@ -174,7 +201,7 @@ describe("CursorAccountsConsole", () => {
|
|
|
174
201
|
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
175
202
|
expect(screen.getByText("empty team")).toBeTruthy();
|
|
176
203
|
expect(screen.getByText("Not routable")).toBeTruthy();
|
|
177
|
-
expect(screen.getByText("
|
|
204
|
+
expect(screen.getByText("shared pool")).toBeTruthy();
|
|
178
205
|
});
|
|
179
206
|
|
|
180
207
|
it("shows the designed access notice on PERMISSION_DENIED", async () => {
|
|
@@ -193,7 +220,7 @@ describe("CursorAccountsConsole", () => {
|
|
|
193
220
|
expect(screen.queryByText(/only platform operators/)).toBeNull();
|
|
194
221
|
});
|
|
195
222
|
|
|
196
|
-
it("opens the detail view with
|
|
223
|
+
it("opens the detail view with the coverage table: covered and gap rows in their groups", async () => {
|
|
197
224
|
const client = createMockStigmer({
|
|
198
225
|
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
199
226
|
getAccountView: vi.fn().mockResolvedValue(scenarView()),
|
|
@@ -203,12 +230,198 @@ describe("CursorAccountsConsole", () => {
|
|
|
203
230
|
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
204
231
|
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
205
232
|
|
|
206
|
-
await waitFor(() =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
expect(screen.getByText(/
|
|
233
|
+
await waitFor(() =>
|
|
234
|
+
expect(screen.getByRole("table", { name: "Team coverage" })).toBeTruthy(),
|
|
235
|
+
);
|
|
236
|
+
// Category 1: on the team, key held — email, key name, spend, status.
|
|
237
|
+
expect(screen.getByText(/On the team — key held/)).toBeTruthy();
|
|
238
|
+
expect(screen.getByText("zane@scenar.ai")).toBeTruthy();
|
|
239
|
+
expect(screen.getByText("stigmer-prod")).toBeTruthy();
|
|
240
|
+
// The email is the row's identity: full value always hoverable on
|
|
241
|
+
// both row kinds (the cell wraps rather than truncates).
|
|
242
|
+
expect(screen.getByTitle("zane@scenar.ai")).toBeTruthy();
|
|
243
|
+
expect(screen.getByTitle("uncovered@scenar.ai")).toBeTruthy();
|
|
244
|
+
expect(screen.getByText("$0.17")).toBeTruthy(); // included, 169342 micro-USD
|
|
211
245
|
expect(screen.getByText("Active")).toBeTruthy();
|
|
246
|
+
// Category 2: on the team, no key — server-joined spend on the gap row.
|
|
247
|
+
expect(screen.getByText(/On the team — no execution key/)).toBeTruthy();
|
|
248
|
+
expect(screen.getByText("uncovered@scenar.ai")).toBeTruthy();
|
|
249
|
+
expect(screen.getByText("$0.04")).toBeTruthy(); // included, 42000 micro-USD
|
|
250
|
+
expect(screen.getByText("No key")).toBeTruthy();
|
|
251
|
+
// Both rows carry all four numeric columns: zero on-demand renders as
|
|
252
|
+
// a real dollar amount and zero pool percents as "0%" (matching
|
|
253
|
+
// Cursor's dashboard), never as em-dashes — those mean "no spend row".
|
|
254
|
+
expect(screen.getAllByText("$0.00")).toHaveLength(2); // on-demand × 2 rows
|
|
255
|
+
expect(screen.getAllByText("0%")).toHaveLength(4); // first-party + API × 2 rows
|
|
256
|
+
// Removed roster entries never render as coverage rows.
|
|
257
|
+
expect(screen.queryByText("departed@scenar.ai")).toBeNull();
|
|
258
|
+
// Header sync line: active members from server facts, removed seats
|
|
259
|
+
// by list arithmetic (roster entries minus active members).
|
|
260
|
+
expect(screen.getByText(/2 members · 1 removed seat/)).toBeTruthy();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("affirms full coverage instead of hiding the gap group when every active member holds a key", async () => {
|
|
264
|
+
const coveredView = create(CursorAccountViewSchema, {
|
|
265
|
+
account: scenarAccount(),
|
|
266
|
+
snapshot: create(CursorAccountSyncSnapshotSchema, {
|
|
267
|
+
accountId: "acc-1",
|
|
268
|
+
syncedAt: timestampFromDate(new Date("2026-07-22T12:00:00Z")),
|
|
269
|
+
members: [
|
|
270
|
+
create(CursorTeamMemberSchema, {
|
|
271
|
+
userId: "u1",
|
|
272
|
+
email: "zane@scenar.ai",
|
|
273
|
+
name: "Zane S",
|
|
274
|
+
role: "owner",
|
|
275
|
+
}),
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
keyViews: [
|
|
279
|
+
create(CursorMemberKeyViewSchema, {
|
|
280
|
+
key: scenarAccount().memberKeys[0],
|
|
281
|
+
state: CursorMemberKeyState.member_key_active,
|
|
282
|
+
}),
|
|
283
|
+
],
|
|
284
|
+
// A fully covered roster: the server sends no gap views.
|
|
285
|
+
});
|
|
286
|
+
const client = createMockStigmer({
|
|
287
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
288
|
+
getAccountView: vi.fn().mockResolvedValue(coveredView),
|
|
289
|
+
});
|
|
290
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
291
|
+
|
|
292
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
293
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
294
|
+
|
|
295
|
+
// The gap group renders with an affirmative answer, so "fully
|
|
296
|
+
// covered" is distinguishable from "the sync never classified".
|
|
297
|
+
await waitFor(() =>
|
|
298
|
+
expect(screen.getByText(/On the team — no execution key/)).toBeTruthy(),
|
|
299
|
+
);
|
|
300
|
+
expect(screen.getByText(/roster is fully covered/)).toBeTruthy();
|
|
301
|
+
// A member with no spend row at all renders em-dash cells.
|
|
302
|
+
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("classifies off-team keys with invite guidance; copy button only with a configured link", async () => {
|
|
306
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
307
|
+
Object.defineProperty(window.navigator, "clipboard", {
|
|
308
|
+
value: { writeText },
|
|
309
|
+
configurable: true,
|
|
310
|
+
});
|
|
311
|
+
const offTeamView = create(CursorAccountViewSchema, {
|
|
312
|
+
account: scenarAccount({
|
|
313
|
+
teamInviteLink: "https://cursor.com/team-invite/abc",
|
|
314
|
+
}),
|
|
315
|
+
snapshot: create(CursorAccountSyncSnapshotSchema, {
|
|
316
|
+
accountId: "acc-1",
|
|
317
|
+
syncedAt: timestampFromDate(new Date("2026-07-22T12:00:00Z")),
|
|
318
|
+
}),
|
|
319
|
+
keyViews: [
|
|
320
|
+
create(CursorMemberKeyViewSchema, {
|
|
321
|
+
key: create(CursorMemberKeySchema, {
|
|
322
|
+
keyId: "k-stranger",
|
|
323
|
+
apiKey: "***REDACTED***",
|
|
324
|
+
boundEmail: "stranger@else.where",
|
|
325
|
+
enabled: true,
|
|
326
|
+
}),
|
|
327
|
+
state: CursorMemberKeyState.member_key_owner_unknown,
|
|
328
|
+
}),
|
|
329
|
+
create(CursorMemberKeyViewSchema, {
|
|
330
|
+
key: create(CursorMemberKeySchema, {
|
|
331
|
+
keyId: "k-gone",
|
|
332
|
+
apiKey: "***REDACTED***",
|
|
333
|
+
boundEmail: "gone@scenar.ai",
|
|
334
|
+
enabled: true,
|
|
335
|
+
}),
|
|
336
|
+
state: CursorMemberKeyState.member_key_owner_removed,
|
|
337
|
+
}),
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
const client = createMockStigmer({
|
|
341
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
342
|
+
getAccountView: vi.fn().mockResolvedValue(offTeamView),
|
|
343
|
+
});
|
|
344
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
345
|
+
|
|
346
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
347
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
348
|
+
|
|
349
|
+
await waitFor(() =>
|
|
350
|
+
expect(screen.getByText(/Key held — not on the team/)).toBeTruthy(),
|
|
351
|
+
);
|
|
352
|
+
// The two off-team causes carry distinct labels.
|
|
353
|
+
expect(screen.getByText("Not on team")).toBeTruthy();
|
|
354
|
+
expect(screen.getByText("Left team")).toBeTruthy();
|
|
355
|
+
|
|
356
|
+
// One "Copy invite" per off-team row — a copy, never a navigation.
|
|
357
|
+
const copyButtons = screen.getAllByRole("button", { name: "Copy invite" });
|
|
358
|
+
expect(copyButtons).toHaveLength(2);
|
|
359
|
+
await userEvent.click(copyButtons[0]);
|
|
360
|
+
await waitFor(() =>
|
|
361
|
+
expect(writeText).toHaveBeenCalledWith("https://cursor.com/team-invite/abc"),
|
|
362
|
+
);
|
|
363
|
+
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("points at the Cursor dashboard when no invite link is configured", async () => {
|
|
367
|
+
const offTeamView = create(CursorAccountViewSchema, {
|
|
368
|
+
account: scenarAccount(),
|
|
369
|
+
snapshot: create(CursorAccountSyncSnapshotSchema, {
|
|
370
|
+
accountId: "acc-1",
|
|
371
|
+
syncedAt: timestampFromDate(new Date("2026-07-22T12:00:00Z")),
|
|
372
|
+
}),
|
|
373
|
+
keyViews: [
|
|
374
|
+
create(CursorMemberKeyViewSchema, {
|
|
375
|
+
key: create(CursorMemberKeySchema, {
|
|
376
|
+
keyId: "k-stranger",
|
|
377
|
+
apiKey: "***REDACTED***",
|
|
378
|
+
boundEmail: "stranger@else.where",
|
|
379
|
+
enabled: true,
|
|
380
|
+
}),
|
|
381
|
+
state: CursorMemberKeyState.member_key_owner_unknown,
|
|
382
|
+
}),
|
|
383
|
+
],
|
|
384
|
+
});
|
|
385
|
+
const client = createMockStigmer({
|
|
386
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
387
|
+
getAccountView: vi.fn().mockResolvedValue(offTeamView),
|
|
388
|
+
});
|
|
389
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
390
|
+
|
|
391
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
392
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
393
|
+
|
|
394
|
+
await waitFor(() =>
|
|
395
|
+
expect(screen.getByText(/Key held — not on the team/)).toBeTruthy(),
|
|
396
|
+
);
|
|
397
|
+
expect(screen.queryByRole("button", { name: "Copy invite" })).toBeNull();
|
|
398
|
+
expect(screen.getByText(/paste the team's invite link/)).toBeTruthy();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("withholds classification before the first sync instead of reporting keys off-team", async () => {
|
|
402
|
+
const unsyncedView = create(CursorAccountViewSchema, {
|
|
403
|
+
account: scenarAccount(),
|
|
404
|
+
keyViews: [
|
|
405
|
+
create(CursorMemberKeyViewSchema, {
|
|
406
|
+
key: scenarAccount().memberKeys[0],
|
|
407
|
+
state: CursorMemberKeyState.member_key_owner_unknown,
|
|
408
|
+
}),
|
|
409
|
+
],
|
|
410
|
+
});
|
|
411
|
+
const client = createMockStigmer({
|
|
412
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
413
|
+
getAccountView: vi.fn().mockResolvedValue(unsyncedView),
|
|
414
|
+
});
|
|
415
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
416
|
+
|
|
417
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
418
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
419
|
+
|
|
420
|
+
await waitFor(() =>
|
|
421
|
+
expect(screen.getByText(/not yet classified/)).toBeTruthy(),
|
|
422
|
+
);
|
|
423
|
+
expect(screen.getByText("Awaiting sync")).toBeTruthy();
|
|
424
|
+
expect(screen.queryByText(/Key held — not on the team/)).toBeNull();
|
|
212
425
|
});
|
|
213
426
|
|
|
214
427
|
it("adds a member key and refreshes both views", async () => {
|
|
@@ -290,9 +503,13 @@ describe("CursorAccountsConsole", () => {
|
|
|
290
503
|
"key_admin_plain",
|
|
291
504
|
);
|
|
292
505
|
await userEvent.type(
|
|
293
|
-
screen.getByLabelText(/
|
|
506
|
+
screen.getByLabelText(/Dedicated organization ids/),
|
|
294
507
|
"org-x org-y",
|
|
295
508
|
);
|
|
509
|
+
await userEvent.type(
|
|
510
|
+
screen.getByLabelText(/Team invite link/),
|
|
511
|
+
"https://cursor.com/team-invite/abc",
|
|
512
|
+
);
|
|
296
513
|
await userEvent.click(screen.getByRole("button", { name: "Create account" }));
|
|
297
514
|
|
|
298
515
|
await waitFor(() => expect(upsertAccount).toHaveBeenCalledTimes(1));
|
|
@@ -300,7 +517,39 @@ describe("CursorAccountsConsole", () => {
|
|
|
300
517
|
expect(submitted.displayName).toBe("new team");
|
|
301
518
|
expect(submitted.adminApiKey).toBe("key_admin_plain");
|
|
302
519
|
expect(submitted.orgIds).toEqual(["org-x", "org-y"]);
|
|
520
|
+
// Readable round-trip: the invite link submits as typed, no marker.
|
|
521
|
+
expect(submitted.teamInviteLink).toBe("https://cursor.com/team-invite/abc");
|
|
303
522
|
expect(submitted.enabled).toBe(true);
|
|
523
|
+
// Checkbox default checked ("on-demand enabled", Cursor's team default)
|
|
524
|
+
// negates into the proto field.
|
|
525
|
+
expect(submitted.onDemandUsageDisabled).toBe(false);
|
|
526
|
+
// The deprecated default flag is never written by current clients —
|
|
527
|
+
// the shared pool is derived from empty org_ids (DD-008).
|
|
528
|
+
expect(submitted.isPlatformDefault).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("declares on-demand usage off through the editor checkbox", async () => {
|
|
532
|
+
const upsertAccount = vi.fn().mockResolvedValue(scenarAccount());
|
|
533
|
+
const client = createMockStigmer({
|
|
534
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [] }),
|
|
535
|
+
upsertAccount,
|
|
536
|
+
});
|
|
537
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
538
|
+
|
|
539
|
+
await waitFor(() =>
|
|
540
|
+
expect(screen.getByRole("button", { name: "Add account" })).toBeTruthy(),
|
|
541
|
+
);
|
|
542
|
+
await userEvent.click(screen.getByRole("button", { name: "Add account" }));
|
|
543
|
+
|
|
544
|
+
await userEvent.type(screen.getByLabelText(/Display name/), "capped team");
|
|
545
|
+
await userEvent.type(screen.getByLabelText(/Team Admin API key/), "key_admin");
|
|
546
|
+
await userEvent.click(
|
|
547
|
+
screen.getByRole("checkbox", { name: /On-demand usage enabled/ }),
|
|
548
|
+
);
|
|
549
|
+
await userEvent.click(screen.getByRole("button", { name: "Create account" }));
|
|
550
|
+
|
|
551
|
+
await waitFor(() => expect(upsertAccount).toHaveBeenCalledTimes(1));
|
|
552
|
+
expect(upsertAccount.mock.calls[0][0].account.onDemandUsageDisabled).toBe(true);
|
|
304
553
|
});
|
|
305
554
|
|
|
306
555
|
it("bulk-imports keys one per line, keeping failed lines for retry", async () => {
|
|
@@ -362,6 +611,53 @@ describe("CursorAccountsConsole", () => {
|
|
|
362
611
|
await waitFor(() => expect(syncAccount).toHaveBeenCalledWith("acc-1"));
|
|
363
612
|
});
|
|
364
613
|
|
|
614
|
+
it("shows pool-utilization columns and the usage-guard badge from server-computed facts", async () => {
|
|
615
|
+
const guardedView = create(CursorAccountViewSchema, {
|
|
616
|
+
account: scenarAccount({ onDemandUsageDisabled: true }),
|
|
617
|
+
snapshot: create(CursorAccountSyncSnapshotSchema, {
|
|
618
|
+
accountId: "acc-1",
|
|
619
|
+
syncedAt: timestampFromDate(new Date("2026-07-22T12:00:00Z")),
|
|
620
|
+
}),
|
|
621
|
+
keyViews: [
|
|
622
|
+
create(CursorMemberKeyViewSchema, {
|
|
623
|
+
key: scenarAccount().memberKeys[0],
|
|
624
|
+
state: CursorMemberKeyState.member_key_active,
|
|
625
|
+
usageGuardTripped: true,
|
|
626
|
+
spend: create(CursorMemberSpendSchema, {
|
|
627
|
+
email: "zane@scenar.ai",
|
|
628
|
+
includedSpendUsdMicros: 169342n,
|
|
629
|
+
apiPercentUsed: 100,
|
|
630
|
+
autoPercentUsed: 5.19,
|
|
631
|
+
totalPercentUsed: 26.58,
|
|
632
|
+
}),
|
|
633
|
+
}),
|
|
634
|
+
],
|
|
635
|
+
});
|
|
636
|
+
const client = createMockStigmer({
|
|
637
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
638
|
+
getAccountView: vi.fn().mockResolvedValue(guardedView),
|
|
639
|
+
});
|
|
640
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
641
|
+
|
|
642
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
643
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
644
|
+
|
|
645
|
+
// The four numeric columns map 1:1 onto Cursor's Members page:
|
|
646
|
+
// first-party pool %, API pool %, included $, on-demand $.
|
|
647
|
+
await waitFor(() => expect(screen.getByText("100%")).toBeTruthy()); // API
|
|
648
|
+
expect(screen.getByText("5%")).toBeTruthy(); // first-party (5.19)
|
|
649
|
+
expect(screen.getByText("$0.17")).toBeTruthy(); // included, 169342 micro-USD
|
|
650
|
+
expect(screen.getByText("$0.00")).toBeTruthy(); // on-demand, none
|
|
651
|
+
// The blended totalPercentUsed (26.58) is deliberately not rendered —
|
|
652
|
+
// it maps to nothing on Cursor's dashboard and reads a flat 100 for
|
|
653
|
+
// removed members.
|
|
654
|
+
expect(screen.queryByText("27%")).toBeNull();
|
|
655
|
+
// The badge renders the server's flag — no client-side threshold math.
|
|
656
|
+
expect(screen.getByText("Usage guard")).toBeTruthy();
|
|
657
|
+
// The header carries the declaration driving the guard.
|
|
658
|
+
expect(screen.getByText(/on-demand usage off \(usage guard active\)/)).toBeTruthy();
|
|
659
|
+
});
|
|
660
|
+
|
|
365
661
|
it("keeps the stored admin key when the editor's masked value is untouched", async () => {
|
|
366
662
|
const upsertAccount = vi.fn().mockResolvedValue(scenarAccount());
|
|
367
663
|
const client = createMockStigmer({
|