cohvu 2.16.1 → 2.18.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';
@@ -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
  }
@@ -593,6 +641,37 @@ export default function App() {
593
641
  }
594
642
  return;
595
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
+ }
596
675
  }
597
676
  // ---- Knowledge keys ----
598
677
  async function handleKnowledgeKey(input, key) {
@@ -747,10 +826,22 @@ export default function App() {
747
826
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-rename-team', input: '' } });
748
827
  return;
749
828
  }
750
- // '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.
751
832
  if (input === 'r' && s.userRole === 'admin' && onLinkRow) {
752
833
  const linkIdx = sel - memberCount - 3;
753
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
+ }
754
845
  dispatch({ type: 'OPEN_MODAL', modal: { kind: 'confirm-regen-link', role } });
755
846
  return;
756
847
  }
@@ -782,10 +873,13 @@ export default function App() {
782
873
  }
783
874
  return;
784
875
  }
785
- // '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.
786
878
  if (input === 'c' && s.userRole === 'admin' && onLinkRow) {
787
879
  const linkIdx = sel - memberCount - 3;
788
880
  const role = linkRoles[linkIdx];
881
+ if (role === 'admin')
882
+ return;
789
883
  const link = s.inviteLinks.find(l => l.role === role);
790
884
  if (link) {
791
885
  copyToClipboard(link.url);
@@ -796,15 +890,34 @@ export default function App() {
796
890
  }
797
891
  return;
798
892
  }
799
- // '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.
800
895
  if (input === 'o' && s.userRole === 'admin' && onLinkRow) {
801
896
  const linkIdx = sel - memberCount - 3;
802
897
  const role = linkRoles[linkIdx];
898
+ if (role === 'admin')
899
+ return;
803
900
  const link = s.inviteLinks.find(l => l.role === role);
804
901
  if (link)
805
902
  openBrowser(link.url);
806
903
  return;
807
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
+ }
808
921
  // 'd' key (admin) — delete team
809
922
  if (input === 'd' && s.userRole === 'admin') {
810
923
  if (team) {
@@ -1033,7 +1146,10 @@ export default function App() {
1033
1146
  }
1034
1147
  // Integration key creation wizard
1035
1148
  if (modal.kind === 'create-integration-key') {
1036
- 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);
1037
1153
  if (modal.step === 'name') {
1038
1154
  if (key.return && modal.input.trim().length > 0) {
1039
1155
  dispatch({ type: 'OPEN_MODAL', modal: { ...modal, step: 'agent' } });
@@ -1265,30 +1381,23 @@ export default function App() {
1265
1381
  return;
1266
1382
  }
1267
1383
  }
1268
- if (shouldRequireConsensus(stateRef.current) && modal.currentRole === 'admin' && newRole !== 'admin') {
1269
- const team = getActiveTeam(stateRef.current);
1270
- if (team) {
1271
- try {
1272
- await api.initiateApproval(team.team_id, 'demote_admin', `demote ${modal.targetEmail} from admin`, modal.targetUserId);
1273
- const approvals = await api.listApprovals(team.team_id);
1274
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1275
- dispatch({ type: 'CLOSE_MODAL' });
1276
- showToast('Approval requested', 'info');
1277
- }
1278
- catch {
1279
- showToast('Failed to request approval', 'error');
1280
- dispatch({ type: 'CLOSE_MODAL' });
1281
- }
1282
- return;
1283
- }
1284
- }
1285
1384
  const changeTeam = getActiveTeam(s);
1286
1385
  if (changeTeam) {
1287
1386
  try {
1288
- await api.changeTeamRole(changeTeam.team_id, modal.targetUserId, newRole);
1289
- const members = await api.listTeamMembers(changeTeam.team_id);
1290
- dispatch({ type: 'SET_MEMBERS', members });
1291
- 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
+ }
1292
1401
  }
1293
1402
  catch {
1294
1403
  showToast('Failed to update role', 'error');
@@ -1336,6 +1445,26 @@ export default function App() {
1336
1445
  }
1337
1446
  else if (key.return) {
1338
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
+ }
1339
1468
  const link = s.inviteLinks.find(l => l.role === role);
1340
1469
  if (link) {
1341
1470
  dispatch({ type: 'CLOSE_MODAL' });
@@ -1529,16 +1658,19 @@ export default function App() {
1529
1658
  return false;
1530
1659
  const slug = modal.input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1531
1660
  try {
1532
- dispatch({ type: 'SET_OPERATION', operation: 'Creating team' });
1533
- const team = await api.createTeam(modal.input, slug);
1534
- dispatch({ type: 'CLOSE_MODAL' });
1535
- 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);
1536
1668
  dispatch({ type: 'SET_OPERATION', operation: null });
1537
- 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');
1538
1670
  }
1539
1671
  catch {
1540
1672
  dispatch({ type: 'SET_OPERATION', operation: null });
1541
- showToast('Failed to create team', 'error');
1673
+ showToast('Failed to start team checkout', 'error');
1542
1674
  }
1543
1675
  return false;
1544
1676
  }
@@ -1659,27 +1791,25 @@ export default function App() {
1659
1791
  case 'confirm-clear': {
1660
1792
  const project = getActiveProject(s);
1661
1793
  if (project && modal.input === project.slug) {
1662
- if (shouldRequireConsensus(s)) {
1663
- const team = getActiveTeam(s);
1664
- if (team) {
1665
- try {
1666
- 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) {
1667
1802
  const approvals = await api.listApprovals(team.team_id);
1668
1803
  dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1669
- showToast('Approval requested', 'info');
1670
1804
  }
1671
- catch {
1672
- showToast('Failed to request approval', 'error');
1673
- }
1674
- 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');
1675
1812
  }
1676
- }
1677
- try {
1678
- dispatch({ type: 'SET_OPERATION', operation: 'Clearing contributions' });
1679
- await api.clearMemories(projectId);
1680
- dispatch({ type: 'SET_MEMORIES', memories: [], total: 0 });
1681
- dispatch({ type: 'SET_OPERATION', operation: null });
1682
- showToast('All contributions cleared', 'success');
1683
1813
  }
1684
1814
  catch {
1685
1815
  dispatch({ type: 'SET_OPERATION', operation: null });
@@ -1691,21 +1821,6 @@ export default function App() {
1691
1821
  case 'confirm-delete': {
1692
1822
  const project = getActiveProject(s);
1693
1823
  if (project && modal.input === project.slug) {
1694
- if (shouldRequireConsensus(s)) {
1695
- const team = getActiveTeam(s);
1696
- if (team) {
1697
- try {
1698
- await api.initiateApproval(team.team_id, 'delete_project', `delete project "${project.slug}"`, projectId);
1699
- const approvals = await api.listApprovals(team.team_id);
1700
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1701
- showToast('Approval requested', 'info');
1702
- }
1703
- catch {
1704
- showToast('Failed to request approval', 'error');
1705
- }
1706
- break;
1707
- }
1708
- }
1709
1824
  try {
1710
1825
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting project' });
1711
1826
  await api.deleteProject(projectId);
@@ -1848,24 +1963,16 @@ export default function App() {
1848
1963
  case 'confirm-delete-team': {
1849
1964
  const teamForDelete = getActiveTeam(s);
1850
1965
  if (teamForDelete && modal.input === modal.slug) {
1851
- if (shouldRequireConsensus(s)) {
1852
- const team = getActiveTeam(s);
1853
- if (team) {
1854
- try {
1855
- await api.initiateApproval(team.team_id, 'delete_team', `delete team "${team.name}"`);
1856
- const approvals = await api.listApprovals(team.team_id);
1857
- dispatch({ type: 'SET_PENDING_APPROVALS', approvals });
1858
- showToast('Approval requested', 'info');
1859
- }
1860
- catch {
1861
- showToast('Failed to request approval', 'error');
1862
- }
1863
- break;
1864
- }
1865
- }
1866
1966
  try {
1867
1967
  dispatch({ type: 'SET_OPERATION', operation: 'Deleting team' });
1868
- 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
+ }
1869
1976
  const me = await api.me();
1870
1977
  dispatch({ type: 'SET_USER_DATA', me });
1871
1978
  dispatch({ type: 'SWITCH_TAB', tab: 'knowledge' });
@@ -1996,11 +2103,12 @@ function hasBillingBanner(state) {
1996
2103
  function deriveFlatProjectsFromMe(me) {
1997
2104
  const list = [];
1998
2105
  for (const p of me.personal_projects) {
1999
- 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' } });
2000
2107
  }
2001
2108
  for (const team of me.teams) {
2109
+ const teamCanceled = team.subscription?.status === 'canceled';
2002
2110
  for (const p of team.projects) {
2003
- 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 } });
2004
2112
  }
2005
2113
  }
2006
2114
  return list;