diff --git a/docs/konnect-control-planes-organization-plan.md b/docs/konnect-control-planes-organization-plan.md new file mode 100644 index 0000000000..37b6ed695a --- /dev/null +++ b/docs/konnect-control-planes-organization-plan.md @@ -0,0 +1,451 @@ +# Plan: Move Konnect Projects into a Global Local Organization ("Control Planes") + +## Background & Goal + +Today the Konnect PAT is stored globally (Electron `safeStorage`, key `konnectPat`), but the +Konnect projects it produces are created with `parentId: organizationId` — i.e. they belong to a +specific organization. As a result the same PAT can be synced independently under several +organizations, which is semantically wrong. + +Goal: introduce a hardcoded local-only organization `org_konnect_${accountId}` (display name +"Control Planes"), move all Konnect projects under it, and remove the Projects/Konnect tab split +from the project navigation sidebar. + +## Feasibility Assessment + +The approach mirrors the existing Scratchpad "fake local organization" pattern and is sound. +The main risk: roughly half of the organization-scoped remote API calls are **not** guarded for +Scratchpad today (check-seats, collaborators, user-permissions, presence, event stream, +`PATCH /organizations/:id`). Scratchpad gets away with it because its entry point is hidden. +Control Planes will be reachable from the organization dropdown, so every one of these needs an +explicit guard. + +## Confirmed Decisions + +1. **konnectSync flag aggregation** — on startup, fetch features for all organizations + concurrently, OR the results, cache in localStorage. (99% of users have a single organization.) +2. **Migration trigger** — runs in the background at startup, after login and after + `syncOrganizations()`. A modal is shown only when the user must choose between organizations. +3. **Orphaned Konnect projects** (parentId not in the current account's organization list) — + ignored entirely: neither migrated nor deleted. +4. **Control Planes visibility** — shown if any organization has `konnectSync = true`; also shown + when all are false but the local database already contains Konnect projects **belonging to the + current account** (the NeDB database has no per-user isolation, so this check must be scoped by + `parentId` — see step 6). In that case the Sync button is disabled. +5. **Capabilities hidden inside Control Planes** — New Project button, invites/collaborators/ + presence, Cloud Sync / Git Sync. The project context menu and Command Palette behaviour stay + as-is. `HeaderPlanIndicator` **stays visible**: per + `docs/header-plan-indicator-data-scope.md` every value it shows (`currentPlan`, resource usage, + license usage, trial eligibility) is account-scoped and fetched with `sessionId` only — it never + takes an `organizationId`, so hiding it would just make billing info disappear for no reason. +6. **Conflict-case data** — Konnect projects under the organizations the user did *not* pick are + **deleted**, cascading to their workspaces / requests / environments / cookie jars. Leaving them + would leak them into that organization's regular project list now that the tab is gone. +7. **Multiple accounts sharing one data directory** — do nothing. Each account gets its own + `org_konnect_${accountId}` data set; no cross-account cleanup for now. +8. **No Settings "Konnect" panel** in this change — the PAT is still configured only from the gear + icon inside Control Planes. But the sync trigger **is** moved out of the sidebar, so a second + entry point can be added later without rework. +9. **The latent buffering bug in `konnect-settings-modal.tsx`'s disconnect flow is not fixed here** — + only documented (see the Phase 4 note). New code must not repeat the pattern. +10. **`konnect-last-synced-at` is carried over** from the old organization key to the Konnect + organization key during migration, so the tooltip does not regress to "Not yet synced". + +## Existing Implementation Reference + +- `SCRATCHPAD_ORGANIZATION_ID` / `isScratchpadOrganizationId` — + `packages/insomnia-data/src/models/organization.ts` +- Organization list cache — localStorage `${accountId}:spaces`, written by `syncOrganizations()` + in `packages/insomnia/src/ui/organization-utils.ts` +- Organization route loader — `packages/insomnia/src/routes/organization.tsx` +- Features loader — `packages/insomnia/src/routes/organization.$organizationId.permissions.tsx` + (Scratchpad short-circuits to `fallbackFeatures`; unknown organizations hit + `throw redirect('/organization')`) +- Storage rules — `packages/insomnia/src/common/organization-storage-rules.ts` +- Sidebar tabs — `ProjectNavigationSidebarTabId` in + `packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx`; + parent owns `${organizationId}:sidebar-tab` in + `packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx` +- Migration precedent — `packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts`, + invoked via `migrateProjectsUnderOrganization()` from + `packages/insomnia/src/routes/organization._index.tsx` + +## Steps + +### Phase 0 — Constants & helpers (blocks everything else) + +1. In `packages/insomnia-data/src/models/organization.ts` add: + - `KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_'` + - `KONNECT_ORGANIZATION_NAME = 'Control Planes'` + - `getKonnectOrganizationId(accountId)` + - `isKonnectOrganizationId(id)` + - `isLocalOrganizationId(id)` (scratchpad || konnect) — the single predicate every + "skip all remote APIs" branch should use. +2. Add `buildKonnectOrganization(accountId, { isOwner }): Organization` that constructs the fake + organization object for rendering in `OrganizationSelect`. `is_owner` must be copied from the + user's first/personal real organization rather than hardcoded — see step 18. + +### Phase 1 — Aggregated konnectSync flag (*can run in parallel with early Phase 2*) + +> **Revised:** do NOT put this inside `syncOrganizations()`. That function has three entry points, +> one of which (`organization.sync.tsx`) is driven by the `OrganizationChanged` SSE event from the +> event stream — fired on renames, membership changes and plan changes, and received by every open +> window. Binding N feature requests to it is wrong for a slow-changing permission bit. +> Note: the comment at `organization.tsx` claiming org switching also sets `asyncTaskList` is +> stale — switching calls `navigate('/organization/:id')` with no state and lands on +> `organization.$organizationId._index`. Only `getInitialEntry()` (`router.ts`) sets it. + +3. Add a **standalone** `syncKonnectSyncEnabled(sessionId, accountId, { force })` to + `packages/insomnia/src/ui/organization-utils.ts`: + - `Promise.allSettled` over `getOrganizationFeatures({ organizationId, sessionId })` for every + organization, OR the `konnectSync.enabled` values. + - Cache to localStorage `${accountId}:konnectSyncEnabled = { enabled, checkedAt }`. + - Self-deduplicating via a TTL (6h); `force` bypasses it. + - Treat failures as false but never overwrite an existing cached `true` (offline resilience). +4. Call sites: + - Add `SyncKonnectFeature` to the `AsyncTask` enum (`packages/insomnia/src/ui/utils/router.ts`), + include it in `getInitialEntry()`'s `asyncTaskList`, and add the matching branch to + `taskPromiseList` in + `packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx` + → runs once per app start. + - `packages/insomnia/src/routes/organization._index.tsx`: call it after `syncOrganizations()` + (login-redirect path, where the organization list has just been fetched). + - `packages/insomnia/src/routes/organization.sync.tsx` (SSE path): do **not** call it; the TTL + covers long-running sessions. + - `packages/insomnia/src/routes/organization.$organizationId.permissions.tsx`: merge the + **current** organization's features result back into the cache so its flag is always fresh. +5. Add a `getKonnectSyncEnabled(accountId)` helper for reading the cache. + Cost: 99% of users have a single organization → one extra request at startup, batched alongside + the three `syncOrganizations` already issues. +6. Visibility condition = `konnectSyncEnabled || hasLocalKonnectProjects`, computed in the + `organization.tsx` `clientLoader`. + `hasLocalKonnectProjects` **must be scoped to the current account** — the NeDB database has no + per-user isolation, so a second account on the same machine would otherwise make this true. + Query with an explicit `parentId` allow-list: + ``` + services.project.count({ + konnectControlPlaneId: { $ne: null }, + parentId: { $in: [getKonnectOrganizationId(accountId), ...orgIdsFrom(`${accountId}:spaces`)] }, + }) + ``` + - `getKonnectOrganizationId(accountId)` covers the post-migration state. + - The real organization ids from `${accountId}:spaces` cover the pre-migration state, so the + organization is already visible during the window before migration finishes (and, in the + conflict case, before the user has chosen). + - Everything else is another account's data (decision 3 orphans) and is deliberately excluded. +7. Sync button `isDisabled = !konnectSyncEnabled`, with a tooltip explaining why. + +### Phase 2 — Fake organization injection + route/API guards (*depends on Phase 0/1*) + +> **Key rule discovered during audit — two classes of `${accountId}:spaces` readers:** +> (a) readers going through `useOrganizationLoaderData()` get the injected fake org for free +> (`use-plan.tsx`, `invite-form.tsx`, `invite-modal.tsx`, `project-type-warning.tsx`, +> `import-export.tsx`, `project.$projectId._index.tsx`); +> (b) loaders that read `localStorage` **directly** never see it and must be fixed one by one — +> `organization.$organizationId.permissions.tsx`, `untracked-projects.tsx`, +> `git-credentials.$id.related-projects.tsx`. +> Do not assume injection in `organization.tsx` is enough. + +#### 2A — Injection & visibility + +8. `packages/insomnia/src/routes/organization.tsx` `clientLoader`: when the visibility condition + holds, prepend `buildKonnectOrganization(accountId, { isOwner })` to `organizations` (it must not + participate in name sorting), and also return `konnectSyncEnabled`. +9. `packages/insomnia/src/ui/components/project/organization-select.tsx`: render `` for + `isKonnectOrganizationId(item.id)` in both the `ListBoxItem` and the trigger `SelectValue`. + Leave "Join an organization" / "Create an organization" as-is — still valid actions. + +#### 2B — Loader / action guards (no doomed network calls) + +10. `organization.$organizationId.permissions.tsx`: replace `isScratchpadOrganizationId` with + `isLocalOrganizationId` and return `fallbackFeatures` / `fallbackBilling`. This **must** return + before the `organizations.find()` lookup, otherwise the fake org hits + `throw redirect('/organization')` and the user is bounced out. + Note: the sidebar's `konnectSyncEnabled` no longer comes from this loader — it reads the + aggregated cache from Phase 1. +11. `packages/insomnia/src/common/organization-storage-rules.ts`: return hardcoded local-only rules + for the Konnect organization (`enableCloudSync: false`, `enableLocalVault: true`, + `enableGitSync: false`, `isOverridden: false`). +12. `organization.$organizationId._index.tsx` plus `syncProjects` / `migrateProjectsUnderOrganization` + in `organization-utils.ts`: skip the Konnect organization entirely. +13. **Collaborator / member routes — all currently unguarded**, add `isLocalOrganizationId` + short-circuits returning empty results: + - `organization.$organizationId.collaborators.tsx` — `getCollaborators()` + - `organization.$organizationId.collaborators-search.tsx` — `searchCollaborators()` + - `organization.$organizationId.collaborators-check-seats.tsx` — `checkSeats()` + - `organization.$organizationId.collaborators.invites.$invitationId.tsx` — `updateInvitationRole()` + - `organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx` — `reinvite()` + - `organization.$organizationId.members.$userId.roles.tsx` — `updateUserRoles()` +14. **Project mutation routes — 6+ unguarded `updateGitProjectCount()` / team-project calls:** + - `organization.$organizationId.project.new.tsx` — `createTeamProject()` and + `reportGitProjectCount()` + - `organization.$organizationId.project.$projectId.update.tsx` — `reportGitProjectCount()` + plus `createTeamProject()` / `updateTeamProject()` + - `organization.$organizationId.project.$projectId.delete.tsx` — `reportGitProjectCount()` + Even though the New Project button is hidden (step 19) and Konnect projects are local-only, + guard the actions themselves — they are reachable via the command palette and project dropdown. +15. `packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx`: guard **both** + unguarded calls — `getRealTimeCollaborators()` and the + `new EventSource('insomnia-event-source://v1/teams//streams')`. +16. Guard the invite surface (`getOrganizationFeatures`, `getOrganizationRoles`, + `getOrganizationMemberRoles`, `getOrgUserPermissions`, `revokeInvitation`, + `deleteOrganizationMember`, `unlinkCollaborator`). Since the invite entry point is hidden + (step 18) this is defence-in-depth. +17. **Extend the existing scratchpad guards on the storage-rules / workspaces fetchers** to + `isLocalOrganizationId` — these already exist but only cover scratchpad: + - `organization.$organizationId.project._index.tsx` + - `organization.$organizationId.project.$projectId._index.tsx` + - `organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx` + - `packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx` + - `packages/insomnia/src/ui/components/modals/request-settings-modal.tsx` + - `packages/insomnia/src/ui/hooks/use-organization-features.tsx` + - the storage-rules fetcher in `organization.$organizationId.project.$projectId.tsx` + - `packages/insomnia/src/ui/components/settings/import-export.tsx` (feature fetch only) + +#### 2C — UI gating + +18. `packages/insomnia/src/routes/organization.tsx` component layer: hide `HeaderInviteButton` and + `PresentUsers`; also hide the untracked-data banner. Keep `HeaderPlanIndicator` — it is + account-scoped (see `docs/header-plan-indicator-data-scope.md`) and its loader + (`packages/insomnia/src/routes/resource.usage.tsx`) never passes an `organizationId`. + Related detail: `usePlanData` (`packages/insomnia/src/ui/hooks/use-plan.tsx`) derives + `isOwner` from `organizations.find(o => o.id === organizationId)?.is_owner`. Do **not** hardcode + `is_owner: true` in `buildKonnectOrganization` — copy it from the user's real organizations, + otherwise an enterprise member sees the wrong "upgrade yourself" CTA in + `UpgradeNotice` / `upgrade-modal`. +19. Hide `NewProjectButton` in the sidebar for the Konnect organization (detail in step 22). + +#### 2D — Direct-localStorage loaders + routing + +20. Add the Konnect organization id to the accepted-org-id lists in the loaders that read + localStorage directly: + - `packages/insomnia/src/routes/untracked-projects.tsx` — otherwise Konnect projects are + reported as untracked and the migration banner appears. + - `packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx` — same + `currentUserOrganizationIds` pattern. +21. `packages/insomnia/src/ui/utils/router.ts` `getInitialEntry()`: `lastVisitedOrganizationId` may + be the Konnect organization, which is absent from `${accountId}:spaces`. The validation must + additionally accept `isKonnectOrganizationId`, otherwise it falls back to the first real + organization on every restart. + +#### 2E — Explicitly NOT extended to the Konnect org (audit conclusions) + +- `organization.tsx` — `{!isScratchPad && }`. **Must stay scratchpad-only.** + Hiding the dropdown would trap the user inside Control Planes with no way out. +- `insomnia-tab-context.tsx` — "navigate to project dashboard when all tabs close" is skipped for + scratchpad because it has no dashboard. Control Planes *does*, so keep navigating. +- `root.tsx` deep-link guards — scratchpad is gated there because the user is logged out; Konnect + users are authenticated, so deep links should behave normally. Verify only. +- `workspace-pane-header.tsx` — breadcrumb slicing is a scratchpad single-project quirk. +- `entry.main.ts` — do **not** mirror the scratchpad "auto-create project/workspace on startup"; + Konnect projects come exclusively from sync. +- `sidebar-workspace-dropdown.tsx` / `workspace-settings-modal.tsx` scratchpad read-only + restrictions — Konnect workspaces stay normal editable collections; only the delete/remove + wording differs, which existing `konnectControlPlaneId` checks already handle. +- `import-modal.tsx` multi-file restriction and `import-export.tsx` "hide import tab" — both are + scratchpad-workspace quirks. +- `project-node.tsx` project context menu — per decision 5, Konnect keeps the current menu. +- `common/sentry.ts` `LandingPage.Scratchpad` — no new Sentry tag needed. + +### Phase 3 — Remove the sidebar tabs (*depends on Phase 2*) + +22. `project-navigation-sidebar.tsx`: + - Delete `SideBarTabList`, the `Tabs` wrapper, `ProjectNavigationSidebarTabId`, and the + `activeTab` / `setActiveTab` props. + - Replace every `isProjectTabActive` branch with `isKonnectOrganizationId(organizationId)`. + - `showKonnectSyncIntro` becomes `isKonnectOrganization && !hasKonnectPat`. + - Drop the `konnectControlPlaneId` filtering in `projectsWithPresence` and the + `nonKonnectProjects` / `konnectProjects` split. + - Do not render `NewProjectButton` inside the Konnect organization. + - Collapse the two filter states (`filterInputValue` / `konnectFilterInputValue`) into one. +23. `EmptyProjectNavigationSidebar`: render `KonnectSyncIntro` when inside the Konnect organization + without a PAT; render the gear button row when a PAT exists but there are no projects. +24. `organization.$organizationId.project.$projectId.tsx` — four separate things, not one: + - **`clientLoader` reads `${organizationId}:sidebar-tab` from localStorage** to decide the + "project not found" redirect (if the user was on the Konnect tab, redirect to another Konnect + project). Rewrite to key off the *organization* instead — fall back to another project in the + same organization. + - Remove the `useLocalStorage('${organizationId}:sidebar-tab')` state and the + `activeSidebarTab = !features.konnectSync.enabled ? 'projects' : …` derivation. + - Remove `activeTab` / `setActiveTab` from the `` props. + - **Replace `activeSidebarTab` / `setActiveSidebarTab` in `ProjectRouteContextValue` and the + Outlet context** with `isKonnectOrganization`. +25. **Outlet-context consumer that is easy to miss:** + `organization.$organizationId.project.$projectId._index.tsx` reads + `const { activeSidebarTab } = useProjectRouteContext()` and gates `FirstRequestCreation` + on `activeSidebarTab === 'projects'`. Re-gate on `!isKonnectOrganization`. +26. `use-project-navigation-sidebar-navigation.ts`: delete `setActiveTab` from the hook signature, + the `setActiveTabRef`, and the auto-switch + (`resources.project.konnectControlPlaneId != null ? 'konnect' : 'projects'`). The rest of the + hook (selection, expansion, `scrollToIndex`) is tab-independent and stays. +27. `use-sidebar-drag-and-drop.tsx`: `canDrop` only blocks cross-project moves for **remote** + projects. Konnect projects are local, so a collection workspace can be dragged between two + Konnect projects — which would orphan its `konnectServiceId` and make the next sync delete or + duplicate it. Add an explicit guard rejecting cross-project drops when either side has + `konnectControlPlaneId`. +28. `use-konnect-sync.ts` call sites: pass the Konnect organization id. `konnect/sync.ts` internals + are unchanged. +29. `konnect-settings-modal.tsx` / sync orchestration ownership: + - **Move the sync trigger out of the sidebar** so the modal no longer depends on the sidebar + being mounted, per decision 8. No Settings panel is added in this change. + - After disconnecting, if the organization becomes invisible, redirect the current route to the + first real organization. +30. **Orphaned localStorage keys** — decide explicitly: + - `${organizationId}:sidebar-tab` — fully dead. + - `${organizationId}:project-navigation-konnect-filter` — fully dead. + - `${organizationId}:konnect-last-synced-at` — the value now lives under the Konnect org id; + per decision 10 the migration copies the old value across (step 31). + Keys that stay valid: `${organizationId}:nav-expanded-projects-and-workspaces`, + `${organizationId}:local-workspace-orders`. + +### Phase 4 — One-time migration (*depends on Phase 0; modal depends on Phase 2*) + +> **Audit finding — database buffering is NOT re-entrant.** +> `database.bufferChanges()` increments a module-level `bufferChangesId`, and `flushChanges(id)` +> only acts when `bufferChangesId === id`. `db.remove()` internally calls `bufferChanges()` and then +> `flushChanges(itsOwnId)` — so wrapping `services.project.remove()` calls in an outer +> `bufferChangesIndefinitely()` is **ineffective**: the first inner remove flushes the outer buffer +> and clears `bufferingChanges`, and the outer `flushChanges(outerId)` becomes a no-op. +> The existing disconnect flow in `konnect-settings-modal.tsx` has this latent bug. +> Consequence for us: buffering works for the **`update()` (parentId rewrite)** path and should be +> used there; do not pretend it batches the **delete** path. + +31. New file `packages/insomnia/src/konnect/migrate-konnect-organization.ts`: + - `detectKonnectOrgMigration({ accountId })` — list projects with + `konnectControlPlaneId != null`, group by `parentId`, drop those already under the Konnect + org, drop those whose `parentId` is not in `${accountId}:spaces` (orphans, per decision 3), + and return `{ status: 'none' | 'auto' | 'conflict', groups }` where each group carries + `organizationId`, `organizationName`, `projectCount`, `workspaceCount`. + - `runKonnectOrgMigration({ accountId, keepOrganizationId })` — + wrap the `parentId` rewrites in `bufferChangesIndefinitely()` / `flushChanges()` in a + `try/finally`; run the deletions for non-selected groups **outside** that buffer via + `services.project.remove` (which cascades through `getWithDescendants()` to workspaces, + requests, environments, cookie jars and meta docs). + - Take `projectLock` (`~/common/project`, the same lock `syncProjects` uses) around the whole + operation so it cannot interleave with a concurrent project sync. + - Copy `${keepOrganizationId}:konnect-last-synced-at` to + `${getKonnectOrganizationId(accountId)}:konnect-last-synced-at` (decision 10). + - **Do not add a `${accountId}:konnect-org-migrated` flag.** The repo has no such convention — + `migrateProjectsUnderOrganization` relies purely on an idempotent detection predicate — and a + flag would prevent re-prompting after the user dismisses the conflict modal. +32. Trigger points — mirror **both** call sites of the existing `migrateProjectsUnderOrganization()`, + because a normal app start does *not* pass through `organization._index.tsx` + (`getInitialEntry()` jumps straight to the project route): + - `packages/insomnia/src/routes/organization._index.tsx` `clientLoader` (login-redirect path). + - the `AsyncTask.MigrateProjects` branch of + `packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx` (startup path). + `auto` runs immediately; `conflict` is surfaced to the UI and nothing is migrated until the user + chooses. + Concurrency: the tasks there run via `Promise.all`, so the migration runs alongside + `syncProjects`. **Verified safe** — `syncTeamProjects` never deletes projects; it only nulls + `remoteId` for projects matching `parentId: organizationId, remoteId: { $nin: …, $ne: null }`, + and Konnect projects have `remoteId: null`, so they are excluded. The `clientAction` also + blocks until `Promise.all` resolves, so the renderer never observes a half-migrated state. +33. New component `packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx`: + single-select list of candidate organizations (name + Konnect project count), explicit warning + that the non-selected data will be deleted, then calls `runKonnectOrgMigration`. +34. Post-migration route hygiene: `locationHistoryEntry:${organizationId}` may still point at a + Konnect project that has moved organizations. `getInitialRouteForOrganization()` already + validates that the project exists and falls back, so this should self-heal — verify rather than + code around it. + +### Phase 5 — Tests & cleanup + +35. Smoke tests: + - `packages/insomnia-smoke-test/tests/smoke/konnect.test.ts` — replace the + `sidebar-tab-konnect` / `sidebar-tab-projects` clicks with navigating to Control Planes via + the organization dropdown; replace the "tab hidden when flag off" case with "organization + absent from the dropdown when flag off". + - `packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts` — remove or + repurpose `clickProjectsTab()` / `clickKonnectTab()`. + - `packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts` toggles the `konnectSync` + flag — confirm it still passes now that the flag is aggregated, not per-org. +36. Add `packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts` covering + auto / conflict / orphan-ignored / idempotency, plus a case asserting that deleting a + non-selected group also removes its workspaces and requests. + Harness: `packages/insomnia/setup-vitest.ts` already runs + `initDatabase(mainDatabase, { inMemoryOnly: true }, true)` + `initServices(...)`. +37. Run `npm run lint`, `npm run type-check`, `npm test -w packages/insomnia`. + +## Verification + +1. Single account, single organization with existing Konnect data → no modal on startup, data + appears under Control Planes, the original organization's project list is clean. +2. Two organizations each holding Konnect data → modal on startup; after choosing one, the other + group is deleted; restarting does not prompt again. +3. All organizations report `konnectSync = false` and there is no local data → Control Planes is + absent from the dropdown. +4. All false but local Konnect data exists → organization is visible, Sync button disabled. +5. Inside Control Planes with DevTools Network open → no `features`, `storage-rule`, + `team-projects`, `collaborators`, `check-seats`, `presence` or `streams` requests are issued. +6. Inside Control Planes: reload the page, enter via deep link, delete the last project → no blank + screen and no redirect to login. Specifically confirm `permissions.tsx` does not bounce the user + back to `/organization`. +7. Switch to Control Planes and restart the app → `lastVisitedOrganizationId` restores correctly. +8. Inside Control Planes the organization dropdown is still rendered and can switch back to a real + organization (regression guard for step 2E). +9. Inside Control Planes the untracked-projects banner does not appear, and Konnect projects are + absent from the untracked list. +10. `HeaderPlanIndicator` shows the same plan/usage inside Control Planes as in a real organization. +11. Sign in as account B on the same machine while account A's Konnect data exists → Control Planes + is not visible to B on the strength of A's data alone. +12. Trigger a project create/update/delete action inside Control Planes via the command palette or + project dropdown → no `team-projects` or `PATCH /organizations/:id` request is issued. + +### Phase 3 specific + +13. Delete the currently open Konnect project while inside Control Planes → the loader falls back to + another project in the same organization, not to a real organization's project list. +14. Open an empty Konnect project → `FirstRequestCreation` does not appear. +15. Attempt to drag a collection workspace from one Konnect project into another → the drop is + rejected. Then re-sync and confirm no workspace was duplicated or deleted. +16. Connect a PAT from the gear icon → sync starts automatically (i.e. the sync trigger is still + wired after the tab removal). +17. Disconnect the PAT while inside Control Planes with the flag off → all Konnect projects are + removed, the organization disappears from the dropdown, and the app navigates to a real + organization instead of sitting on a dead route. +18. Enter a real organization → no Sync/gear buttons, no sync-progress line, no sync-result panel, + and the New Project button is present. + +### Phase 4 specific + +19. Conflict case: after choosing an organization, confirm the non-selected organization's Konnect + projects **and their workspaces, requests, environments and cookie jars** are all gone from the + database, not just the project documents. +20. Dismiss the conflict modal without choosing → nothing is migrated, and the modal appears again + on the next start (no idempotency flag suppressing it). +21. Run the migration with a large data set and confirm the sidebar does not flicker per-document + during the `parentId` rewrite (buffering is effective on the update path), and that the app is + not left permanently non-revalidating afterwards (buffer was flushed in `finally`). +22. Trigger a startup where migration and `syncProjects` run concurrently → Konnect projects are + neither deleted nor have their `remoteId` mutated. +23. After migration, restart the app → `locationHistoryEntry:` pointing at a moved project + self-heals via `getInitialRouteForOrganization()` rather than landing on a broken route. +24. After migration, the Sync button tooltip shows the previous "Last synced" timestamp rather than + "Not yet synced" (decision 10 carry-over). + +## Scope + +**Included:** the fake organization, aggregated feature flag, route/API guards, sidebar tab removal, +one-time migration with conflict resolution, tests. + +**Excluded:** the "Dev Portals" organization visible in the reference design; any backend changes; +changes to the Konnect sync algorithm itself (`konnect/sync.ts` internals). + +## Implementation Notes + +Deviations from the plan as built: + +- **Step 29** — instead of a React context, the sync trigger lives in a small module-level registry + (`packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts`, `registerKonnectSyncTrigger` / + `runKonnectSync`). This removes the `syncKonnectProjectsAndNotifyRef` prop from + `KonnectSettingsModal` entirely and no-ops when the sidebar is not mounted. +- **Step 16** — the guard sits in `HeaderInviteButton` itself (returning before + `getOrgUserPermissions`) rather than inside the invite modal, since the modal is only reachable + through that button. +- **Step 12** — the `syncProjects` guard was moved *above* `getAllTeamProjects`; the original + scratchpad guard sat after it and the `fetchTeamProjects` request had already been issued. +- **Step 32** — the startup entry point is `migrateKonnectProjectsIfUnambiguous(accountId)`, which + runs `detectKonnectOrgMigration` and only auto-migrates the unambiguous case. The conflict modal + is mounted in `organization.tsx` and calls `runKonnectOrgMigration` directly rather than going + through the `showModal` registry, because it needs to trigger a router revalidation on completion. diff --git a/packages/insomnia-data/src/models/organization.ts b/packages/insomnia-data/src/models/organization.ts index 332002b96e..95101b9bae 100644 --- a/packages/insomnia-data/src/models/organization.ts +++ b/packages/insomnia-data/src/models/organization.ts @@ -1,8 +1,34 @@ -import type { PersonalPlanType } from 'insomnia-api'; +import type { Organization, PersonalPlanType } from 'insomnia-api'; export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad'; export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID; +export const KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_'; +export const KONNECT_ORGANIZATION_NAME = 'Control Planes'; + +// The Konnect organization is local-only, but the database is not partitioned per user, so the +// account id is baked into the id to keep one account's Konnect data out of another's. +export const getKonnectOrganizationId = (accountId: string) => `${KONNECT_ORGANIZATION_ID_PREFIX}${accountId}`; +export const isKonnectOrganizationId = (organizationId: string) => + organizationId.startsWith(KONNECT_ORGANIZATION_ID_PREFIX); + +/** Organizations that exist only on this machine and must never be used for organization-scoped API calls. */ +export const isLocalOrganizationId = (organizationId: string) => + isScratchpadOrganizationId(organizationId) || isKonnectOrganizationId(organizationId); + +export const buildKonnectOrganization = (accountId: string, { isOwner }: { isOwner: boolean }): Organization => ({ + id: getKonnectOrganizationId(accountId), + name: KONNECT_ORGANIZATION_NAME, + picture: null, + owner_first_name: null, + owner_last_name: null, + owner_email: null, + total_members: 1, + total_invites: 0, + is_owner: isOwner, + can_leave: false, +}); + export const formatCurrentPlanType = (type: PersonalPlanType) => { switch (type) { case 'free': { diff --git a/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts b/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts index 497bc690f8..cd3a89a420 100644 --- a/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts +++ b/packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts @@ -15,15 +15,16 @@ export class NavigationSidebar { } // =========================================================================== - // Tab controls + // Organization switching // =========================================================================== - async clickProjectsTab(): Promise { - await this.root.getByTestId('sidebar-tab-projects').click(); + async selectOrganization(name: string): Promise { + await this.page.getByRole('button', { name: 'Organizations' }).click(); + await this.page.getByRole('option', { name }).click(); } - async clickKonnectTab(): Promise { - await this.root.getByTestId('sidebar-tab-konnect').click(); + async openControlPlanesOrganization(): Promise { + await this.selectOrganization('Control Planes'); } // =========================================================================== diff --git a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts index ae8a185790..b0dd4dbc3c 100644 --- a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts @@ -2,9 +2,10 @@ import { expect } from '@playwright/test'; import { test } from '../../playwright/test'; -test.describe('Konnect sidebar tab', () => { - test('shows intro card without a PAT, configure it, then sync', async ({ page, insomnia }) => { - await page.getByTestId('sidebar-tab-konnect').click(); +test.describe('Control Planes organization', () => { + test('shows intro card without a PAT, configure it, then sync', async ({ page }) => { + await page.getByRole('button', { name: 'Organizations' }).click(); + await page.getByRole('option', { name: 'Control Planes' }).click(); await expect.soft(page.getByText('Auto-sync your gateway service routes')).toBeVisible(); await page.getByRole('button', { name: 'Configure' }).click(); @@ -13,9 +14,8 @@ test.describe('Konnect sidebar tab', () => { await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden(); await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible(); - - await page.getByTestId('sidebar-tab-projects').click(); - await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeVisible(); + // The Konnect organization never offers manual project creation. + await expect.soft(page.getByRole('button', { name: 'Create new Project' })).toBeHidden(); }); test.describe('with konnectSync feature flag disabled', () => { @@ -31,9 +31,10 @@ test.describe('Konnect sidebar tab', () => { }); }); - test('hides the Konnect tab', async ({ page }) => { + test('hides the Control Planes organization', async ({ page }) => { await page.reload({ waitUntil: 'networkidle' }); - await expect.soft(page.getByTestId('sidebar-tab-konnect')).toBeHidden(); + await page.getByRole('button', { name: 'Organizations' }).click(); + await expect.soft(page.getByRole('option', { name: 'Control Planes' })).toBeHidden(); }); }); }); diff --git a/packages/insomnia/src/common/organization-storage-rules.ts b/packages/insomnia/src/common/organization-storage-rules.ts index 9086bac1da..a5db9ea8b2 100644 --- a/packages/insomnia/src/common/organization-storage-rules.ts +++ b/packages/insomnia/src/common/organization-storage-rules.ts @@ -18,7 +18,7 @@ export async function fetchAndCacheOrganizationStorageRule( ): Promise { invariant(organizationId, 'Organization ID is required'); - if (models.organization.isScratchpadOrganizationId(organizationId)) { + if (models.organization.isLocalOrganizationId(organizationId)) { return { enableCloudSync: false, enableLocalVault: true, diff --git a/packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts b/packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts new file mode 100644 index 0000000000..0404e78186 --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts @@ -0,0 +1,165 @@ +/** + * Tests run against the in-memory NeDB initialized by setup-vitest.ts. + * localStorage is stubbed per-test to supply the cached organization list. + */ + +import { initDatabase, models, services } from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mainDatabase } from '../../main/database.main'; +import { + detectKonnectOrgMigration, + migrateKonnectProjectsIfUnambiguous, + runKonnectOrgMigration, +} from '../migrate-konnect-organization'; + +const ACCOUNT_ID = 'acct_1'; +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const KONNECT_ORG_ID = models.organization.getKonnectOrganizationId(ACCOUNT_ID); + +function stubLocalStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }); + return store; +} + +async function createKonnectProject(parentId: string, controlPlaneId: string) { + const project = await services.project.create({ + name: `CP ${controlPlaneId}`, + parentId, + konnectControlPlaneId: controlPlaneId, + }); + await services.workspace.create({ + name: `Service of ${controlPlaneId}`, + parentId: project._id, + scope: 'collection', + konnectServiceId: `svc-${controlPlaneId}`, + }); + return project; +} + +const listKonnectProjects = () => services.project.list({ konnectControlPlaneId: { $ne: null } }); + +beforeEach(async () => { + await initDatabase(mainDatabase, { inMemoryOnly: true }, true); + stubLocalStorage({ + [`${ACCOUNT_ID}:spaces`]: JSON.stringify([ + { id: ORG_A, name: 'Org A' }, + { id: ORG_B, name: 'Org B' }, + ]), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('detectKonnectOrgMigration', () => { + it('reports nothing to do when there are no Konnect projects', async () => { + expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] }); + }); + + it('reports a single source organization as auto-migratable', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + await createKonnectProject(ORG_A, 'cp-2'); + + const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID }); + + expect(plan.status).toBe('auto'); + expect(plan.groups).toEqual([ + { organizationId: ORG_A, organizationName: 'Org A', projectCount: 2, workspaceCount: 2 }, + ]); + }); + + it('reports multiple source organizations as a conflict', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + await createKonnectProject(ORG_B, 'cp-2'); + + const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID }); + + expect(plan.status).toBe('conflict'); + expect(plan.groups.map(g => g.organizationId).sort()).toEqual([ORG_A, ORG_B]); + }); + + it('ignores Konnect projects owned by another account on the same machine', async () => { + await createKonnectProject('org_someone_else', 'cp-1'); + + expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] }); + }); + + it('ignores Konnect projects that already live under the Konnect organization', async () => { + await createKonnectProject(KONNECT_ORG_ID, 'cp-1'); + + expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] }); + }); +}); + +describe('runKonnectOrgMigration', () => { + it('re-parents the chosen organization and deletes the rest with their descendants', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + const discarded = await createKonnectProject(ORG_B, 'cp-2'); + + await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A }); + + const projects = await listKonnectProjects(); + expect(projects).toHaveLength(1); + expect(projects[0].parentId).toBe(KONNECT_ORG_ID); + expect(projects[0].konnectControlPlaneId).toBe('cp-1'); + + expect(await services.workspace.count({ parentId: discarded._id })).toBe(0); + }); + + it('leaves another account\'s Konnect projects untouched', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + await createKonnectProject('org_someone_else', 'cp-other'); + + await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A }); + + const projects = await listKonnectProjects(); + expect(projects.map(p => p.parentId).sort()).toEqual([KONNECT_ORG_ID, 'org_someone_else'].sort()); + }); + + it('carries the last-synced timestamp over to the Konnect organization', async () => { + const store = stubLocalStorage({ + [`${ACCOUNT_ID}:spaces`]: JSON.stringify([{ id: ORG_A, name: 'Org A' }]), + [`${ORG_A}:konnect-last-synced-at`]: '1700000000000', + }); + await createKonnectProject(ORG_A, 'cp-1'); + + await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A }); + + expect(store.get(`${KONNECT_ORG_ID}:konnect-last-synced-at`)).toBe('1700000000000'); + expect(store.get(`${ORG_A}:konnect-last-synced-at`)).toBeUndefined(); + }); +}); + +describe('migrateKonnectProjectsIfUnambiguous', () => { + it('migrates automatically and is idempotent on a second run', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + + expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] }); + + const afterFirstRun = await listKonnectProjects(); + expect(afterFirstRun.map(p => p.parentId)).toEqual([KONNECT_ORG_ID]); + + expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] }); + expect(await listKonnectProjects()).toHaveLength(1); + }); + + it('defers to the user when the source organization is ambiguous', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + await createKonnectProject(ORG_B, 'cp-2'); + + const plan = await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID); + + expect(plan.status).toBe('conflict'); + // Nothing moved until the user picks one. + const projects = await listKonnectProjects(); + expect(projects.map(p => p.parentId).sort()).toEqual([ORG_A, ORG_B]); + }); +}); diff --git a/packages/insomnia/src/konnect/migrate-konnect-organization.ts b/packages/insomnia/src/konnect/migrate-konnect-organization.ts new file mode 100644 index 0000000000..6bf5208e35 --- /dev/null +++ b/packages/insomnia/src/konnect/migrate-konnect-organization.ts @@ -0,0 +1,147 @@ +import type { Organization } from 'insomnia-api'; +import type { Project } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; + +import { database } from '~/common/database'; +import { projectLock } from '~/common/project'; + +export interface KonnectMigrationGroup { + organizationId: string; + organizationName: string; + projectCount: number; + workspaceCount: number; +} + +export interface KonnectMigrationPlan { + /** `auto` means a single source organization, `conflict` means the user has to choose one. */ + status: 'none' | 'auto' | 'conflict'; + groups: KonnectMigrationGroup[]; +} + +const lastSyncedAtKey = (organizationId: string) => `${organizationId}:konnect-last-synced-at`; + +function readCachedOrganizations(accountId: string): Organization[] { + try { + return JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; + } catch { + return []; + } +} + +async function listMigratableKonnectProjects(accountId: string): Promise<{ + projects: Project[]; + organizationNameById: Map; +}> { + const organizationNameById = new Map(readCachedOrganizations(accountId).map(o => [o.id, o.name])); + if (organizationNameById.size === 0) { + return { projects: [], organizationNameById }; + } + + // Konnect projects parented to an organization this account does not belong to came from another + // account sharing the same database; they are deliberately left alone. + const projects = await services.project.list({ + konnectControlPlaneId: { $ne: null }, + parentId: { $in: [...organizationNameById.keys()] }, + }); + + return { projects, organizationNameById }; +} + +export async function detectKonnectOrgMigration({ accountId }: { accountId: string }): Promise { + if (!accountId) { + return { status: 'none', groups: [] }; + } + + const { projects, organizationNameById } = await listMigratableKonnectProjects(accountId); + if (projects.length === 0) { + return { status: 'none', groups: [] }; + } + + const projectsByOrganizationId = new Map(); + for (const project of projects) { + projectsByOrganizationId.set(project.parentId, [...(projectsByOrganizationId.get(project.parentId) ?? []), project]); + } + + const groups: KonnectMigrationGroup[] = []; + for (const [organizationId, organizationProjects] of projectsByOrganizationId) { + const workspaceCounts = await Promise.all( + organizationProjects.map(project => services.workspace.count({ parentId: project._id })), + ); + groups.push({ + organizationId, + organizationName: organizationNameById.get(organizationId) ?? organizationId, + projectCount: organizationProjects.length, + workspaceCount: workspaceCounts.reduce((total, count) => total + count, 0), + }); + } + + groups.sort((a, b) => b.projectCount - a.projectCount || a.organizationName.localeCompare(b.organizationName)); + + return { status: groups.length > 1 ? 'conflict' : 'auto', groups }; +} + +/** + * Runs the migration only when there is exactly one source organization. The ambiguous case is + * surfaced to the user instead, since it means discarding one organization's Konnect data. + */ +export async function migrateKonnectProjectsIfUnambiguous(accountId: string): Promise { + const plan = await detectKonnectOrgMigration({ accountId }); + if (plan.status === 'auto') { + await runKonnectOrgMigration({ accountId, keepOrganizationId: plan.groups[0].organizationId }); + return { status: 'none', groups: [] }; + } + return plan; +} + +/** + * Re-parents the chosen organization's Konnect projects onto the account-wide Konnect organization + * and discards the rest. Idempotent: once nothing matches, `detectKonnectOrgMigration` returns + * `none` and this is never called again. + */ +export async function runKonnectOrgMigration({ + accountId, + keepOrganizationId, +}: { + accountId: string; + keepOrganizationId: string; +}): Promise { + if (!accountId) { + return; + } + const konnectOrganizationId = models.organization.getKonnectOrganizationId(accountId); + + await projectLock.lock(); + try { + const { projects } = await listMigratableKonnectProjects(accountId); + const projectsToKeep = projects.filter(p => p.parentId === keepOrganizationId); + const projectsToRemove = projects.filter(p => p.parentId !== keepOrganizationId); + + if (projectsToKeep.length > 0) { + const bufferId = await database.bufferChangesIndefinitely(); + try { + for (const project of projectsToKeep) { + await services.project.update(project, { parentId: konnectOrganizationId }); + } + } finally { + await database.flushChanges(bufferId); + } + } + + // `project.remove` buffers and flushes internally, so it must not run inside the buffer above. + for (const project of projectsToRemove) { + await services.project.remove(project); + } + + try { + const previousLastSyncedAt = localStorage.getItem(lastSyncedAtKey(keepOrganizationId)); + if (previousLastSyncedAt && !localStorage.getItem(lastSyncedAtKey(konnectOrganizationId))) { + localStorage.setItem(lastSyncedAtKey(konnectOrganizationId), previousLastSyncedAt); + } + localStorage.removeItem(lastSyncedAtKey(keepOrganizationId)); + } catch { + // A stale timestamp is cosmetic; never fail the migration over it. + } + } finally { + await projectLock.unlock(); + } +} diff --git a/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx b/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx index b54f08b136..7db2881246 100644 --- a/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx +++ b/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx @@ -35,6 +35,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const currentUserOrganizationIds = new Set([ ...organizations.map(o => o.id), models.organization.SCRATCHPAD_ORGANIZATION_ID, + models.organization.getKonnectOrganizationId(accountId), ]); const currentUserProjects = relatedProjects.filter(p => currentUserOrganizationIds.has(p.parentId)); diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx index f4419f21e3..bcbc9e5ade 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx @@ -1,5 +1,5 @@ import { checkSeats } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; import { v4 as uuidv4 } from 'uuid'; @@ -12,6 +12,10 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId } = params; + if (models.organization.isLocalOrganizationId(organizationId)) { + return { isAllowed: false }; + } + try { // Check whether the user can add a new collaborator // Use a random email to avoid hitting any existing member emails diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators-search.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators-search.tsx index e71e698f2b..cecbf3b846 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.collaborators-search.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators-search.tsx @@ -1,5 +1,5 @@ import { searchCollaborators } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; import { createFetcherLoadHook } from '~/ui/utils/router'; @@ -11,6 +11,10 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const { organizationId } = params; + if (models.organization.isLocalOrganizationId(organizationId)) { + return []; + } + try { const requestUrl = new URL(request.url); const searchParams = Object.fromEntries(requestUrl.searchParams.entries()); diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx index 5dfe4d7eb8..df1bfbb125 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx @@ -1,7 +1,8 @@ import { reinvite } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; +import { invariant } from '~/common/utils/invariant'; import { createFetcherSubmitHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.collaborators.invites.$invitationId.reinvite'; @@ -9,6 +10,11 @@ import type { Route } from './+types/organization.$organizationId.collaborators. export async function clientAction({ params }: Route.ClientActionArgs) { const { organizationId, invitationId } = params; + invariant( + !models.organization.isLocalOrganizationId(organizationId), + 'Invitations are not available for this organization', + ); + try { const user = await services.userSession.get(); const sessionId = user.id; diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.tsx index 3e4a46d0e8..1765127c79 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators.invites.$invitationId.tsx @@ -1,5 +1,5 @@ import { updateInvitationRole } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; import { invariant } from '~/common/utils/invariant'; @@ -10,6 +10,11 @@ import type { Route } from './+types/organization.$organizationId.collaborators. export async function clientAction({ request, params }: Route.ClientActionArgs) { const { organizationId, invitationId } = params; + invariant( + !models.organization.isLocalOrganizationId(organizationId), + 'Invitations are not available for this organization', + ); + const formData = await request.formData(); const roleId = formData.get('roleId'); diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators.tsx index 12bc8b5b18..83e93c2038 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.collaborators.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators.tsx @@ -1,5 +1,5 @@ import { getCollaborators } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; import { createFetcherLoadHook } from '~/ui/utils/router'; @@ -11,6 +11,10 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const { organizationId } = params; + if (models.organization.isLocalOrganizationId(organizationId)) { + return { collaborators: [], start: 0, limit: 0, length: 0, total: 0, next: '' }; + } + try { const requestUrl = new URL(request.url); const searchParams = Object.fromEntries(requestUrl.searchParams.entries()); diff --git a/packages/insomnia/src/routes/organization.$organizationId.members.$userId.roles.tsx b/packages/insomnia/src/routes/organization.$organizationId.members.$userId.roles.tsx index 6318a4a03e..769720e428 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.members.$userId.roles.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.members.$userId.roles.tsx @@ -1,5 +1,5 @@ import { updateUserRoles } from 'insomnia-api'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { href } from 'react-router'; import { invariant } from '~/common/utils/invariant'; @@ -10,6 +10,10 @@ import type { Route } from './+types/organization.$organizationId.members.$userI export async function clientAction({ request, params }: Route.ClientActionArgs) { const { organizationId, userId } = params; + if (models.organization.isLocalOrganizationId(organizationId)) { + return { error: 'Organization members are not available for this organization' }; + } + const formData = await request.formData(); const roleId = formData.get('roleId'); diff --git a/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx b/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx index 8351cdcf57..036cea475f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx @@ -2,6 +2,7 @@ import { type Billing, type FeatureList, getOrganizationFeatures, type Organizat import { models, services } from 'insomnia-data'; import { href, redirect, type ShouldRevalidateFunctionArgs } from 'react-router'; +import { mergeKonnectSyncEnabledForOrganization } from '~/ui/organization-utils'; import { createFetcherLoadHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.permissions'; @@ -28,7 +29,9 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId } = params; const { id: sessionId, accountId } = await services.userSession.get(); - if (models.organization.isScratchpadOrganizationId(organizationId)) { + // Local-only organizations have no server-side representation; must return before the lookup + // below, which would otherwise bounce the user out to /organization. + if (models.organization.isLocalOrganizationId(organizationId)) { return { featuresPromise: Promise.resolve(fallbackFeatures), billingPromise: Promise.resolve(fallbackBilling), @@ -46,7 +49,11 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const featuresResponse = getOrganizationFeatures({ organizationId, sessionId }); return { - featuresPromise: featuresResponse.then(res => res?.features || fallbackFeatures), + featuresPromise: featuresResponse.then(res => { + const features = res?.features || fallbackFeatures; + mergeKonnectSyncEnabledForOrganization(accountId, features.konnectSync?.enabled === true); + return features; + }), billingPromise: featuresResponse.then(res => res?.billing || fallbackBilling), }; } catch { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 91186d170a..5f4cc4de28 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -81,7 +81,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const Component = ({ loaderData }: Route.ComponentProps) => { const { localFiles, remoteFilesPromise } = loaderData; const { activeProject, activeProjectGitRepository, projects } = useProjectLoaderData()!; - const { activeSidebarTab } = useProjectRouteContext(); + const { isKonnectOrganization } = useProjectRouteContext(); const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string; @@ -119,7 +119,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { 'There are issues with one or more Insomnia files in this project. Use the git CLI and your local file system to resolve them and continue.'; useEffect(() => { - if (!models.organization.isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isLocalOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } @@ -365,7 +365,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
- {activeSidebarTab === 'projects' && ( + {!isKonnectOrganization && ( p.konnectControlPlaneId != null)); - if (konnectProjects.length > 0) { - return redirect( - href('/organization/:organizationId/project/:projectId', { - organizationId, - projectId: konnectProjects[0]._id, - }), - ); - } - } - } catch { - // ignore parse errors - } + // The project was deleted; stay inside the current organization rather than bouncing the user out. + const allProjects = await services.project.listByOrganizationIds(organizationId); + const [fallbackProject] = models.project.sortProjects(allProjects); + if (fallbackProject) { + return redirect( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId: fallbackProject._id, + }), + ); } return redirect(href('/organization/:organizationId', { organizationId })); } @@ -143,8 +130,7 @@ export function useProjectLoaderData() { } export interface ProjectRouteContextValue { - activeSidebarTab: ProjectNavigationSidebarTabId; - setActiveSidebarTab: Dispatch>; + isKonnectOrganization: boolean; } export function useProjectRouteContext() { @@ -181,12 +167,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { } }, [isSidebarCollapsed]); - const { features } = useOrganizationPermissions(); - const [storedSidebarTab, setActiveSidebarTab] = reactUse.useLocalStorage( - `${organizationId}:sidebar-tab`, - 'projects', - ); - const activeSidebarTab = !features.konnectSync.enabled ? 'projects' : (storedSidebarTab ?? 'projects'); + const isKonnectOrganization = models.organization.isKonnectOrganizationId(organizationId); const isScratchPad = models.project.isScratchpadProject(activeProject); const gitRepositoryId = @@ -226,11 +207,8 @@ const Component = ({ loaderData }: Route.ComponentProps) => { >
setIsNewProjectModalOpen(true)} - setActiveTab={setActiveSidebarTab} ref={navigationSidebarRef} /> {isScratchPad && } @@ -264,8 +242,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index 73497bb066..329ee3b249 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -35,6 +35,11 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const { organizationId, projectId } = params; + invariant( + storageType === 'local' || !models.organization.isLocalOrganizationId(organizationId), + 'Projects in this organization can only be stored locally', + ); + const project = await services.project.getById(projectId); invariant(project, 'Project not found'); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 2873246307..3f590baa21 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -215,7 +215,7 @@ const Component = ({ params }: Route.ComponentProps) => { const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!models.organization.isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isLocalOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index cca999e5ed..99812c8357 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -100,7 +100,7 @@ const Component = () => { const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!models.organization.isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isLocalOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx index 92e0942dce..5610971e02 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx @@ -34,6 +34,10 @@ export interface CreateProjectData { } export const reportGitProjectCount = async (organizationId: string, sessionId: string, maxRetries = 3) => { + // Local-only organizations are not known to the backend, so there is nothing to report. + if (models.organization.isLocalOrganizationId(organizationId)) { + return; + } const projects = await services.project.listByOrganizationIds(organizationId); const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); const gitProjectsCount = gitRepositoryIds.length; @@ -59,6 +63,10 @@ const createProjectImpl = async (organizationId: string, newProjectData: CreateP const user = await services.userSession.get(); const sessionId = user.id; invariant(sessionId, 'User must be logged in to create a project'); + invariant( + newProjectData.storageType === 'local' || !models.organization.isLocalOrganizationId(organizationId), + 'Only local projects can be created in this organization', + ); if (newProjectData.storageType === 'local') { const project = await services.project.create({ diff --git a/packages/insomnia/src/routes/organization._index.tsx b/packages/insomnia/src/routes/organization._index.tsx index 2973582d43..f5b9b0261e 100644 --- a/packages/insomnia/src/routes/organization._index.tsx +++ b/packages/insomnia/src/routes/organization._index.tsx @@ -3,8 +3,9 @@ import { services } from 'insomnia-data'; import { href, redirect } from 'react-router'; import { invariant } from '~/common/utils/invariant'; +import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization'; import * as session from '~/ui/account/session'; -import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncOrganizations } from '~/ui/organization-utils'; +import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncKonnectSyncEnabled, syncOrganizations } from '~/ui/organization-utils'; import type { Route } from './+types/organization._index'; @@ -18,6 +19,8 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const landingOrganizationId = organizations[0].id; await migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId); + await syncKonnectSyncEnabled(sessionId, accountId); + await migrateKonnectProjectsIfUnambiguous(accountId); const specificOrgRedirectAfterAuthorize = window.localStorage.getItem('specificOrgRedirectAfterAuthorize'); if (specificOrgRedirectAfterAuthorize && specificOrgRedirectAfterAuthorize !== '') { diff --git a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx index 55ce757542..289aa0ec25 100644 --- a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx +++ b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx @@ -3,7 +3,8 @@ import { services } from 'insomnia-data'; import { href, redirect } from 'react-router'; import { invariant } from '~/common/utils/invariant'; -import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncOrganizations, syncProjects } from '~/ui/organization-utils'; +import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization'; +import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncKonnectSyncEnabled, syncOrganizations, syncProjects } from '~/ui/organization-utils'; import { AsyncTask, createFetcherSubmitHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.sync-organizations-and-projects'; @@ -28,14 +29,27 @@ export async function clientAction({ request }: Route.ClientActionArgs) { if (asyncTaskList.includes(AsyncTask.SyncOrganization)) { invariant(sessionId, 'sessionId is required'); invariant(accountId, 'accountId is required'); - taskPromiseList.push(syncOrganizations(sessionId, accountId)); + // The Konnect flag is derived from the organization list, so it has to wait for that sync. + taskPromiseList.push( + asyncTaskList.includes(AsyncTask.SyncKonnectFeature) + ? syncOrganizations(sessionId, accountId).then(() => syncKonnectSyncEnabled(sessionId, accountId)) + : syncOrganizations(sessionId, accountId), + ); + } else if (asyncTaskList.includes(AsyncTask.SyncKonnectFeature)) { + invariant(sessionId, 'sessionId is required'); + invariant(accountId, 'accountId is required'); + taskPromiseList.push(syncKonnectSyncEnabled(sessionId, accountId)); } if (asyncTaskList.includes(AsyncTask.MigrateProjects)) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; invariant(organizations.length, 'Failed to fetch organizations.'); invariant(sessionId, 'sessionId is required'); - taskPromiseList.push(migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId)); + taskPromiseList.push( + migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId).then(() => + migrateKonnectProjectsIfUnambiguous(accountId), + ), + ); } if (asyncTaskList.includes(AsyncTask.SyncProjects)) { diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index af52ed40f4..e5720b9ec2 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -3,9 +3,11 @@ import type { Settings } from 'insomnia-data'; import { models, services } from 'insomnia-data'; import React, { useCallback, useEffect, useState } from 'react'; import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components'; -import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router'; +import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRevalidator, useRouteLoaderData } from 'react-router'; import * as reactUse from 'react-use'; +import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organization'; +import { detectKonnectOrgMigration } from '~/konnect/migrate-konnect-organization'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects'; @@ -22,6 +24,7 @@ import { Icon } from '~/ui/components/icon'; import { InsomniaLogo } from '~/ui/components/insomnia-icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { showModal } from '~/ui/components/modals'; +import { KonnectOrgMigrationModal } from '~/ui/components/modals/konnect-org-migration-modal'; import { SettingsModal, showSettingsModal } from '~/ui/components/modals/settings-modal'; import { PresentUsers } from '~/ui/components/present-users'; import { OrganizationSelect } from '~/ui/components/project/organization-select'; @@ -30,6 +33,7 @@ import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context'; import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context'; import { RunnerProvider } from '~/ui/context/app/runner-context'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; +import { getKonnectSyncEnabled } from '~/ui/organization-utils'; import type { AsyncTask } from '~/ui/utils/router'; import type { Route } from './+types/organization'; @@ -40,22 +44,53 @@ export interface OrganizationLoaderData { currentPlan?: CurrentPlan; } +/** + * The Konnect organization is local-only, so it is surfaced when the account-wide feature flag is + * on, or when this account already has Konnect data from a previous version (in which case sync is + * disabled but the data stays reachable). The database is not partitioned per user, so the + * "existing data" probe is restricted to parents this account can actually own. + */ +async function getKonnectOrganization(accountId: string, organizations: Organization[]) { + const konnectOrganizationId = models.organization.getKonnectOrganizationId(accountId); + const konnectSyncEnabled = getKonnectSyncEnabled(accountId); + + if (!konnectSyncEnabled) { + const existingKonnectProjectCount = await services.project.count({ + konnectControlPlaneId: { $ne: null }, + parentId: { $in: [konnectOrganizationId, ...organizations.map(o => o.id)] }, + }); + if (existingKonnectProjectCount === 0) { + return { konnectSyncEnabled, konnectOrganization: null }; + } + } + + return { + konnectSyncEnabled, + konnectOrganization: models.organization.buildKonnectOrganization(accountId, { + isOwner: organizations.some(o => o.is_owner === true), + }), + }; +} + export async function clientLoader(_args: Route.ClientLoaderArgs) { const { id, accountId } = await services.userSession.get(); if (id) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as User; const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; + const { konnectSyncEnabled, konnectOrganization } = await getKonnectOrganization(accountId, organizations); return { - organizations, + organizations: konnectOrganization ? [konnectOrganization, ...organizations] : organizations, user, currentPlan, + konnectSyncEnabled, }; } return { organizations: [], user: undefined, currentPlan: undefined, + konnectSyncEnabled: false, }; } @@ -176,17 +211,23 @@ const LoginUserActions = ({ user: User; currentPlan?: CurrentPlan; }) => { + // Collaboration is meaningless in an organization that only exists on this machine. + const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId); return ( <> - - + {!isLocalOrganization && ( + <> + + + + )} @@ -195,7 +236,9 @@ const LoginUserActions = ({ const Component = ({ loaderData }: Route.ComponentProps) => { const { organizations, user, currentPlan } = loaderData; - const { settings } = useRootLoaderData()!; + const { settings, userSession } = useRootLoaderData()!; + const { revalidate } = useRevalidator(); + const [konnectMigrationGroups, setKonnectMigrationGroups] = useState([]); const workspaceData = useWorkspaceLoaderData(); @@ -248,10 +291,22 @@ const Component = ({ loaderData }: Route.ComponentProps) => { return () => window.main.setCurrentOrganizationId(undefined); }, [organizationId]); + useEffect(() => { + const accountId = userSession.accountId; + if (!accountId) { + return; + } + // The unambiguous case already migrated during startup; only a genuine conflict reaches the UI. + detectKonnectOrgMigration({ accountId }).then(plan => { + setKonnectMigrationGroups(plan.status === 'conflict' ? plan.groups : []); + }); + }, [userSession.accountId]); + const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || []; const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || []; const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0; const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID; + const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId); useCloseConnection({ organizationId, @@ -390,7 +445,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { - {!isScratchpadWorkspace && hasUntrackedData && ( + {!isScratchpadWorkspace && !isLocalOrganization && hasUntrackedData && (
+ {konnectMigrationGroups.length > 0 && userSession.accountId && ( + { + setKonnectMigrationGroups([]); + revalidate(); + }} + /> + )} diff --git a/packages/insomnia/src/routes/untracked-projects.tsx b/packages/insomnia/src/routes/untracked-projects.tsx index 837dedb24d..cf60d5b471 100644 --- a/packages/insomnia/src/routes/untracked-projects.tsx +++ b/packages/insomnia/src/routes/untracked-projects.tsx @@ -14,7 +14,11 @@ export interface UntrackedProjectsLoaderData { export async function clientLoader(_args: Route.ClientLoaderArgs) { const { accountId } = await services.userSession.get(); const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; - const listOfOrganizationIds = [...organizations.map(o => o.id), models.organization.SCRATCHPAD_ORGANIZATION_ID]; + const listOfOrganizationIds = [ + ...organizations.map(o => o.id), + models.organization.SCRATCHPAD_ORGANIZATION_ID, + models.organization.getKonnectOrganizationId(accountId), + ]; const projects = await services.project.list({ parentId: { $nin: listOfOrganizationIds }, diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index bd4c60b7b3..beae30b24e 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -75,7 +75,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!models.organization.isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isLocalOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } diff --git a/packages/insomnia/src/ui/components/header-invite-button.tsx b/packages/insomnia/src/ui/components/header-invite-button.tsx index a9f2bd1d7f..271fd299ea 100644 --- a/packages/insomnia/src/ui/components/header-invite-button.tsx +++ b/packages/insomnia/src/ui/components/header-invite-button.tsx @@ -1,4 +1,5 @@ import { getOrgUserPermissions, type Permission } from 'insomnia-api'; +import { models } from 'insomnia-data'; import React, { useEffect, useState } from 'react'; import { Button, Heading, Link, Radio, RadioGroup } from 'react-aria-components'; @@ -23,6 +24,9 @@ export const HeaderInviteButton = ({ // TODO: should manage this in the scope of organization context useEffect(() => { + if (models.organization.isLocalOrganizationId(organizationId)) { + return; + } (async () => { getOrgUserPermissions({ organizationId, diff --git a/packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx b/packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx new file mode 100644 index 0000000000..a8bdeb6ce4 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { Button, Dialog, Heading, Modal, ModalOverlay, Radio, RadioGroup } from 'react-aria-components'; + +import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organization'; +import { runKonnectOrgMigration } from '~/konnect/migrate-konnect-organization'; +import { KongLogo } from '~/ui/components/kong-logo'; + +import { Icon } from '../icon'; + +export const KonnectOrgMigrationModal = ({ + accountId, + groups, + onDone, +}: { + accountId: string; + groups: KonnectMigrationGroup[]; + onDone: () => void; +}) => { + const [keepOrganizationId, setKeepOrganizationId] = useState(groups[0]?.organizationId ?? ''); + const [isMigrating, setIsMigrating] = useState(false); + + const handleConfirm = async () => { + setIsMigrating(true); + try { + await runKonnectOrgMigration({ accountId, keepOrganizationId }); + onDone(); + } finally { + setIsMigrating(false); + } + }; + + return ( + + + +
+
+ + + Choose which Konnect data to keep + +
+ +

+ Konnect data is now stored once per account in the Control Planes organization, but this + computer has Konnect data under more than one organization. Pick the one to keep — the others will be + deleted along with their collections and requests. +

+ + + {groups.map(group => ( + + {group.organizationName} + + {group.projectCount} control plane(s), {group.workspaceCount} collection(s) + + + ))} + + +
+ +
+
+
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx index de62452532..7c6ab4951e 100644 --- a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx @@ -6,17 +6,16 @@ import { database } from '~/common/database'; import { fetchKonnectOrganizationId, validatePat } from '~/konnect/api'; import { useRootLoaderData } from '~/root'; import { AnalyticsEvent } from '~/ui/analytics'; +import { runKonnectSync } from '~/ui/hooks/konnect-sync-trigger'; import { useSettingsPatcher } from '../../hooks/use-request'; import { Icon } from '../icon'; export const KonnectSettingsModal = ({ onClose, - syncKonnectProjectsAndNotifyRef, onDisconnect, }: { onClose: () => void; - syncKonnectProjectsAndNotifyRef: React.MutableRefObject<(konnectOrganizationId?: string | null) => Promise>; onDisconnect?: () => void; }) => { const { settings } = useRootLoaderData()!; @@ -74,7 +73,7 @@ export const KonnectSettingsModal = ({ } await window.main.secretStorage.setSecret('konnectPat', trimmed); patchSettings({ hasKonnectPat: true, konnectOrganizationId: orgId ?? null }); - syncKonnectProjectsAndNotifyRef.current(orgId ?? null); + runKonnectSync(orgId ?? null); onClose(); }; diff --git a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx index afd3552a04..fe459a04ef 100644 --- a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx @@ -56,7 +56,7 @@ export const RequestSettingsModal = ({ request, onHide }: ModalProps & RequestSe const workspacesFetcher = useProjectListWorkspacesLoaderFetcher(); useEffect(() => { const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data; - if (isIdleAndUninitialized && !models.organization.isScratchpadOrganizationId(organizationId)) { + if (isIdleAndUninitialized && !models.organization.isLocalOrganizationId(organizationId)) { workspacesFetcher.load({ organizationId, projectId, diff --git a/packages/insomnia/src/ui/components/project/organization-select.tsx b/packages/insomnia/src/ui/components/project/organization-select.tsx index fdfcb282ad..43ef7f6992 100644 --- a/packages/insomnia/src/ui/components/project/organization-select.tsx +++ b/packages/insomnia/src/ui/components/project/organization-select.tsx @@ -1,9 +1,11 @@ import type { CurrentPlan, Organization } from 'insomnia-api'; +import { models } from 'insomnia-data'; import { Fragment, useEffect, useState } from 'react'; import { Button, Input, ListBox, ListBoxItem, Popover, SearchField, Select, SelectValue } from 'react-aria-components'; import { getAppWebsiteBaseURL } from '~/common/constants'; import { getLoginUrl } from '~/ui/auth-session-provider.client'; +import { KongLogo } from '~/ui/components/kong-logo'; import { showModal } from '~/ui/components/modals'; import { AlertModal } from '~/ui/components/modals/alert-modal'; @@ -49,6 +51,7 @@ export const OrganizationSelect = ({ value={organizationId} > + ) : ( + !isScratchPad && )} - {!isProjectTabActive && syncing && ( + {isKonnectOrganization && syncing && (

{progress}

)} @@ -1387,7 +1319,7 @@ const ProjectNavigationSidebarInner = ( /> )} - {!isProjectTabActive && lastSyncResult && ( + {isKonnectOrganization && lastSyncResult && (
setShowKonnectConfigModal(false)} - syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef} onDisconnect={() => setLastSyncedAt(null)} /> )} @@ -1536,19 +1467,37 @@ export const ProjectNavigationSidebar = forwardRef void }) => { const { organizationId } = useParams() as { organizationId: string }; + const { settings } = useRootLoaderData()!; const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId); - const { features } = useOrganizationPermissions(); + const isKonnectOrganization = models.organization.isKonnectOrganizationId(organizationId); + const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false); + + if (isKonnectOrganization) { + return ( +
+ {!settings.hasKonnectPat ? ( + setShowKonnectConfigModal(true)} /> + ) : ( +
+ {}} /> + +
+ )} + {showKonnectConfigModal && ( + setShowKonnectConfigModal(false)} /> + )} +
+ ); + } return (
- - -
{}} /> {!isScratchPad && } diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts index 6e31f27745..480f1241c1 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts @@ -1,6 +1,5 @@ import type { Virtualizer } from '@tanstack/react-virtual'; import { database, models, type Workspace } from 'insomnia-data'; -import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { NavigationResources } from '~/ui/hooks/use-insomnia-navigation'; @@ -25,13 +24,11 @@ const getSelectedItemId = (resources?: NavigationResources) => { }; export const useProjectNavigationSidebarNavigation = ({ - setActiveTab, toggleRequestGroups, expandProjectOrWorkspaces, visibleFlatItems, virtualizer, }: { - setActiveTab: Dispatch>; toggleRequestGroups: (requestGroupIds: string[], workspace: Workspace, collapsed?: boolean) => Promise; expandProjectOrWorkspaces: (ids: string[]) => void; visibleFlatItems: FlatItem[]; @@ -42,10 +39,8 @@ export const useProjectNavigationSidebarNavigation = ({ const [selectedItemId, setSelectedItemId] = useState(null); const lastHandledScrollKeyRef = useRef(null); - const setActiveTabRef = useRef(setActiveTab); const toggleRequestGroupsRef = useRef(toggleRequestGroups); const expandProjectOrWorkspacesRef = useRef(expandProjectOrWorkspaces); - setActiveTabRef.current = setActiveTab; toggleRequestGroupsRef.current = toggleRequestGroups; expandProjectOrWorkspacesRef.current = expandProjectOrWorkspaces; @@ -69,9 +64,6 @@ export const useProjectNavigationSidebarNavigation = ({ return; } - // update active tab - setActiveTabRef.current(resources.project.konnectControlPlaneId != null ? 'konnect' : 'projects'); - const idsToExpand = [resources.project._id]; if (resources.workspace && models.workspace.isCollection(resources.workspace)) { idsToExpand.push(resources.workspace._id); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx index 028b19d2e1..67e5f75d54 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx @@ -60,6 +60,15 @@ function canDrop( const dragInCloud = models.project.isRemoteProject(dragItem.project); if (dragItem.kind === 'workspace') { const dragWorkspaceScope = dragItem.doc.scope; + // Konnect workspaces are keyed to their project's control plane, so moving one across projects + // would orphan it and make the next sync delete or duplicate it. + const dropProject = realDropItem.kind === 'project' ? realDropItem.doc : realDropItem.project; + const dropToAnotherKonnectProject = + dragItem.project._id !== dropProject._id && + (dragItem.project.konnectControlPlaneId != null || dropProject.konnectControlPlaneId != null); + if (dropToAnotherKonnectProject) { + return false; + } if (realDropItem) { if (realDropItem.kind === 'project') { const dropToAnotherProject = dragItem.project._id !== realDropItem.doc._id; diff --git a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx index f1a9b942ad..2fad0b288a 100644 --- a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx +++ b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx @@ -1,4 +1,5 @@ import { getRealTimeCollaborators, type Organization, type UserPresence } from 'insomnia-api'; +import { models } from 'insomnia-data'; import React, { createContext, type FC, type PropsWithChildren, useContext, useEffect, useState } from 'react'; import { useFetchers, useParams, useRevalidator } from 'react-router'; import * as reactUse from 'react-use'; @@ -111,7 +112,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) useEffect(() => { async function updatePresence() { const sessionId = userSession.id; - if (sessionId && remoteId) { + if (sessionId && remoteId && !models.organization.isLocalOrganizationId(organizationId)) { try { const response = await getRealTimeCollaborators({ sessionId, @@ -140,7 +141,8 @@ export const InsomniaEventStreamProvider: FC = ({ children }) useEffect(() => { const sessionId = userSession.id; - if (sessionId) { + // Local-only organizations have no server-side stream to subscribe to. + if (sessionId && !models.organization.isLocalOrganizationId(organizationId)) { try { const source = new EventSource(`insomnia-event-source://v1/teams/${sanitizeTeamId(organizationId)}/streams`); diff --git a/packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts b/packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts new file mode 100644 index 0000000000..aa3e0412f0 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts @@ -0,0 +1,15 @@ +type KonnectSyncTrigger = (konnectOrganizationId?: string | null) => Promise; + +let trigger: KonnectSyncTrigger | null = null; + +/** + * Lets the Konnect settings modal start a sync without being handed a callback by whichever + * component happens to render it. No-ops when nothing is registered. + */ +export const registerKonnectSyncTrigger = (fn: KonnectSyncTrigger | null) => { + trigger = fn; +}; + +export const runKonnectSync = async (konnectOrganizationId?: string | null) => { + await trigger?.(konnectOrganizationId); +}; diff --git a/packages/insomnia/src/ui/hooks/use-organization-features.tsx b/packages/insomnia/src/ui/hooks/use-organization-features.tsx index 32b591c261..61743c09e8 100644 --- a/packages/insomnia/src/ui/hooks/use-organization-features.tsx +++ b/packages/insomnia/src/ui/hooks/use-organization-features.tsx @@ -22,7 +22,7 @@ export function useOrganizationPermissions() { // Load organization permissions and features if they are not already loaded. useEffect(() => { const isIdleAndUninitialized = permissionsFetcher.state === 'idle' && !permissionsFetcher.data; - if (organizationId && !models.organization.isScratchpadOrganizationId(organizationId) && isIdleAndUninitialized) { + if (organizationId && !models.organization.isLocalOrganizationId(organizationId) && isIdleAndUninitialized) { permissionsFetcher.load({ organizationId, }); diff --git a/packages/insomnia/src/ui/organization-utils.ts b/packages/insomnia/src/ui/organization-utils.ts index f860dee45f..530b2c9973 100644 --- a/packages/insomnia/src/ui/organization-utils.ts +++ b/packages/insomnia/src/ui/organization-utils.ts @@ -2,6 +2,7 @@ import { createTeamProject, fetchTeamProjects, getCurrentPlan, + getOrganizationFeatures, getUserProfile, isApiError, type Organization, @@ -54,6 +55,78 @@ export async function syncOrganizations(sessionId: string, accountId: string) { } } +interface KonnectSyncEnabledCache { + enabled: boolean; + checkedAt: number; +} + +const KONNECT_SYNC_ENABLED_TTL_MS = 6 * 60 * 60 * 1000; + +const konnectSyncEnabledCacheKey = (accountId: string) => `${accountId}:konnectSyncEnabled`; + +function readKonnectSyncEnabledCache(accountId: string): KonnectSyncEnabledCache | null { + try { + const raw = localStorage.getItem(konnectSyncEnabledCacheKey(accountId)); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as KonnectSyncEnabledCache; + return typeof parsed?.enabled === 'boolean' ? parsed : null; + } catch { + return null; + } +} + +export function getKonnectSyncEnabled(accountId: string): boolean { + return readKonnectSyncEnabledCache(accountId)?.enabled ?? false; +} + +/** + * Konnect sync is a per-organization feature flag, but the Konnect organization is account-wide, + * so the flag is the OR across every organization the user belongs to. + */ +export async function syncKonnectSyncEnabled( + sessionId: string, + accountId: string, + { force = false }: { force?: boolean } = {}, +) { + if (!sessionId || !accountId) { + return; + } + + const cached = readKonnectSyncEnabledCache(accountId); + if (!force && cached && Date.now() - cached.checkedAt < KONNECT_SYNC_ENABLED_TTL_MS) { + return; + } + + const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; + if (organizations.length === 0) { + return; + } + + const results = await Promise.allSettled( + organizations.map(organization => getOrganizationFeatures({ organizationId: organization.id, sessionId })), + ); + const enabled = results.some( + result => result.status === 'fulfilled' && result.value?.features?.konnectSync?.enabled === true, + ); + + // A failed lookup is indistinguishable from "disabled", so never downgrade a known-true value. + if (!enabled && cached?.enabled && results.some(result => result.status === 'rejected')) { + return; + } + + localStorage.setItem(konnectSyncEnabledCacheKey(accountId), JSON.stringify({ enabled, checkedAt: Date.now() })); +} + +/** Keeps the aggregated flag fresh for the organization the user is currently looking at. */ +export function mergeKonnectSyncEnabledForOrganization(accountId: string, enabled: boolean) { + if (!accountId || !enabled) { + return; + } + localStorage.setItem(konnectSyncEnabledCacheKey(accountId), JSON.stringify({ enabled, checkedAt: Date.now() })); +} + export async function updateLocalProjectToRemote({ project, vcs, @@ -239,10 +312,14 @@ async function syncTeamProjects({ } export const syncProjects = projectLock.wrapWithLock(async (organizationId: string) => { + // Local-only organizations have no team projects to fetch, so bail out before the request. + if (models.organization.isLocalOrganizationId(organizationId)) { + return; + } const user = await services.userSession.get(); const teamProjects = await getAllTeamProjects(organizationId); // ensure we don't sync projects in the wrong place - if (Array.isArray(teamProjects) && user.id && !models.organization.isScratchpadOrganizationId(organizationId)) { + if (Array.isArray(teamProjects) && user.id) { await syncTeamProjects({ organizationId, teamProjects, diff --git a/packages/insomnia/src/ui/utils/router.ts b/packages/insomnia/src/ui/utils/router.ts index 231e24fe0f..46f97352f0 100644 --- a/packages/insomnia/src/ui/utils/router.ts +++ b/packages/insomnia/src/ui/utils/router.ts @@ -10,6 +10,7 @@ export const enum AsyncTask { SyncOrganization, MigrateProjects, SyncProjects, + SyncKonnectFeature, } const getMatchParams = (location: string) => { @@ -133,7 +134,12 @@ export const getInitialEntry = async () => { // Check if the user has a last visited organization try { const lastVisitedOrganizationId = localStorage.getItem('lastVisitedOrganizationId'); - if (lastVisitedOrganizationId && organizations.find(o => o.id === lastVisitedOrganizationId)) { + // The Konnect organization is local-only, so it is never in the cached organization list. + const isKnownOrganization = + lastVisitedOrganizationId === + (user.accountId && models.organization.getKonnectOrganizationId(user.accountId)) || + organizations.some(o => o.id === lastVisitedOrganizationId); + if (lastVisitedOrganizationId && isKnownOrganization) { organizationId = lastVisitedOrganizationId; } } catch {} @@ -142,7 +148,12 @@ export const getInitialEntry = async () => { pathname: await getInitialRouteForOrganization({ organizationId, navigateToWorkspace: true }), state: { // async task need to execute when first entry - asyncTaskList: [AsyncTask.SyncOrganization, AsyncTask.MigrateProjects, AsyncTask.SyncProjects], + asyncTaskList: [ + AsyncTask.SyncOrganization, + AsyncTask.MigrateProjects, + AsyncTask.SyncProjects, + AsyncTask.SyncKonnectFeature, + ], }, }; }