From b623f1fba9bafe2767d56a589162bc1907922b95 Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:09:30 +0800 Subject: [PATCH 1/2] feat(konnect): move Konnect projects into a global Control Planes organization Konnect projects move from `parentId: ` to one account-wide, local-only organization `org_konnect_` ("Control Planes"), and the Projects/Konnect tab split is removed from the project navigation sidebar. - Add the fake organization helpers and the `isLocalOrganizationId` predicate every "skip all remote APIs" branch now uses. - Aggregate the per-organization `konnectSync` flag into one account-wide, TTL-cached value and inject the fake organization from the layout loader. - Guard the organization-scoped remote calls that were never guarded for Scratchpad: collaborators, invites, member roles, check-seats, presence, the SSE stream, and the team-project/git-project-count mutations. - One-time migration in `entry.client.tsx` before hydration, with a modal for the ambiguous case where several organizations hold Konnect data. - Hide project creation inside Control Planes, including the `NoProjectView` create form. Design notes, audit findings and verification steps live in docs/konnect-control-planes-organization-plan.md. INS-3148 --- ...onnect-control-planes-organization-plan.md | 641 ++++++++++++++++++ packages/insomnia-data/src/database/types.ts | 2 + .../insomnia-data/src/models/organization.ts | 29 +- .../pages/components/navigation-sidebar.ts | 11 +- .../tests/smoke/konnect.test.ts | 17 +- .../src/common/organization-storage-rules.ts | 2 +- packages/insomnia/src/entry.client.tsx | 12 + .../migrate-konnect-organization.test.ts | 204 ++++++ .../konnect/migrate-konnect-organization.ts | 156 +++++ packages/insomnia/src/konnect/sync.ts | 14 +- .../git-credentials.$id.related-projects.tsx | 3 +- ...ganizationId.collaborators-check-seats.tsx | 6 +- ...n.$organizationId.collaborators-search.tsx | 6 +- ...orators.invites.$invitationId.reinvite.tsx | 8 +- ...Id.collaborators.invites.$invitationId.tsx | 7 +- ...nization.$organizationId.collaborators.tsx | 6 +- ....$organizationId.members.$userId.roles.tsx | 6 +- ...ganization.$organizationId.permissions.tsx | 11 +- ...ganizationId.project.$projectId._index.tsx | 4 +- ...ion.$organizationId.project.$projectId.tsx | 51 +- ...ganizationId.project.$projectId.update.tsx | 5 + ...ganization.$organizationId.project.new.tsx | 8 + packages/insomnia/src/routes/organization.tsx | 101 ++- .../src/routes/untracked-projects.tsx | 6 +- .../ui/components/header-invite-button.tsx | 4 + .../modals/konnect-org-migration-modal.tsx | 88 +++ .../modals/konnect-settings-modal.tsx | 5 +- .../ui/components/panes/no-project-view.tsx | 21 + .../project/organization-select.tsx | 4 + .../project-navigation-sidebar.tsx | 161 ++--- ...e-project-navigation-sidebar-navigation.ts | 8 - .../use-sidebar-drag-and-drop.tsx | 9 + .../app/insomnia-event-stream-context.tsx | 22 +- .../src/ui/hooks/konnect-sync-trigger.ts | 15 + .../src/ui/hooks/use-command-search.ts | 13 +- .../ui/hooks/use-organization-features.tsx | 2 +- .../insomnia/src/ui/organization-utils.ts | 79 ++- packages/insomnia/src/ui/utils/router.ts | 11 +- 38 files changed, 1542 insertions(+), 216 deletions(-) create mode 100644 docs/konnect-control-planes-organization-plan.md create mode 100644 packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts create mode 100644 packages/insomnia/src/konnect/migrate-konnect-organization.ts create mode 100644 packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx create mode 100644 packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts diff --git a/docs/konnect-control-planes-organization-plan.md b/docs/konnect-control-planes-organization-plan.md new file mode 100644 index 000000000000..e79717315726 --- /dev/null +++ b/docs/konnect-control-planes-organization-plan.md @@ -0,0 +1,641 @@ +# 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 `entry.client.tsx` **before the router hydrates**, so every + loader and component downstream can assume Konnect data already lives under the Konnect + organization. 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**, i.e. parented to `getKonnectOrganizationId(accountId)` (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 chosen organization's key to the Konnect + organization key during migration, so the tooltip does not regress to "Not yet synced". The key + is then removed for **every** source organization the migration touched, not just the chosen + one — after the move nothing reads `${realOrganizationId}:konnect-last-synced-at` again. + Orphan organizations (decision 3) keep theirs, matching how their data is left alone. + +## 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): Organization` that constructs the fake + organization object for rendering in `OrganizationSelect`. `is_owner` is hardcoded to `true`: + the backend guarantees every account owns at least one organization, so the value is constant + in practice — 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. +> +> **Revised again — the `AsyncTask` / `asyncTaskList` pipeline is dead code.** `getInitialEntry()` +> returns either a string or `{ pathname, state }`, but its only caller (`entry.client.tsx`) handles +> the string case exclusively: +> +> ```ts +> if (typeof initialEntry === 'string' && window.location.pathname !== initialEntry) { … } +> ``` +> +> The object branch is silently dropped, so `location.state.asyncTaskList` is never populated, the +> `useEffect` in `organization.tsx` never fires, and the +> `organization.sync-organizations-and-projects` action never runs. Do not hang anything new off it. +> +> The path that actually executes on a cold start is: +> `entry.client.tsx` (no-op) → pathname stays `/` → `routes/_index.tsx` → `redirect('/organization')` +> → `organization._index.tsx` `clientLoader`. Nothing in this change depends on that route being the +> landing point though — the migration runs before hydration (step 32) and the flag sync runs in the +> `/organization/**` layout loader (step 4), so both survive a future change of startup destination. + +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 site — `packages/insomnia/src/routes/organization.tsx` `clientLoader`, **awaited immediately + before reading the cache** in `getKonnectOrganization()`. + Two reasons for this placement rather than a startup route loader: + - **Ordering.** The writer (`syncKonnectSyncEnabled`) and the reader (`getKonnectSyncEnabled`) + must not sit in two concurrently-running loaders — the reader would silently see the previous + session's value and simply hide the organization for one render, with no error anywhere. Having + the consumer await its own precondition removes the cross-file ordering assumption entirely. + - **Reliability.** `organization.tsx` is the layout route for every `/organization/**` path, so it + runs whether the app opens on `/organization`, on `/organization/:id`, or deep-links straight + into a workspace. A route that only matches one exact path would silently stop firing if the + startup destination ever changed. + + The TTL makes this a no-op on all but the first load of a session, and on that first load the + sibling `organization._index.tsx` loader is already blocking on `syncOrganizations`'s network + calls in parallel, so the added wall-clock cost is effectively zero. + - `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. + - Do **not** add an `AsyncTask` entry — see the note above. + +5. Add a `getKonnectSyncEnabled(accountId)` helper for reading the cache. It must only ever be + called after `syncKonnectSyncEnabled` has been awaited in the same loader. +6. Visibility condition = `konnectSyncEnabled || hasLocalKonnectProjects`, computed in the + `organization.tsx` `clientLoader`. + Because the migration completes before hydration (step 32), this can simply count projects + parented to the Konnect organization: + + ``` + services.project.count({ + konnectControlPlaneId: { $exists: true, $ne: null }, + parentId: getKonnectOrganizationId(accountId), + }) + ``` + + - **`$ne: null` alone is not enough.** `konnectControlPlaneId` is in `project.optionalKeys` and is + not set by `project.init()`, so a regular project has no such field at all — and NeDB's `$ne` + matches documents where the field is absent (verified: a query for `{ $ne: null }` returns both + a plain project and a Konnect one; `{ $exists: true, $ne: null }` returns only the Konnect one). + The pre-existing `konnect/sync.ts` compensates for this with a redundant + `.filter(p => p.konnectControlPlaneId != null)` after the query. + - Scoping by that parent also keeps another account's Konnect data (decision 3 orphans) out of + the count — the NeDB database is not partitioned per user. + - In the unresolved-conflict case nothing has been migrated yet, so the count is 0 and the + organization stays hidden until the user answers the modal, at which point the migration runs + and triggers a revalidation. + +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`, +> `input-vault-key-modal.tsx`, `import-export.tsx`, `project.$projectId._index.tsx`); +> (b) loaders and hooks 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`, `use-command-search.ts`, +> `insomnia-event-stream-context.tsx`. +> Do not assume injection in `organization.tsx` is enough. + +#### Why the Konnect organization is not written into `${accountId}:spaces` + +The obvious alternative to injecting the fake organization at each reader is to persist it in the +cache, so every reader sees it for free. That was evaluated and rejected. `${accountId}:spaces` has +exactly one writer — `syncOrganizations()` in `organization-utils.ts`, which does a straight +`setItem(JSON.stringify(await services.organization.list()))` — so "write it at the source" means +teaching `syncOrganizations` to append a synthesized entry. Four things break: + +1. **`findMigrationTargetSpaceId` would select it.** It picks `o.is_owner && o.total_members === 1`, + and the fake organization is exactly that. Legacy `parentId: null` projects would be migrated + into Control Planes by `migrateProjectsUnderOrganization`. +2. **`organizations[0]` is the landing organization** in both `organization._index.tsx` and + `getInitialEntry()`. Prepending makes Control Planes everyone's startup destination; appending + puts it last in the dropdown. +3. **Circular dependency.** `syncKonnectSyncEnabled` iterates `${accountId}:spaces` calling + `getOrganizationFeatures` per organization, so the fake id would produce a doomed request on + every refresh — while that same flag is what decides whether the entry should exist at all. +4. **The migration would never converge.** `detectKonnectOrgMigration` uses "parentId is in + `${accountId}:spaces`" as its definition of a migratable project. With the Konnect organization + in that list, already-migrated projects look migratable forever. + +Two further reasons that are not bugs but matter: + +- **Staleness.** Visibility is `konnectSyncEnabled || hasLocalKonnectProjects`. Computed in + `organization.tsx`'s loader it is re-evaluated on every run, so disconnecting the PAT makes the + organization disappear immediately (verification 17). Baked into the cache it would survive until + the next `syncOrganizations`, and `syncOrganizations` would have to await + `syncKonnectSyncEnabled` plus a NeDB count — re-introducing the coupling Phase 1 rejected, on a + function whose third call site is driven by the `OrganizationChanged` SSE event. +- **Cache semantics.** `${accountId}:spaces` is a verbatim mirror of `GET /organizations`. Storing a + synthesized entry breaks that contract for anything comparing cache against server truth. + +The cost of the chosen approach is that every class-(b) reader has to be handled explicitly, and +missing one is silent. Two were missed in the first pass and are fixed here: +`use-command-search.ts` (Konnect projects were unreachable from the command palette, contradicting +decision 5) and the `VaultKeyChanged` branch of `insomnia-event-stream-context.tsx` (Konnect +workspaces' secrets survived a vault-key reset performed on another device). A shared +`getVisibleOrganizations()` helper was considered to remove the duplication but deliberately not +added: the explicit call sites are what keep the three readers that must see **only** server truth +(`syncKonnectSyncEnabled`, `findMigrationTargetSpaceId`, `detectKonnectOrgMigration`) distinguishable +from the rest. + +#### 2A — Injection & visibility + +8. `packages/insomnia/src/routes/organization.tsx` `clientLoader`: when the visibility condition + holds, prepend `buildKonnectOrganization(accountId)` 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. `syncProjects` in `organization-utils.ts`: bail out for local-only organizations. The guard has to + sit **before** `getAllTeamProjects()`, not after — the pre-existing scratchpad guard sat after it, + so the `fetchTeamProjects` request had already gone out by the time it ran. + `migrateProjectsUnderOrganization` needs no change: it only touches legacy projects with + `parentId: null`, which Konnect projects never have. +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 — unguarded `updateGitProjectCount()` / team-project calls.** + Rather than wrapping each remote call, block the storage types that reach them, which makes + every one of those branches unreachable in a local-only organization: + - `organization.$organizationId.project.new.tsx` — one guard inside `reportGitProjectCount()` + (its only caller of `updateGitProjectCount()`, and the function all three routes import, so + this single guard covers `update.tsx` and `delete.tsx` too), plus an `invariant` in + `createProjectImpl` rejecting any non-`local` `storageType`, which makes `createTeamProject()` + unreachable. + - `organization.$organizationId.project.$projectId.update.tsx` — one `invariant` at the top of + `clientAction` rejecting any non-`local` `storageType`. That alone makes the + `createTeamProject()` / `updateTeamProject()` / `reportGitProjectCount()` branches + unreachable; the remaining `deleteTeamProject()` calls are already gated on + `project.remoteId`, which a local-only organization's projects never have. + - `organization.$organizationId.project.$projectId.delete.tsx` — **no change needed.** + `deleteTeamProject()` is gated on `project.remoteId` and `reportGitProjectCount()` on + `project.gitRepositoryId`, and the latter is guarded internally anyway. + + The New Project button is hidden (step 19), but the project context menu stays (decision 5), so + guard the actions rather than relying on the entry points. + +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. **Existing scratchpad guards on the storage-rules / workspaces fetchers — extend only + `use-organization-features.tsx`, leave the rest scratchpad-only.** The audit that produced the + original list was wrong: none of these fetchers issues a request for a local-only organization + once steps 10/11 are in place, so gating them buys nothing and actively breaks things. + - `packages/insomnia/src/ui/hooks/use-organization-features.tsx` — **extend.** Skipping the load + leaves `features`/`billing` at `fallbackFeatures`/`fallbackBilling`, which is byte-for-byte + what `permissions.tsx` returns for a local organization (step 10), so behaviour is unchanged + and one route invocation is saved. + - The four storage-rule fetchers — **do not extend** (`project._index.tsx`, + `project.$projectId._index.tsx`, `workspace.$workspaceId.spec.tsx`, + `git-project-sync-dropdown.tsx`; `project.$projectId.tsx` only reads the shared + `storage-rule:${organizationId}` key and never loads). `fetchAndCacheOrganizationStorageRule` + already short-circuits locally per step 11, so the load costs nothing — whereas skipping it + leaves `storageRules` at `DEFAULT_STORAGE_RULES` (`enableCloudSync: true`, + `enableGitSync: true`), the exact opposite of the local-only rules step 11 installs, and + Cloud Sync / Git Sync would surface inside Control Planes. + - `request-settings-modal.tsx` and `import-export.tsx` — **do not extend.** Both use + `useProjectListWorkspacesLoaderFetcher`, whose loader is pure NeDB (no network at all). + Skipping it empties "Move/Copy to Workspace" in the request settings modal and breaks + Settings → Data (`workspacesForActiveProject`, `activeProject`, `projects` all empty, so + project export produces nothing and the import buttons disappear). + The scratchpad guard exists there only because scratchpad has a single project and workspace. + +#### 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`, which reaches + `UpgradeModal` (the enterprise-gated nunjucks tag menu in the editors) and `UpgradeNotice` + (Settings → Cloud Credentials). `is_owner` is hardcoded to `true` on the Konnect organization: + the backend guarantees every account owns at least one organization, so deriving it from the + real organization list would always yield `true` anyway. +19. Hide `NewProjectButton` in the sidebar for the Konnect organization (detail in step 22), and + replace the main-pane `NoProjectView` with a read-only explanation. `NoProjectView` renders + `ProjectCreateForm`, so leaving it in place would hand the user a project-creation form inside + an organization whose projects come exclusively from sync. The branch lives in the component + itself rather than at its two call sites (`project._index.tsx`, + `project.$projectId._index.tsx`), and its wording depends on `settings.hasKonnectPat`: + "connect a PAT" before one is set, "use the Sync button" after. + +#### 2D — Direct-localStorage loaders + routing + +20. Add the Konnect organization id to the accepted-org-id lists in the loaders and hooks 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. + - `packages/insomnia/src/ui/hooks/use-command-search.ts` — `command-search.ts` scopes its + project query with `parentId: { $in: allOrganizations.map(o => o.id) }`, so without this the + command palette cannot reach any Konnect project, collection or request. Append the id and + `KONNECT_ORGANIZATION_NAME` unconditionally: with no Konnect projects the extra id simply + matches nothing. + - `packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx`, the + `VaultKeyChanged` branch — `services.environment.removeAllSecrets()` is scoped by organization + id, so Konnect workspaces' secret environment variables would survive a vault-key reset + performed on another device. (The in-app "Reset Vault Key" button in + `input-vault-key-modal.tsx` is already correct — it is a class-(a) reader.) +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. + Note: this lives inside the object-returning branch, which `entry.client.tsx` currently discards + (see the Phase 1 note), so it is inert today — `lastVisitedOrganizationId` is otherwise only read + by `root.tsx`'s deep-link handler. A cold start therefore always lands on `organizations[0]`, + for every organization, not just Control Planes. The change is kept because it is the correct + behaviour once that branch is honoured. + +#### 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. + **Known limitation, deliberately not fixed here:** that gear row has no Sync button, so with a + PAT connected and zero synced projects there is no way to trigger a sync from the sidebar. + `EmptyProjectNavigationSidebar` exists only because `ProjectNavigationSidebarInner` calls + `useProjectLoaderData()!`, which is `undefined` on the `project._index` route (no `:projectId` + match), so the full sidebar cannot render when the organization has no projects. In a real + organization that state is rare; in Control Planes it is the _initial_ state, which is why the + gap is visible here. The proper fix is to make the full sidebar tolerate missing loader data + (`projects = projectLoaderData?.projects ?? []`) and delete this variant, but that touches a + component every organization renders and is out of scope. `NoProjectView`'s copy therefore says + "Sync from Konnect" rather than pointing at a button that is not there. +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 chosen organization's value across and then deletes + the key for every source organization (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: { $exists: true, $ne: null }` (see the step 6 note — `$ne: null` on its + own also matches regular projects, which would get re-parented or **deleted**), 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`, then remove the key for every + source organization in the migrated set (decision 10). The set is derived from the `parentId`s + captured **before** the re-parent, so orphans are naturally excluded. + - **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 point — `packages/insomnia/src/entry.client.tsx`, immediately before `getInitialEntry()` + and `hydrateRoot()`. + **The migration must complete before the router hydrates**, so that every loader, component and + query downstream can assume Konnect projects are already parented to the Konnect organization. + Placing it in a route loader instead does not work: `organization.tsx` is the parent layout of + `organization._index.tsx`, so their loaders run **concurrently** — the parent would observe + pre-migration data and would need a defensive `parentId` allow-list to compensate. + It sits after the env-var session block (`setSessionData`) because it needs `accountId`, and it + reads `${accountId}:spaces` from the previous session's cache — which is exactly the population + that has data to migrate. It is wrapped in `try/catch`: a migration failure must never prevent + the app from starting. + `auto` runs immediately; `conflict` migrates nothing and is surfaced later by the modal in + `organization.tsx`, which re-runs `detectKonnectOrgMigration` after hydration. +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, and two cases asserting that + regular projects (no `konnectControlPlaneId` field) are neither detected nor touched. + 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. + Also confirm the local-only storage rules actually reach the UI (step 17): no Cloud Sync / Git + Sync options in the project settings form, and no Git sync dropdown. +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 → the app opens on `organizations[0]`, **not** on + Control Planes. This is expected today and is not a regression: `lastVisitedOrganizationId` is + only consulted in the dead `getInitialEntry()` branch, so no organization is restored on restart. + What must be verified is that landing elsewhere does not break — Control Planes is still present + in the dropdown and still reachable. +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 update/delete action inside Control Planes from the project dropdown + (`sidebar-project-dropdown.tsx`) → no `team-projects` or `PATCH /organizations/:id` request is + issued. +13. Inside Control Planes, open a request's settings → "Move/Copy to Workspace" lists the other + workspaces of the same project. Then open Settings → Data → project export contains the + project's workspaces (regression guards for step 17).26. Open the command palette from a real organization and search for the name of a Konnect + collection or request → it appears, labelled "Control Planes" (regression guard for step 20). +14. Reset the vault key on a second device → the `VaultKeyChanged` event clears the secret + environment variables of Konnect workspaces too, not just those of real organizations. + +### 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. +19. Enter Control Planes with no synced projects → the main pane shows the read-only explanation, + not `ProjectCreateForm`; check both before and after connecting a PAT for the two wordings + (step 19). + +### 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. Confirm the migration cannot be corrupted by a concurrent `syncProjects` — it takes `projectLock`, + and `syncTeamProjects` only nulls `remoteId` on projects that have one, so Konnect projects + (`remoteId: null`) are untouched either way. +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). + +Deliberately **not** touched, despite earlier drafts modifying them: +`organization._index.tsx` and `organization.sync-organizations-and-projects.tsx`. Both ended up with +no functional change, so they were reverted to keep them out of the diff. + +## 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 4** — `syncKonnectSyncEnabled` was first hung off a new `AsyncTask` entry, then moved to the + `organization._index.tsx` loader, and finally into `organization.tsx`'s `getKonnectOrganization()` + where it is awaited immediately before the cache is read. Only the last placement actually + guarantees the write happens before the read; the earlier ones relied on two concurrent loaders + happening to run in the right order, which fails silently. +- **Step 32** — the entry point is `migrateKonnectProjectsIfUnambiguous(accountId)`, which runs + `detectKonnectOrgMigration` and only auto-migrates the unambiguous case. It was initially placed + in the `organization._index.tsx` loader, then moved to `entry.client.tsx` so that it completes + before hydration; that ordering is what lets step 6 assume post-migration parents instead of + carrying a defensive allow-list. 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. + +## Pre-existing Issues Found + +Discovered while implementing this change, left unfixed and out of scope: + +1. **The `AsyncTask` / `asyncTaskList` pipeline never runs.** `getInitialEntry()` can return + `{ pathname, state }`, but `entry.client.tsx` only acts on a `string` return value, so the object + form is discarded. `location.state.asyncTaskList` is therefore always undefined, the `useEffect` + in `organization.tsx` never submits, and the `organization.sync-organizations-and-projects` action + is unreachable. Knock-on effects: the "Syncing" state in `NetworkAndSyncIndicator` never appears, + and `lastVisitedOrganizationId` is never honoured on startup — a cold start always lands on `/`, + is redirected to `/organization` by `routes/_index.tsx`, and then to `organizations[0]`, + regardless of where the user was last. + The first draft of this work hung a new `AsyncTask.SyncKonnectFeature` off that pipeline; it was + removed once the pipeline was confirmed dead. +2. **`konnect-settings-modal.tsx`'s disconnect flow buffers ineffectively** — the outer + `bufferChangesIndefinitely()` is defeated by `db.remove()`'s internal flush (decision 9). diff --git a/packages/insomnia-data/src/database/types.ts b/packages/insomnia-data/src/database/types.ts index c509e71578c8..11afca48790b 100644 --- a/packages/insomnia-data/src/database/types.ts +++ b/packages/insomnia-data/src/database/types.ts @@ -27,6 +27,8 @@ export interface SpecificQuery { $in?: (string | null)[]; $nin?: string[]; $ne?: string | null; + // NeDB's `$ne` also matches documents where the field is absent, so optional keys need this too. + $exists?: boolean; } export type Query = { diff --git a/packages/insomnia-data/src/models/organization.ts b/packages/insomnia-data/src/models/organization.ts index 332002b96e5c..2df01a279f27 100644 --- a/packages/insomnia-data/src/models/organization.ts +++ b/packages/insomnia-data/src/models/organization.ts @@ -1,8 +1,35 @@ -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): 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, + // Every account owns at least one organization, and this one only exists on their machine. + is_owner: true, + 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 497bc690f895..cd3a89a42043 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 ae8a18579084..b0dd4dbc3cfc 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 9086bac1da8e..a5db9ea8b2ba 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/entry.client.tsx b/packages/insomnia/src/entry.client.tsx index 3531655d6d3e..d03fd998404f 100644 --- a/packages/insomnia/src/entry.client.tsx +++ b/packages/insomnia/src/entry.client.tsx @@ -9,6 +9,7 @@ import { HydratedRouter } from 'react-router/dom'; import { insomniaFetch } from '~/common/insomnia-fetch'; import { setTemplatingDbAuthToken } from '~/common/templating/liquid-extension-worker'; +import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization'; import { initRuntime } from '~/runtimes'; import { rendererRuntime } from '~/runtimes/runtime.renderer'; import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from '~/ui/account/session'; @@ -145,6 +146,17 @@ if (appSettings.clearOAuth2SessionOnRestart) { applyColorScheme(appSettings); +// Runs before the router hydrates so every loader can assume Konnect projects already live under +// the Konnect organization. The ambiguous case is left for the user to resolve in the UI. +try { + const { accountId } = await services.userSession.get(); + if (accountId) { + await migrateKonnectProjectsIfUnambiguous(accountId); + } +} catch (e) { + console.log('[konnect] Failed to migrate Konnect projects', e); +} + const initialEntry = await getInitialEntry(); if (typeof initialEntry === 'string' && window.location.pathname !== initialEntry) { 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 000000000000..e52173bf7e47 --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts @@ -0,0 +1,204 @@ +/** + * 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: { $exists: true, $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: [] }); + }); + + it('ignores regular projects, which omit konnectControlPlaneId entirely', async () => { + await services.project.create({ name: 'Regular', parentId: ORG_A }); + + 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(); + }); + + it('clears the last-synced timestamp of every source organization but leaves orphans alone', async () => { + const store = stubLocalStorage({ + [`${ACCOUNT_ID}:spaces`]: JSON.stringify([ + { id: ORG_A, name: 'Org A' }, + { id: ORG_B, name: 'Org B' }, + ]), + [`${ORG_A}:konnect-last-synced-at`]: '1700000000000', + [`${ORG_B}:konnect-last-synced-at`]: '1600000000000', + ['org_someone_else:konnect-last-synced-at']: '1500000000000', + }); + await createKonnectProject(ORG_A, 'cp-1'); + await createKonnectProject(ORG_B, 'cp-2'); + await createKonnectProject('org_someone_else', 'cp-other'); + + 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(); + expect(store.get(`${ORG_B}:konnect-last-synced-at`)).toBeUndefined(); + expect(store.get('org_someone_else:konnect-last-synced-at')).toBe('1500000000000'); + }); + + it('neither re-parents nor deletes regular projects', async () => { + await createKonnectProject(ORG_A, 'cp-1'); + const keptRegular = await services.project.create({ name: 'Regular A', parentId: ORG_A }); + const discardedRegular = await services.project.create({ name: 'Regular B', parentId: ORG_B }); + + await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A }); + + expect((await services.project.getById(keptRegular._id))?.parentId).toBe(ORG_A); + expect((await services.project.getById(discardedRegular._id))?.parentId).toBe(ORG_B); + }); +}); + +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 000000000000..946c0613d1ec --- /dev/null +++ b/packages/insomnia/src/konnect/migrate-konnect-organization.ts @@ -0,0 +1,156 @@ +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. + // `konnectControlPlaneId` is an optional key, so non-Konnect projects omit it entirely and NeDB's + // `$ne: null` alone would match them. + const projects = await services.project.list({ + konnectControlPlaneId: { $exists: true, $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); + } + // `projects` was captured before the re-parent, so it still carries the source organization + // ids. Orphans are absent from it and keep their key, matching how their data is left alone. + for (const sourceOrganizationId of new Set([keepOrganizationId, ...projects.map(p => p.parentId)])) { + localStorage.removeItem(lastSyncedAtKey(sourceOrganizationId)); + } + } catch { + // A stale timestamp is cosmetic; never fail the migration over it. + } + } finally { + await projectLock.unlock(); + } +} diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index bef35fdf515d..021bf50ed10f 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -700,13 +700,13 @@ export async function syncKonnect({ pat, organizationId, signal, onProgress }: S }; try { - // Load all existing Konnect projects up front to avoid per Control Plane queries - const existingProjects = ( - await insoservices.project.list({ - parentId: organizationId, - konnectControlPlaneId: { $ne: null }, - }) - ).filter(p => p.konnectControlPlaneId != null); + // Load all existing Konnect projects up front to avoid per Control Plane queries. + // `konnectControlPlaneId` is an optional key, so regular projects omit it entirely and NeDB's + // `$ne: null` alone would match them. + const existingProjects = await insoservices.project.list({ + parentId: organizationId, + konnectControlPlaneId: { $exists: true, $ne: null }, + }); const existingProjectsByKonnectId = new Map(existingProjects.map(p => [p.konnectControlPlaneId!, p])); const incomingControlPlaneIds = new Set(); const syncCtx: SyncContext = { pat, organizationId, existingProjectsByKonnectId, signal, onProgress }; 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 b54f08b1360b..49f78524d1eb 100644 --- a/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx +++ b/packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx @@ -19,7 +19,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { accountId } = await services.userSession.get(); let organizations: Organization[] = []; try { - organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; + organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; } catch { // If parsing fails, fall through with empty array } @@ -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 f4419f21e39f..bcbc9e5ade07 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 e71e698f2b4a..cecbf3b84678 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 5dfe4d7eb896..df1bfbb1251f 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 3e4a46d0e8a5..1765127c7915 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 12bc8b5b18d9..83e93c203876 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 6318a4a03ebf..769720e42811 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 8351cdcf576b..036cea475fc6 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 91186d170a9b..bbbb507c2643 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; @@ -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 73497bb0666e..329ee3b24937 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.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx index 92e0942dce9a..5610971e02fe 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.tsx b/packages/insomnia/src/routes/organization.tsx index af52ed40f4e6..eaf0151a83f1 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -3,9 +3,20 @@ 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 +33,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 +42,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, syncKonnectSyncEnabled } from '~/ui/organization-utils'; import type { AsyncTask } from '~/ui/utils/router'; import type { Route } from './+types/organization'; @@ -40,22 +53,54 @@ 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). Migration runs before hydration in `entry.client.tsx`, + * so any Konnect data this account owns is already parented to the Konnect organization by now. + */ +async function getKonnectOrganization(sessionId: string, accountId: string) { + const konnectOrganizationId = models.organization.getKonnectOrganizationId(accountId); + // Refresh before reading rather than assuming another loader already did it. TTL-guarded, so this + // is a no-op on all but the first load of a session. + await syncKonnectSyncEnabled(sessionId, accountId); + const konnectSyncEnabled = getKonnectSyncEnabled(accountId); + + if (!konnectSyncEnabled) { + const existingKonnectProjectCount = await services.project.count({ + konnectControlPlaneId: { $exists: true, $ne: null }, + parentId: konnectOrganizationId, + }); + if (existingKonnectProjectCount === 0) { + return { konnectSyncEnabled, konnectOrganization: null }; + } + } + + return { + konnectSyncEnabled, + konnectOrganization: models.organization.buildKonnectOrganization(accountId), + }; +} + 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(id, accountId); return { - organizations, + organizations: konnectOrganization ? [konnectOrganization, ...organizations] : organizations, user, currentPlan, + konnectSyncEnabled, }; } return { organizations: [], user: undefined, currentPlan: undefined, + konnectSyncEnabled: false, }; } @@ -176,17 +221,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 +246,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 +301,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 +455,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 837dedb24df8..cf60d5b4711f 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/header-invite-button.tsx b/packages/insomnia/src/ui/components/header-invite-button.tsx index a9f2bd1d7fa1..271fd299ea51 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 000000000000..a8bdeb6ce440 --- /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 de6245253247..7c6ab4951eaf 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/panes/no-project-view.tsx b/packages/insomnia/src/ui/components/panes/no-project-view.tsx index 01dca4d033ad..ec48380c7286 100644 --- a/packages/insomnia/src/ui/components/panes/no-project-view.tsx +++ b/packages/insomnia/src/ui/components/panes/no-project-view.tsx @@ -1,7 +1,10 @@ import type { StorageRules } from 'insomnia-api'; +import { models } from 'insomnia-data'; import React, { type FC } from 'react'; import { Heading } from 'react-aria-components'; +import { useParams } from 'react-router'; +import { useRootLoaderData } from '~/root'; import { useGitCredentials } from '~/ui/hooks/use-git-credentials'; import { ProjectCreateForm } from '../project/project-create-form'; @@ -11,7 +14,25 @@ interface Props { } export const NoProjectView: FC = ({ storageRules }) => { + const { organizationId } = useParams() as { organizationId: string }; + const { settings } = useRootLoaderData()!; const { credentials, providers } = useGitCredentials(); + + // Konnect projects come exclusively from sync, so there is nothing for the user to create here. + if (models.organization.isKonnectOrganizationId(organizationId)) { + return ( +
+ No control planes synced yet + + Projects here mirror your Konnect control planes. + {settings.hasKonnectPat + ? ' Sync from Konnect to pull them in.' + : ' Connect a Konnect personal access token from the sidebar to get started.'} + +
+ ); + } + return (
diff --git a/packages/insomnia/src/ui/components/project/organization-select.tsx b/packages/insomnia/src/ui/components/project/organization-select.tsx index fdfcb282ad7b..43ef7f6992c6 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 6e31f27745a0..480f1241c1a2 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 028b19d2e18f..67e5f75d54ab 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 f1a9b942ad12..5ec808875368 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`); @@ -184,7 +186,10 @@ export const InsomniaEventStreamProvider: FC = ({ children }) window.setTimeout(() => avatarImageCache.invalidate(event.avatar), CDN_INVALIDATION_TTL); } syncOrganizationsSubmit(); - } else if (event.type === 'StorageRuleChanged' && (event.team.startsWith('org_') || event.team.startsWith('team_'))) { + } else if ( + event.type === 'StorageRuleChanged' && + (event.team.startsWith('org_') || event.team.startsWith('team_')) + ) { syncStorageRulesSubmit({ organizationId: event.team, }); @@ -205,11 +210,14 @@ export const InsomniaEventStreamProvider: FC = ({ children }) } } else if (event.type === 'VaultKeyChanged') { const accountId = userSession.accountId; - const organizations = JSON.parse( - localStorage.getItem(`${accountId}:spaces`) || '[]', - ) as Organization[]; + const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; clearVaultKeySubmit({ - organizations: organizations?.map(org => org.id) || [], + // The Konnect organization is local-only so it is never in the cached list, but its + // workspaces hold secrets like any other. + organizations: [ + ...(organizations?.map(org => org.id) || []), + models.organization.getKonnectOrganizationId(accountId), + ], sessionId: event.sessionId, }); } else if ( 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 000000000000..aa3e0412f0a0 --- /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-command-search.ts b/packages/insomnia/src/ui/hooks/use-command-search.ts index 83cc45e17a3a..1271f8f241c0 100644 --- a/packages/insomnia/src/ui/hooks/use-command-search.ts +++ b/packages/insomnia/src/ui/hooks/use-command-search.ts @@ -1,5 +1,5 @@ import type { CommandSearchResult } from 'insomnia-data'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { useCallback, useEffect, useRef, useState } from 'react'; interface UseCommandSearchParams { @@ -44,9 +44,18 @@ export function useCommandSearch({ services.helpers.abortCommandSearch(prevRequestId).catch(() => {}); } - const allOrganizations: { id: string; name: string }[] = JSON.parse( + const cachedOrganizations: { id: string; name: string }[] = JSON.parse( localStorage.getItem(`${accountId}:spaces`) || '[]', ); + // The Konnect organization is local-only so it is never in the cached list, and search scopes + // projects by organization id — without it Konnect projects are unreachable. + const allOrganizations = [ + ...cachedOrganizations, + { + id: models.organization.getKonnectOrganizationId(accountId), + name: models.organization.KONNECT_ORGANIZATION_NAME, + }, + ]; try { const result = await services.helpers.commandSearch({ diff --git a/packages/insomnia/src/ui/hooks/use-organization-features.tsx b/packages/insomnia/src/ui/hooks/use-organization-features.tsx index 32b591c26185..61743c09e89a 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 f860dee45f57..530b2c997389 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 231e24fe0fd4..18d10c6163b6 100644 --- a/packages/insomnia/src/ui/utils/router.ts +++ b/packages/insomnia/src/ui/utils/router.ts @@ -120,9 +120,7 @@ export const getInitialEntry = async () => { const user = await services.userSession.get(); if (user.id) { - const organizations = JSON.parse( - localStorage.getItem(`${user.accountId}:spaces`) || '[]', - ) as Organization[]; + const organizations = JSON.parse(localStorage.getItem(`${user.accountId}:spaces`) || '[]') as Organization[]; // If no organizations are in local storage, go fetch from org index loader if (organizations.length === 0) { return href('/organization'); @@ -133,7 +131,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 {} From 9e070eb549620ef02d7344a3b7c34e66e0b2399f Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:09:30 +0800 Subject: [PATCH 2/2] refactor(sidebar): drop redundant activeProjects alias after tab removal --- .../project-navigation-sidebar.tsx | 10 +++++----- .../src/ui/hooks/use-navigation-sidebar-data.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 37d235b2d0bb..1b4f07643e88 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -145,7 +145,7 @@ const ProjectNavigationSidebarInner = ( null, ); - const { organizationProjects, organizationWorkspaces, workspaceMetas, activeProjects, collectionByWorkspaceId } = + const { organizationProjects, organizationWorkspaces, workspaceMetas, collectionByWorkspaceId } = useProjectNavigationSidebarData(organizationId, { projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds, @@ -179,8 +179,8 @@ const ProjectNavigationSidebarInner = ( const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID; const cloudSyncProjects = useMemo( - () => activeProjects.filter(p => models.project.isRemoteProject(p)), - [activeProjects], + () => organizationProjects.filter(p => models.project.isRemoteProject(p)), + [organizationProjects], ); // Generate a stable string key to trigger getOrFetchUnsyncedFiles when the list of cloud sync projects changes. const cloudSyncProjectIdsKey = useMemo( () => @@ -373,7 +373,7 @@ const ProjectNavigationSidebarInner = ( const collectionChildrenAndMetaByWorkspaceId = collectionByWorkspaceId; - for (const project of activeProjects) { + for (const project of organizationProjects) { const projectId = project._id; const isProjectCollapsed = !(expandedProjectAndWorkspaceIds ?? []).includes(projectId); items.push({ @@ -627,12 +627,12 @@ const ProjectNavigationSidebarInner = ( return buildWorkspaceAndCollectionData(); }, [ activeFilter, - activeProjects, collectionByWorkspaceId, collectionSortOrders, expandedProjectAndWorkspaceIds, localWorkspaceOrders, organizationId, + organizationProjects, organizationWorkspaces, projectWorkspaceSortOrder, requestGroupCollapseOverrides, diff --git a/packages/insomnia/src/ui/hooks/use-navigation-sidebar-data.ts b/packages/insomnia/src/ui/hooks/use-navigation-sidebar-data.ts index 43c35b0a495e..0e9f6e95750b 100644 --- a/packages/insomnia/src/ui/hooks/use-navigation-sidebar-data.ts +++ b/packages/insomnia/src/ui/hooks/use-navigation-sidebar-data.ts @@ -13,8 +13,7 @@ export function useProjectNavigationSidebarData( ) { const { projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds } = options; const { projects, workspaces, workspaceMetas } = useOrganizationData(organizationId); - const activeProjects = projects; - const projectIds = useMemo(() => activeProjects.map(p => p._id), [activeProjects]); + const projectIds = useMemo(() => projects.map(p => p._id), [projects]); // Get the list of collection workspace ids that should be cached based on the current filter and expanded projects/workspaces. const collectionWorkspaceIds = useMemo(() => { @@ -40,7 +39,6 @@ export function useProjectNavigationSidebarData( organizationProjects: projects, organizationWorkspaces: workspaces, workspaceMetas, - activeProjects, projectIds, collectionWorkspaceIds, collectionByWorkspaceId,