cohvu 2.16.0 → 2.17.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/dist/tui/App.js CHANGED
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Direct translation of dashboard.ts into React hooks.
4
4
  import { useReducer, useRef, useEffect, useCallback } from 'react';
5
5
  import { Box, Text, useInput, useApp, useStdout } from 'ink';
6
- import { reduce, initialState, TABS, getActiveProject, getActiveTeam } from './state.js';
6
+ import { reduce, initialState, TABS, getActiveProject, getActiveTeam, isTeamLocked, filterToSameEntity } from './state.js';
7
7
  import { ApiClient } from '../api.js';
8
8
  import { runSetup } from '../setup.js';
9
9
  import { detectPlatformStatuses } from './platform-detect.js';
@@ -20,7 +20,7 @@ import { BillingTab } from './tabs/BillingTab.js';
20
20
  import { ProjectTab } from './tabs/ProjectTab.js';
21
21
  import { KeysTab } from './tabs/KeysTab.js';
22
22
  import { YouTab } from './tabs/YouTab.js';
23
- import { KNOWN_TOOL_NAMES } from './state.js';
23
+ import { ALL_KEY_ACTIONS } from './state.js';
24
24
  import { timeUntil } from './utils.js';
25
25
  import { exec, execFile } from 'child_process';
26
26
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, watch } from 'fs';
@@ -76,12 +76,6 @@ export default function App() {
76
76
  execFile(cmd, [url], () => { });
77
77
  }
78
78
  }
79
- function shouldRequireConsensus(s) {
80
- if (!s.requireConsensus)
81
- return false;
82
- const admins = s.members.filter(m => m.role === 'admin');
83
- return admins.length >= 2;
84
- }
85
79
  function copyToClipboard(text) {
86
80
  const cmd = process.platform === 'darwin' ? 'pbcopy'
87
81
  : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard';
@@ -278,6 +272,23 @@ export default function App() {
278
272
  }
279
273
  catch { }
280
274
  }, 30_000);
275
+ // Team overview powers the lock card when a team sub is canceled. It's
276
+ // cheap and always reflects the current team's shape — fetch on every
277
+ // project switch into a team project, clear when switching to personal.
278
+ const project = stateRef.current.projects.find(p => p.project_id === projectId);
279
+ if (project?.owner.kind === 'team') {
280
+ const teamId = project.owner.teamId;
281
+ api.getTeamOverview(teamId)
282
+ .then(overview => {
283
+ if (stateRef.current.activeProjectId === projectId) {
284
+ dispatch({ type: 'SET_TEAM_OVERVIEW', overview });
285
+ }
286
+ })
287
+ .catch(() => { });
288
+ }
289
+ else {
290
+ dispatch({ type: 'SET_TEAM_OVERVIEW', overview: null });
291
+ }
281
292
  setTimeout(() => loadTabData(), 0);
282
293
  }, [api, connectFeed, dispatch, loadTabData]);
283
294
  const executeSearch = useCallback(async () => {
@@ -482,6 +493,43 @@ export default function App() {
482
493
  await handleModalKey(input, key);
483
494
  return;
484
495
  }
496
+ // Team sub canceled — admins can hit `r` from any team tab to start
497
+ // resubscribe checkout. Members just see the contact list in the lock
498
+ // card; `r` does nothing for them.
499
+ if (isTeamLocked(s) && input === 'r' && s.userRole === 'admin') {
500
+ const team = getActiveTeam(s);
501
+ if (team) {
502
+ try {
503
+ showToast('Opening checkout...', 'info');
504
+ const c = await api.createTeamCheckout(team.team_id);
505
+ if (c.checkout_url)
506
+ openBrowser(c.checkout_url);
507
+ }
508
+ catch {
509
+ showToast('Failed to open checkout', 'error');
510
+ }
511
+ }
512
+ return;
513
+ }
514
+ // Pro canceled with locked personal project in view — `u` opens Pro
515
+ // checkout. One checkout unlocks every personal project at once, no
516
+ // per-project choice. Integration keys stay disabled and need manual
517
+ // restore after.
518
+ if (input === 'u' && !s.modal) {
519
+ const project = getActiveProject(s);
520
+ if (project?.owner.kind === 'personal' && project.locked) {
521
+ try {
522
+ showToast('Opening checkout...', 'info');
523
+ const c = await api.createIndividualCheckout();
524
+ if (c.checkout_url)
525
+ openBrowser(c.checkout_url);
526
+ }
527
+ catch {
528
+ showToast('Failed to open checkout', 'error');
529
+ }
530
+ return;
531
+ }
532
+ }
485
533
  // Approval keys (when approvals exist, on team/project tab, team project)
486
534
  if ((input === 'a' || input === 'x') && !s.modal && s.pendingApprovals.length > 0 && (s.tab === 'team' || s.tab === 'project')) {
487
535
  const activeProject = getActiveProject(s);
@@ -492,7 +540,7 @@ export default function App() {
492
540
  kind: 'approve-action',
493
541
  approvalId: approval.id,
494
542
  description: approval.description,
495
- initiator: approval.initiator_email,
543
+ initiator: approval.initiator_email ?? approval.initiator_name ?? 'unknown',
496
544
  expiresIn: timeUntil(approval.expires_at),
497
545
  } });
498
546
  }
@@ -567,21 +615,22 @@ export default function App() {
567
615
  }
568
616
  if (input === 'n' && s.userRole === 'admin') {
569
617
  if (!hasPaidPlan(s)) {
570
- showToast('Integration keys require a paid plan', 'error');
618
+ showToast('Keys require a paid plan', 'error');
571
619
  return;
572
620
  }
573
- const adminProjects = adminScopableProjects(s);
574
621
  dispatch({ type: 'OPEN_MODAL', modal: {
575
622
  kind: 'create-integration-key',
576
623
  step: 'name',
577
624
  input: '',
578
- projectId: adminProjects[0]?.project_id ?? '',
579
- projectSelectedIdx: 0,
580
- role: 'member',
581
- roleSelectedIdx: 1,
582
- allowedTools: [],
583
- toolsSelectedIdx: 0,
584
625
  agentName: '',
626
+ projectSelectedIdx: 0,
627
+ selectedProjectIds: new Set(),
628
+ projectScopes: new Map(),
629
+ permProjectIdx: 0,
630
+ permActionIdx: 0,
631
+ limitsField: 'expiry',
632
+ expiresInDays: '',
633
+ opsLimit: '',
585
634
  } });
586
635
  return;
587
636
  }
@@ -592,6 +641,37 @@ export default function App() {
592
641
  }
593
642
  return;
594
643
  }
644
+ // Restore a disabled integration key. Only valid when the selected key
645
+ // is disabled — otherwise `r` is a no-op here. Server rotates the
646
+ // secret and returns it once; TUI shows the "new secret" modal so the
647
+ // admin can copy it into their external systems.
648
+ if (input === 'r' && s.userRole === 'admin' && s.apiKeys.length > 0) {
649
+ const target = s.apiKeys[s.apiKeysSelected];
650
+ if (target && target.disabled_at) {
651
+ try {
652
+ dispatch({ type: 'SET_OPERATION', operation: 'Restoring key' });
653
+ const { api_key, key } = await api.restoreApiKey(target.id);
654
+ dispatch({ type: 'SET_OPERATION', operation: null });
655
+ dispatch({ type: 'OPEN_MODAL', modal: {
656
+ kind: 'key-created',
657
+ keyId: api_key.id,
658
+ keyValue: key,
659
+ keyName: api_key.name,
660
+ kind2: 'integration',
661
+ } });
662
+ // Refresh the list so the restored key moves out of the disabled section.
663
+ if (s.activeProjectId) {
664
+ const keys = await api.listIntegrationKeys(s.activeProjectId);
665
+ dispatch({ type: 'SET_API_KEYS', keys });
666
+ }
667
+ }
668
+ catch {
669
+ dispatch({ type: 'SET_OPERATION', operation: null });
670
+ showToast('Failed to restore key', 'error');
671
+ }
672
+ }
673
+ return;
674
+ }
595
675
  }
596
676
  // ---- Knowledge keys ----
597
677
  async function handleKnowledgeKey(input, key) {
@@ -746,10 +826,22 @@ export default function App() {
746
826
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-rename-team', input: '' } });
747
827
  return;
748
828
  }
749
- // 'r' key on invite link row — regen link
829
+ // 'r' key on invite link row — regen (member/viewer) or mint-new (admin).
830
+ // Admin regen is semantically the same as mint since each admin link is
831
+ // single-use; we just mint and surface instead of asking for confirmation.
750
832
  if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
751
833
  const linkIdx = sel - memberCount - 3;
752
834
  const role = linkRoles[linkIdx];
835
+ if (role === 'admin' && team) {
836
+ try {
837
+ const link = await api.mintAdminInvite(team.team_id);
838
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role: 'admin', url: link.url } });
839
+ }
840
+ catch {
841
+ showToast('Failed to mint admin invite', 'error');
842
+ }
843
+ return;
844
+ }
753
845
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
754
846
  return;
755
847
  }
@@ -781,10 +873,13 @@ export default function App() {
781
873
  }
782
874
  return;
783
875
  }
784
- // 'c' key on invite link row — copy link
876
+ // 'c' key on invite link row — copy link. Admin row has no persistent
877
+ // URL (admin invites are minted on demand) so 'c' is a no-op there.
785
878
  if (input === 'c' && s.userRole === 'admin' && onLinkRow) {
786
879
  const linkIdx = sel - memberCount - 3;
787
880
  const role = linkRoles[linkIdx];
881
+ if (role === 'admin')
882
+ return;
788
883
  const link = s.inviteLinks.find(l => l.role === role);
789
884
  if (link) {
790
885
  copyToClipboard(link.url);
@@ -795,15 +890,34 @@ export default function App() {
795
890
  }
796
891
  return;
797
892
  }
798
- // 'o' key on invite link row — open in browser
893
+ // 'o' key on invite link row — open in browser. Same as 'c', admin row
894
+ // has nothing to open since the link doesn't exist until minted.
799
895
  if (input === 'o' && s.userRole === 'admin' && onLinkRow) {
800
896
  const linkIdx = sel - memberCount - 3;
801
897
  const role = linkRoles[linkIdx];
898
+ if (role === 'admin')
899
+ return;
802
900
  const link = s.inviteLinks.find(l => l.role === role);
803
901
  if (link)
804
902
  openBrowser(link.url);
805
903
  return;
806
904
  }
905
+ // Enter on the admin invite row — mint a fresh short-lived single-use
906
+ // link and surface it via the invite-link modal (copy/open affordances).
907
+ if (key.return && s.userRole === 'admin' && onLinkRow) {
908
+ const linkIdx = sel - memberCount - 3;
909
+ const role = linkRoles[linkIdx];
910
+ if (role === 'admin' && team) {
911
+ try {
912
+ const link = await api.mintAdminInvite(team.team_id);
913
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role: 'admin', url: link.url } });
914
+ }
915
+ catch {
916
+ showToast('Failed to mint admin invite', 'error');
917
+ }
918
+ return;
919
+ }
920
+ }
807
921
  // 'd' key (admin) — delete team
808
922
  if (input === 'd' && s.userRole === 'admin') {
809
923
  if (team) {
@@ -1032,14 +1146,13 @@ export default function App() {
1032
1146
  }
1033
1147
  // Integration key creation wizard
1034
1148
  if (modal.kind === 'create-integration-key') {
1035
- const adminProjects = adminScopableProjects(s);
1149
+ // Silo rule: once one project is selected, only same-entity projects
1150
+ // are reachable. Filter here so index arithmetic in the picker stays
1151
+ // consistent with what the Modal renders.
1152
+ const adminProjects = filterToSameEntity(adminScopableProjects(s), modal.selectedProjectIds, s.user?.id ?? null);
1036
1153
  if (modal.step === 'name') {
1037
1154
  if (key.return && modal.input.trim().length > 0) {
1038
- if (adminProjects.length === 0) {
1039
- showInlineError('no projects you can scope an integration key to', 3000);
1040
- return;
1041
- }
1042
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'project' } });
1155
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'agent' } });
1043
1156
  }
1044
1157
  else if (key.backspace || key.delete) {
1045
1158
  dispatch({ type: 'OPEN_MODAL', modal: { ...modal, input: modal.input.slice(0, -1) } });
@@ -1052,75 +1165,131 @@ export default function App() {
1052
1165
  }
1053
1166
  return;
1054
1167
  }
1055
- if (modal.step === 'project') {
1056
- if (adminProjects.length === 0)
1057
- return;
1058
- if (key.upArrow) {
1059
- const i = Math.max(0, modal.projectSelectedIdx - 1);
1060
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: i, projectId: adminProjects[i].project_id } });
1168
+ if (modal.step === 'agent') {
1169
+ if (key.return) {
1170
+ if (adminProjects.length === 0) {
1171
+ showInlineError('no projects you can scope a key to', 3000);
1172
+ return;
1173
+ }
1174
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'projects' } });
1061
1175
  }
1062
- else if (key.downArrow) {
1063
- const i = Math.min(adminProjects.length - 1, modal.projectSelectedIdx + 1);
1064
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: i, projectId: adminProjects[i].project_id } });
1176
+ else if (key.backspace || key.delete) {
1177
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName.slice(0, -1) } });
1065
1178
  }
1066
- else if (key.return && modal.projectId) {
1067
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'role' } });
1179
+ else if (input === ' ') {
1180
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + ' ' } });
1181
+ }
1182
+ else if (input.length === 1 && !key.ctrl && !key.meta) {
1183
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + input } });
1068
1184
  }
1069
1185
  return;
1070
1186
  }
1071
- if (modal.step === 'role') {
1072
- const roles = ['admin', 'member', 'viewer'];
1187
+ if (modal.step === 'projects') {
1188
+ if (adminProjects.length === 0)
1189
+ return;
1073
1190
  if (key.upArrow) {
1074
- const i = Math.max(0, modal.roleSelectedIdx - 1);
1075
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, roleSelectedIdx: i, role: roles[i] } });
1191
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: Math.max(0, modal.projectSelectedIdx - 1) } });
1076
1192
  }
1077
1193
  else if (key.downArrow) {
1078
- const i = Math.min(2, modal.roleSelectedIdx + 1);
1079
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, roleSelectedIdx: i, role: roles[i] } });
1194
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectSelectedIdx: Math.min(adminProjects.length - 1, modal.projectSelectedIdx + 1) } });
1080
1195
  }
1081
- else if (key.return) {
1082
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'tools' } });
1196
+ else if (input === ' ') {
1197
+ const p = adminProjects[modal.projectSelectedIdx];
1198
+ if (p) {
1199
+ const next = new Set(modal.selectedProjectIds);
1200
+ if (next.has(p.project_id))
1201
+ next.delete(p.project_id);
1202
+ else
1203
+ next.add(p.project_id);
1204
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, selectedProjectIds: next } });
1205
+ }
1206
+ }
1207
+ else if (key.return && modal.selectedProjectIds.size > 0) {
1208
+ // Initialize default scopes for new projects (all actions on)
1209
+ const scopes = new Map(modal.projectScopes);
1210
+ for (const pid of modal.selectedProjectIds) {
1211
+ if (!scopes.has(pid)) {
1212
+ scopes.set(pid, new Set([...ALL_KEY_ACTIONS]));
1213
+ }
1214
+ }
1215
+ // Remove scopes for deselected projects
1216
+ for (const pid of scopes.keys()) {
1217
+ if (!modal.selectedProjectIds.has(pid))
1218
+ scopes.delete(pid);
1219
+ }
1220
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'permissions', projectScopes: scopes, permProjectIdx: 0, permActionIdx: 0 } });
1083
1221
  }
1084
1222
  return;
1085
1223
  }
1086
- if (modal.step === 'tools') {
1087
- if (key.upArrow) {
1088
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, toolsSelectedIdx: Math.max(0, modal.toolsSelectedIdx - 1) } });
1224
+ if (modal.step === 'permissions') {
1225
+ const selectedProjects = adminProjects.filter(p => modal.selectedProjectIds.has(p.project_id));
1226
+ const projectCount = selectedProjects.length;
1227
+ if (projectCount === 0)
1228
+ return;
1229
+ if (key.tab) {
1230
+ // tab cycles through projects
1231
+ const next = (modal.permProjectIdx + 1) % projectCount;
1232
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: next, permActionIdx: 0 } });
1233
+ }
1234
+ else if (key.upArrow) {
1235
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: Math.max(0, modal.permProjectIdx - 1), permActionIdx: 0 } });
1089
1236
  }
1090
1237
  else if (key.downArrow) {
1091
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, toolsSelectedIdx: Math.min(KNOWN_TOOL_NAMES.length - 1, modal.toolsSelectedIdx + 1) } });
1238
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permProjectIdx: Math.min(projectCount - 1, modal.permProjectIdx + 1), permActionIdx: 0 } });
1239
+ }
1240
+ else if (key.leftArrow) {
1241
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permActionIdx: Math.max(0, modal.permActionIdx - 1) } });
1242
+ }
1243
+ else if (key.rightArrow) {
1244
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, permActionIdx: Math.min(ALL_KEY_ACTIONS.length - 1, modal.permActionIdx + 1) } });
1092
1245
  }
1093
1246
  else if (input === ' ') {
1094
- const t = KNOWN_TOOL_NAMES[modal.toolsSelectedIdx];
1095
- const next = modal.allowedTools.includes(t)
1096
- ? modal.allowedTools.filter(x => x !== t)
1097
- : [...modal.allowedTools, t];
1098
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, allowedTools: next } });
1247
+ const p = selectedProjects[modal.permProjectIdx];
1248
+ if (p) {
1249
+ const action = ALL_KEY_ACTIONS[modal.permActionIdx];
1250
+ const scopes = new Map(modal.projectScopes);
1251
+ const actions = new Set(scopes.get(p.project_id) ?? []);
1252
+ if (actions.has(action))
1253
+ actions.delete(action);
1254
+ else
1255
+ actions.add(action);
1256
+ scopes.set(p.project_id, actions);
1257
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, projectScopes: scopes } });
1258
+ }
1099
1259
  }
1100
1260
  else if (key.return) {
1101
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'agent' } });
1261
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'limits' } });
1102
1262
  }
1103
1263
  return;
1104
1264
  }
1105
- if (modal.step === 'agent') {
1265
+ if (modal.step === 'limits') {
1106
1266
  if (key.return) {
1107
1267
  dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'confirm' } });
1108
1268
  }
1109
- else if (key.backspace || key.delete) {
1110
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName.slice(0, -1) } });
1269
+ else if (key.tab) {
1270
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, limitsField: modal.limitsField === 'expiry' ? 'ops' : 'expiry' } });
1111
1271
  }
1112
- else if (input === ' ') {
1113
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + ' ' } });
1272
+ else if (key.backspace || key.delete) {
1273
+ if (modal.limitsField === 'expiry') {
1274
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, expiresInDays: modal.expiresInDays.slice(0, -1) } });
1275
+ }
1276
+ else {
1277
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, opsLimit: modal.opsLimit.slice(0, -1) } });
1278
+ }
1114
1279
  }
1115
- else if (input.length === 1 && !key.ctrl && !key.meta) {
1116
- dispatch({ type: 'OPEN_MODAL', modal: { ...modal, agentName: modal.agentName + input } });
1280
+ else if (/^[0-9]$/.test(input)) {
1281
+ if (modal.limitsField === 'expiry') {
1282
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, expiresInDays: modal.expiresInDays + input } });
1283
+ }
1284
+ else {
1285
+ dispatch({ type: 'OPEN_MODAL', modal: { ...modal, opsLimit: modal.opsLimit + input } });
1286
+ }
1117
1287
  }
1118
1288
  return;
1119
1289
  }
1120
1290
  if (modal.step === 'confirm') {
1121
1291
  if (input === 'y') {
1122
1292
  await confirmModal(modal);
1123
- // confirmModal handles the modal transition (closes on error, opens key-created on success)
1124
1293
  }
1125
1294
  else if (input === 'n') {
1126
1295
  dispatch({ type: 'CLOSE_MODAL' });
@@ -1212,30 +1381,23 @@ export default function App() {
1212
1381
  return;
1213
1382
  }
1214
1383
  }
1215
- if (shouldRequireConsensus(stateRef.current) && modal.currentRole === 'admin' && newRole !== 'admin') {
1216
- const team = getActiveTeam(stateRef.current);
1217
- if (team) {
1218
- try {
1219
- await api.initiateApproval(team.team_id, 'demote_admin', `demote ${modal.targetEmail} from admin`, modal.targetUserId);
1220
- const approvals = await api.listApprovals(team.team_id);
1221
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1222
- dispatch({ type: 'CLOSE_MODAL' });
1223
- showToast('Approval requested', 'info');
1224
- }
1225
- catch {
1226
- showToast('Failed to request approval', 'error');
1227
- dispatch({ type: 'CLOSE_MODAL' });
1228
- }
1229
- return;
1230
- }
1231
- }
1232
1384
  const changeTeam = getActiveTeam(s);
1233
1385
  if (changeTeam) {
1234
1386
  try {
1235
- await api.changeTeamRole(changeTeam.team_id, modal.targetUserId, newRole);
1236
- const members = await api.listTeamMembers(changeTeam.team_id);
1237
- dispatch({ type: 'SET_MEMBERS', members });
1238
- showToast('Role updated', 'success');
1387
+ // Server decides whether consensus applies — if so, it returns
1388
+ // 202 with a pending_approval object; otherwise the role change
1389
+ // applies immediately. We don't branch client-side.
1390
+ const resp = await api.changeTeamRole(changeTeam.team_id, modal.targetUserId, newRole);
1391
+ if (resp.pending_approval) {
1392
+ const approvals = await api.listApprovals(changeTeam.team_id);
1393
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1394
+ showToast('Role change proposed — waiting for majority', 'info');
1395
+ }
1396
+ else {
1397
+ const members = await api.listTeamMembers(changeTeam.team_id);
1398
+ dispatch({ type: 'SET_MEMBERS', members });
1399
+ showToast('Role updated', 'success');
1400
+ }
1239
1401
  }
1240
1402
  catch {
1241
1403
  showToast('Failed to update role', 'error');
@@ -1283,6 +1445,26 @@ export default function App() {
1283
1445
  }
1284
1446
  else if (key.return) {
1285
1447
  const role = roles[modal.selected];
1448
+ const team = getActiveTeam(s);
1449
+ if (!team) {
1450
+ dispatch({ type: 'CLOSE_MODAL' });
1451
+ showToast('No team selected', 'error');
1452
+ return;
1453
+ }
1454
+ if (role === 'admin') {
1455
+ // Admin invites are minted on the fly — short-lived, single-use.
1456
+ // No persistent link in state.inviteLinks; each invite is fresh.
1457
+ try {
1458
+ const link = await api.mintAdminInvite(team.team_id);
1459
+ dispatch({ type: 'CLOSE_MODAL' });
1460
+ dispatch({ type: 'OPEN_MODAL', modal: { kind: 'invite-link', role, url: link.url } });
1461
+ }
1462
+ catch {
1463
+ dispatch({ type: 'CLOSE_MODAL' });
1464
+ showToast('Failed to mint admin invite', 'error');
1465
+ }
1466
+ return;
1467
+ }
1286
1468
  const link = s.inviteLinks.find(l => l.role === role);
1287
1469
  if (link) {
1288
1470
  dispatch({ type: 'CLOSE_MODAL' });
@@ -1476,16 +1658,19 @@ export default function App() {
1476
1658
  return false;
1477
1659
  const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1478
1660
  try {
1479
- dispatch({ type: 'SET_OPERATION', operation: 'Creating team' });
1480
- const team = await api.createTeam(modal.input, slug);
1481
- dispatch({ type: 'CLOSE_MODAL' });
1482
- dispatch({ type: 'OPEN_MODAL', modal: { kind: 'create-team-project', teamId: team.id, teamName: modal.input, input: '' } });
1661
+ dispatch({ type: 'SET_OPERATION', operation: 'Opening team checkout' });
1662
+ // Team creation is gated on a team subscription we hand the user
1663
+ // to Stripe checkout; the webhook creates the team + sub atomically
1664
+ // on payment. The new team appears on the next me() refresh.
1665
+ const { checkout_url } = await api.createTeam(modal.input, slug);
1666
+ if (checkout_url)
1667
+ openBrowser(checkout_url);
1483
1668
  dispatch({ type: 'SET_OPERATION', operation: null });
1484
- return true; // chained to create-team-project caller must not CLOSE_MODAL
1669
+ showToast('Complete checkout in your browser team will appear after payment', 'info');
1485
1670
  }
1486
1671
  catch {
1487
1672
  dispatch({ type: 'SET_OPERATION', operation: null });
1488
- showToast('Failed to create team', 'error');
1673
+ showToast('Failed to start team checkout', 'error');
1489
1674
  }
1490
1675
  return false;
1491
1676
  }
@@ -1512,19 +1697,23 @@ export default function App() {
1512
1697
  return false;
1513
1698
  }
1514
1699
  if (modal.kind === 'create-integration-key') {
1515
- // Only the confirm step submits — earlier steps just advance via OPEN_MODAL.
1516
1700
  if (modal.step !== 'confirm')
1517
1701
  return false;
1518
- if (!modal.input.trim() || !modal.projectId)
1702
+ if (!modal.input.trim() || modal.selectedProjectIds.size === 0)
1519
1703
  return false;
1520
1704
  try {
1521
1705
  dispatch({ type: 'SET_OPERATION', operation: 'Creating key' });
1706
+ const projects = [...modal.selectedProjectIds].map(pid => ({
1707
+ project_id: pid,
1708
+ allowed_actions: [...(modal.projectScopes.get(pid) ?? [])],
1709
+ }));
1522
1710
  const result = await api.createApiKey({
1523
1711
  kind: 'integration',
1524
1712
  name: modal.input.trim(),
1525
- projects: [{ project_id: modal.projectId, role: modal.role }],
1526
- ...(modal.allowedTools.length > 0 ? { allowed_tools: modal.allowedTools } : {}),
1713
+ projects,
1527
1714
  ...(modal.agentName.trim() ? { default_agent_name: modal.agentName.trim() } : {}),
1715
+ ...(modal.expiresInDays ? { expires_in_days: parseInt(modal.expiresInDays, 10) } : {}),
1716
+ ...(modal.opsLimit ? { ops_limit: parseInt(modal.opsLimit, 10) } : {}),
1528
1717
  });
1529
1718
  dispatch({ type: 'SET_OPERATION', operation: null });
1530
1719
  const integrationKeys = stateRef.current.userRole === 'admin' && stateRef.current.activeProjectId
@@ -1602,27 +1791,25 @@ export default function App() {
1602
1791
  case 'confirm-clear': {
1603
1792
  const project = getActiveProject(s);
1604
1793
  if (project && modal.input === project.slug) {
1605
- if (shouldRequireConsensus(s)) {
1606
- const team = getActiveTeam(s);
1607
- if (team) {
1608
- try {
1609
- await api.initiateApproval(team.team_id, 'clear_memories', `clear all contributions from "${project.slug}"`, projectId);
1794
+ try {
1795
+ dispatch({ type: 'SET_OPERATION', operation: 'Clearing contributions' });
1796
+ // Server gates this behind consensus for team projects. If the
1797
+ // response includes pending_approval, the action is queued.
1798
+ const resp = await api.clearMemories(projectId);
1799
+ if (resp.pending_approval) {
1800
+ const team = getActiveTeam(s);
1801
+ if (team) {
1610
1802
  const approvals = await api.listApprovals(team.team_id);
1611
1803
  dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1612
- showToast('Approval requested', 'info');
1613
- }
1614
- catch {
1615
- showToast('Failed to request approval', 'error');
1616
1804
  }
1617
- break;
1805
+ dispatch({ type: 'SET_OPERATION', operation: null });
1806
+ showToast('Clear proposed — waiting for majority', 'info');
1807
+ }
1808
+ else {
1809
+ dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
1810
+ dispatch({ type: 'SET_OPERATION', operation: null });
1811
+ showToast('All contributions cleared', 'success');
1618
1812
  }
1619
- }
1620
- try {
1621
- dispatch({ type: 'SET_OPERATION', operation: 'Clearing contributions' });
1622
- await api.clearMemories(projectId);
1623
- dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
1624
- dispatch({ type: 'SET_OPERATION', operation: null });
1625
- showToast('All contributions cleared', 'success');
1626
1813
  }
1627
1814
  catch {
1628
1815
  dispatch({ type: 'SET_OPERATION', operation: null });
@@ -1634,21 +1821,6 @@ export default function App() {
1634
1821
  case 'confirm-delete': {
1635
1822
  const project = getActiveProject(s);
1636
1823
  if (project && modal.input === project.slug) {
1637
- if (shouldRequireConsensus(s)) {
1638
- const team = getActiveTeam(s);
1639
- if (team) {
1640
- try {
1641
- await api.initiateApproval(team.team_id, 'delete_project', `delete project "${project.slug}"`, projectId);
1642
- const approvals = await api.listApprovals(team.team_id);
1643
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1644
- showToast('Approval requested', 'info');
1645
- }
1646
- catch {
1647
- showToast('Failed to request approval', 'error');
1648
- }
1649
- break;
1650
- }
1651
- }
1652
1824
  try {
1653
1825
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting project' });
1654
1826
  await api.deleteProject(projectId);
@@ -1791,24 +1963,16 @@ export default function App() {
1791
1963
  case 'confirm-delete-team': {
1792
1964
  const teamForDelete = getActiveTeam(s);
1793
1965
  if (teamForDelete && modal.input === modal.slug) {
1794
- if (shouldRequireConsensus(s)) {
1795
- const team = getActiveTeam(s);
1796
- if (team) {
1797
- try {
1798
- await api.initiateApproval(team.team_id, 'delete_team', `delete team "${team.name}"`);
1799
- const approvals = await api.listApprovals(team.team_id);
1800
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1801
- showToast('Approval requested', 'info');
1802
- }
1803
- catch {
1804
- showToast('Failed to request approval', 'error');
1805
- }
1806
- break;
1807
- }
1808
- }
1809
1966
  try {
1810
1967
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting team' });
1811
- await api.deleteTeam(teamForDelete.team_id);
1968
+ const resp = await api.deleteTeam(teamForDelete.team_id);
1969
+ if (resp.pending_approval) {
1970
+ const approvals = await api.listApprovals(teamForDelete.team_id);
1971
+ dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1972
+ dispatch({ type: 'SET_OPERATION', operation: null });
1973
+ showToast('Team deletion proposed — waiting for majority', 'info');
1974
+ break;
1975
+ }
1812
1976
  const me = await api.me();
1813
1977
  dispatch({ type: 'SET_USER_DATA', me });
1814
1978
  dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
@@ -1939,11 +2103,12 @@ function hasBillingBanner(state) {
1939
2103
  function deriveFlatProjectsFromMe(me) {
1940
2104
  const list = [];
1941
2105
  for (const p of me.personal_projects) {
1942
- list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, owner: { kind: 'personal' } });
2106
+ list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, locked: p.locked ?? false, owner: { kind: 'personal' } });
1943
2107
  }
1944
2108
  for (const team of me.teams) {
2109
+ const teamCanceled = team.subscription?.status === 'canceled';
1945
2110
  for (const p of team.projects) {
1946
- list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, owner: { kind: 'team', teamId: team.team_id, teamName: team.name, teamSlug: team.slug } });
2111
+ list.push({ project_id: p.project_id, slug: p.slug, name: p.name, created_at: p.created_at, locked: teamCanceled, owner: { kind: 'team', teamId: team.team_id, teamName: team.name, teamSlug: team.slug } });
1947
2112
  }
1948
2113
  }
1949
2114
  return list;