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) => {