feat(konnect): move Konnect projects into a global Control Planes organization

The Konnect PAT is stored per-account, but the projects it synced were created
with `parentId: organizationId`, so the same PAT could be synced independently
under several organizations.

Konnect projects now live in a hardcoded local-only organization,
`org_konnect_<accountId>`, displayed as "Control Planes" in the organization
dropdown. The Projects/Konnect tab split in the navigation sidebar is gone —
the sidebar switches its header controls based on the active organization
instead.

- Add `getKonnectOrganizationId` / `isKonnectOrganizationId` /
  `isLocalOrganizationId` / `buildKonnectOrganization` to the organization model.
- Aggregate the per-organization `konnectSync` feature flag into an account-wide
  value cached in localStorage, refreshed once per app start via a new
  `AsyncTask.SyncKonnectFeature`.
- Guard every organization-scoped API call for local-only organizations:
  features, storage rules, collaborators, invites, member roles, team projects,
  git project count, real-time presence and the SSE stream.
- Migrate existing Konnect projects on startup. A single source organization is
  migrated silently; multiple source organizations prompt the user to pick one,
  and the rest are deleted.
- Reject dragging a collection workspace between Konnect projects, which would
  orphan its `konnectServiceId`.
- Decouple the Konnect sync trigger from the sidebar so the settings modal no
  longer needs it passed down as a ref.

Also fixes a pre-existing bug where `syncProjects` issued `fetchTeamProjects`
before checking whether the organization was local.
This commit is contained in:
yaoweiprc
2026-07-30 18:10:54 +08:00
parent c74e2d6c45
commit 88c84ee3a9
40 changed files with 1261 additions and 209 deletions

View File

@@ -0,0 +1,451 @@
# Plan: Move Konnect Projects into a Global Local Organization ("Control Planes")
## Background & Goal
Today the Konnect PAT is stored globally (Electron `safeStorage`, key `konnectPat`), but the
Konnect projects it produces are created with `parentId: organizationId` — i.e. they belong to a
specific organization. As a result the same PAT can be synced independently under several
organizations, which is semantically wrong.
Goal: introduce a hardcoded local-only organization `org_konnect_${accountId}` (display name
"Control Planes"), move all Konnect projects under it, and remove the Projects/Konnect tab split
from the project navigation sidebar.
## Feasibility Assessment
The approach mirrors the existing Scratchpad "fake local organization" pattern and is sound.
The main risk: roughly half of the organization-scoped remote API calls are **not** guarded for
Scratchpad today (check-seats, collaborators, user-permissions, presence, event stream,
`PATCH /organizations/:id`). Scratchpad gets away with it because its entry point is hidden.
Control Planes will be reachable from the organization dropdown, so every one of these needs an
explicit guard.
## Confirmed Decisions
1. **konnectSync flag aggregation** — on startup, fetch features for all organizations
concurrently, OR the results, cache in localStorage. (99% of users have a single organization.)
2. **Migration trigger** — runs in the background at startup, after login and after
`syncOrganizations()`. A modal is shown only when the user must choose between organizations.
3. **Orphaned Konnect projects** (parentId not in the current account's organization list) —
ignored entirely: neither migrated nor deleted.
4. **Control Planes visibility** — shown if any organization has `konnectSync = true`; also shown
when all are false but the local database already contains Konnect projects **belonging to the
current account** (the NeDB database has no per-user isolation, so this check must be scoped by
`parentId` — see step 6). In that case the Sync button is disabled.
5. **Capabilities hidden inside Control Planes** — New Project button, invites/collaborators/
presence, Cloud Sync / Git Sync. The project context menu and Command Palette behaviour stay
as-is. `HeaderPlanIndicator` **stays visible**: per
`docs/header-plan-indicator-data-scope.md` every value it shows (`currentPlan`, resource usage,
license usage, trial eligibility) is account-scoped and fetched with `sessionId` only — it never
takes an `organizationId`, so hiding it would just make billing info disappear for no reason.
6. **Conflict-case data** — Konnect projects under the organizations the user did *not* pick are
**deleted**, cascading to their workspaces / requests / environments / cookie jars. Leaving them
would leak them into that organization's regular project list now that the tab is gone.
7. **Multiple accounts sharing one data directory** — do nothing. Each account gets its own
`org_konnect_${accountId}` data set; no cross-account cleanup for now.
8. **No Settings "Konnect" panel** in this change — the PAT is still configured only from the gear
icon inside Control Planes. But the sync trigger **is** moved out of the sidebar, so a second
entry point can be added later without rework.
9. **The latent buffering bug in `konnect-settings-modal.tsx`'s disconnect flow is not fixed here**
only documented (see the Phase 4 note). New code must not repeat the pattern.
10. **`konnect-last-synced-at` is carried over** from the old organization key to the Konnect
organization key during migration, so the tooltip does not regress to "Not yet synced".
## Existing Implementation Reference
- `SCRATCHPAD_ORGANIZATION_ID` / `isScratchpadOrganizationId`
`packages/insomnia-data/src/models/organization.ts`
- Organization list cache — localStorage `${accountId}:spaces`, written by `syncOrganizations()`
in `packages/insomnia/src/ui/organization-utils.ts`
- Organization route loader — `packages/insomnia/src/routes/organization.tsx`
- Features loader — `packages/insomnia/src/routes/organization.$organizationId.permissions.tsx`
(Scratchpad short-circuits to `fallbackFeatures`; unknown organizations hit
`throw redirect('/organization')`)
- Storage rules — `packages/insomnia/src/common/organization-storage-rules.ts`
- Sidebar tabs — `ProjectNavigationSidebarTabId` in
`packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx`;
parent owns `${organizationId}:sidebar-tab` in
`packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx`
- Migration precedent — `packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts`,
invoked via `migrateProjectsUnderOrganization()` from
`packages/insomnia/src/routes/organization._index.tsx`
## Steps
### Phase 0 — Constants & helpers (blocks everything else)
1. In `packages/insomnia-data/src/models/organization.ts` add:
- `KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_'`
- `KONNECT_ORGANIZATION_NAME = 'Control Planes'`
- `getKonnectOrganizationId(accountId)`
- `isKonnectOrganizationId(id)`
- `isLocalOrganizationId(id)` (scratchpad || konnect) — the single predicate every
"skip all remote APIs" branch should use.
2. Add `buildKonnectOrganization(accountId, { isOwner }): Organization` that constructs the fake
organization object for rendering in `OrganizationSelect`. `is_owner` must be copied from the
user's first/personal real organization rather than hardcoded — see step 18.
### Phase 1 — Aggregated konnectSync flag (*can run in parallel with early Phase 2*)
> **Revised:** do NOT put this inside `syncOrganizations()`. That function has three entry points,
> one of which (`organization.sync.tsx`) is driven by the `OrganizationChanged` SSE event from the
> event stream — fired on renames, membership changes and plan changes, and received by every open
> window. Binding N feature requests to it is wrong for a slow-changing permission bit.
> Note: the comment at `organization.tsx` claiming org switching also sets `asyncTaskList` is
> stale — switching calls `navigate('/organization/:id')` with no state and lands on
> `organization.$organizationId._index`. Only `getInitialEntry()` (`router.ts`) sets it.
3. Add a **standalone** `syncKonnectSyncEnabled(sessionId, accountId, { force })` to
`packages/insomnia/src/ui/organization-utils.ts`:
- `Promise.allSettled` over `getOrganizationFeatures({ organizationId, sessionId })` for every
organization, OR the `konnectSync.enabled` values.
- Cache to localStorage `${accountId}:konnectSyncEnabled = { enabled, checkedAt }`.
- Self-deduplicating via a TTL (6h); `force` bypasses it.
- Treat failures as false but never overwrite an existing cached `true` (offline resilience).
4. Call sites:
- Add `SyncKonnectFeature` to the `AsyncTask` enum (`packages/insomnia/src/ui/utils/router.ts`),
include it in `getInitialEntry()`'s `asyncTaskList`, and add the matching branch to
`taskPromiseList` in
`packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx`
→ runs once per app start.
- `packages/insomnia/src/routes/organization._index.tsx`: call it after `syncOrganizations()`
(login-redirect path, where the organization list has just been fetched).
- `packages/insomnia/src/routes/organization.sync.tsx` (SSE path): do **not** call it; the TTL
covers long-running sessions.
- `packages/insomnia/src/routes/organization.$organizationId.permissions.tsx`: merge the
**current** organization's features result back into the cache so its flag is always fresh.
5. Add a `getKonnectSyncEnabled(accountId)` helper for reading the cache.
Cost: 99% of users have a single organization → one extra request at startup, batched alongside
the three `syncOrganizations` already issues.
6. Visibility condition = `konnectSyncEnabled || hasLocalKonnectProjects`, computed in the
`organization.tsx` `clientLoader`.
`hasLocalKonnectProjects` **must be scoped to the current account** — the NeDB database has no
per-user isolation, so a second account on the same machine would otherwise make this true.
Query with an explicit `parentId` allow-list:
```
services.project.count({
konnectControlPlaneId: { $ne: null },
parentId: { $in: [getKonnectOrganizationId(accountId), ...orgIdsFrom(`${accountId}:spaces`)] },
})
```
- `getKonnectOrganizationId(accountId)` covers the post-migration state.
- The real organization ids from `${accountId}:spaces` cover the pre-migration state, so the
organization is already visible during the window before migration finishes (and, in the
conflict case, before the user has chosen).
- Everything else is another account's data (decision 3 orphans) and is deliberately excluded.
7. Sync button `isDisabled = !konnectSyncEnabled`, with a tooltip explaining why.
### Phase 2 — Fake organization injection + route/API guards (*depends on Phase 0/1*)
> **Key rule discovered during audit — two classes of `${accountId}:spaces` readers:**
> (a) readers going through `useOrganizationLoaderData()` get the injected fake org for free
> (`use-plan.tsx`, `invite-form.tsx`, `invite-modal.tsx`, `project-type-warning.tsx`,
> `import-export.tsx`, `project.$projectId._index.tsx`);
> (b) loaders that read `localStorage` **directly** never see it and must be fixed one by one —
> `organization.$organizationId.permissions.tsx`, `untracked-projects.tsx`,
> `git-credentials.$id.related-projects.tsx`.
> Do not assume injection in `organization.tsx` is enough.
#### 2A — Injection & visibility
8. `packages/insomnia/src/routes/organization.tsx` `clientLoader`: when the visibility condition
holds, prepend `buildKonnectOrganization(accountId, { isOwner })` to `organizations` (it must not
participate in name sorting), and also return `konnectSyncEnabled`.
9. `packages/insomnia/src/ui/components/project/organization-select.tsx`: render `<KongLogo />` for
`isKonnectOrganizationId(item.id)` in both the `ListBoxItem` and the trigger `SelectValue`.
Leave "Join an organization" / "Create an organization" as-is — still valid actions.
#### 2B — Loader / action guards (no doomed network calls)
10. `organization.$organizationId.permissions.tsx`: replace `isScratchpadOrganizationId` with
`isLocalOrganizationId` and return `fallbackFeatures` / `fallbackBilling`. This **must** return
before the `organizations.find()` lookup, otherwise the fake org hits
`throw redirect('/organization')` and the user is bounced out.
Note: the sidebar's `konnectSyncEnabled` no longer comes from this loader — it reads the
aggregated cache from Phase 1.
11. `packages/insomnia/src/common/organization-storage-rules.ts`: return hardcoded local-only rules
for the Konnect organization (`enableCloudSync: false`, `enableLocalVault: true`,
`enableGitSync: false`, `isOverridden: false`).
12. `organization.$organizationId._index.tsx` plus `syncProjects` / `migrateProjectsUnderOrganization`
in `organization-utils.ts`: skip the Konnect organization entirely.
13. **Collaborator / member routes — all currently unguarded**, add `isLocalOrganizationId`
short-circuits returning empty results:
- `organization.$organizationId.collaborators.tsx` — `getCollaborators()`
- `organization.$organizationId.collaborators-search.tsx` — `searchCollaborators()`
- `organization.$organizationId.collaborators-check-seats.tsx` — `checkSeats()`
- `organization.$organizationId.collaborators.invites.$invitationId.tsx` — `updateInvitationRole()`
- `organization.$organizationId.collaborators.invites.$invitationId.reinvite.tsx` — `reinvite()`
- `organization.$organizationId.members.$userId.roles.tsx` — `updateUserRoles()`
14. **Project mutation routes — 6+ unguarded `updateGitProjectCount()` / team-project calls:**
- `organization.$organizationId.project.new.tsx` — `createTeamProject()` and
`reportGitProjectCount()`
- `organization.$organizationId.project.$projectId.update.tsx` — `reportGitProjectCount()`
plus `createTeamProject()` / `updateTeamProject()`
- `organization.$organizationId.project.$projectId.delete.tsx` — `reportGitProjectCount()`
Even though the New Project button is hidden (step 19) and Konnect projects are local-only,
guard the actions themselves — they are reachable via the command palette and project dropdown.
15. `packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx`: guard **both**
unguarded calls — `getRealTimeCollaborators()` and the
`new EventSource('insomnia-event-source://v1/teams/<org>/streams')`.
16. Guard the invite surface (`getOrganizationFeatures`, `getOrganizationRoles`,
`getOrganizationMemberRoles`, `getOrgUserPermissions`, `revokeInvitation`,
`deleteOrganizationMember`, `unlinkCollaborator`). Since the invite entry point is hidden
(step 18) this is defence-in-depth.
17. **Extend the existing scratchpad guards on the storage-rules / workspaces fetchers** to
`isLocalOrganizationId` — these already exist but only cover scratchpad:
- `organization.$organizationId.project._index.tsx`
- `organization.$organizationId.project.$projectId._index.tsx`
- `organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx`
- `packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx`
- `packages/insomnia/src/ui/components/modals/request-settings-modal.tsx`
- `packages/insomnia/src/ui/hooks/use-organization-features.tsx`
- the storage-rules fetcher in `organization.$organizationId.project.$projectId.tsx`
- `packages/insomnia/src/ui/components/settings/import-export.tsx` (feature fetch only)
#### 2C — UI gating
18. `packages/insomnia/src/routes/organization.tsx` component layer: hide `HeaderInviteButton` and
`PresentUsers`; also hide the untracked-data banner. Keep `HeaderPlanIndicator` — it is
account-scoped (see `docs/header-plan-indicator-data-scope.md`) and its loader
(`packages/insomnia/src/routes/resource.usage.tsx`) never passes an `organizationId`.
Related detail: `usePlanData` (`packages/insomnia/src/ui/hooks/use-plan.tsx`) derives
`isOwner` from `organizations.find(o => o.id === organizationId)?.is_owner`. Do **not** hardcode
`is_owner: true` in `buildKonnectOrganization` — copy it from the user's real organizations,
otherwise an enterprise member sees the wrong "upgrade yourself" CTA in
`UpgradeNotice` / `upgrade-modal`.
19. Hide `NewProjectButton` in the sidebar for the Konnect organization (detail in step 22).
#### 2D — Direct-localStorage loaders + routing
20. Add the Konnect organization id to the accepted-org-id lists in the loaders that read
localStorage directly:
- `packages/insomnia/src/routes/untracked-projects.tsx` — otherwise Konnect projects are
reported as untracked and the migration banner appears.
- `packages/insomnia/src/routes/git-credentials.$id.related-projects.tsx` — same
`currentUserOrganizationIds` pattern.
21. `packages/insomnia/src/ui/utils/router.ts` `getInitialEntry()`: `lastVisitedOrganizationId` may
be the Konnect organization, which is absent from `${accountId}:spaces`. The validation must
additionally accept `isKonnectOrganizationId`, otherwise it falls back to the first real
organization on every restart.
#### 2E — Explicitly NOT extended to the Konnect org (audit conclusions)
- `organization.tsx` — `{!isScratchPad && <OrganizationSelect/>}`. **Must stay scratchpad-only.**
Hiding the dropdown would trap the user inside Control Planes with no way out.
- `insomnia-tab-context.tsx` — "navigate to project dashboard when all tabs close" is skipped for
scratchpad because it has no dashboard. Control Planes *does*, so keep navigating.
- `root.tsx` deep-link guards — scratchpad is gated there because the user is logged out; Konnect
users are authenticated, so deep links should behave normally. Verify only.
- `workspace-pane-header.tsx` — breadcrumb slicing is a scratchpad single-project quirk.
- `entry.main.ts` — do **not** mirror the scratchpad "auto-create project/workspace on startup";
Konnect projects come exclusively from sync.
- `sidebar-workspace-dropdown.tsx` / `workspace-settings-modal.tsx` scratchpad read-only
restrictions — Konnect workspaces stay normal editable collections; only the delete/remove
wording differs, which existing `konnectControlPlaneId` checks already handle.
- `import-modal.tsx` multi-file restriction and `import-export.tsx` "hide import tab" — both are
scratchpad-workspace quirks.
- `project-node.tsx` project context menu — per decision 5, Konnect keeps the current menu.
- `common/sentry.ts` `LandingPage.Scratchpad` — no new Sentry tag needed.
### Phase 3 — Remove the sidebar tabs (*depends on Phase 2*)
22. `project-navigation-sidebar.tsx`:
- Delete `SideBarTabList`, the `Tabs` wrapper, `ProjectNavigationSidebarTabId`, and the
`activeTab` / `setActiveTab` props.
- Replace every `isProjectTabActive` branch with `isKonnectOrganizationId(organizationId)`.
- `showKonnectSyncIntro` becomes `isKonnectOrganization && !hasKonnectPat`.
- Drop the `konnectControlPlaneId` filtering in `projectsWithPresence` and the
`nonKonnectProjects` / `konnectProjects` split.
- Do not render `NewProjectButton` inside the Konnect organization.
- Collapse the two filter states (`filterInputValue` / `konnectFilterInputValue`) into one.
23. `EmptyProjectNavigationSidebar`: render `KonnectSyncIntro` when inside the Konnect organization
without a PAT; render the gear button row when a PAT exists but there are no projects.
24. `organization.$organizationId.project.$projectId.tsx` — four separate things, not one:
- **`clientLoader` reads `${organizationId}:sidebar-tab` from localStorage** to decide the
"project not found" redirect (if the user was on the Konnect tab, redirect to another Konnect
project). Rewrite to key off the *organization* instead — fall back to another project in the
same organization.
- Remove the `useLocalStorage('${organizationId}:sidebar-tab')` state and the
`activeSidebarTab = !features.konnectSync.enabled ? 'projects' : …` derivation.
- Remove `activeTab` / `setActiveTab` from the `<ProjectNavigationSidebar>` props.
- **Replace `activeSidebarTab` / `setActiveSidebarTab` in `ProjectRouteContextValue` and the
Outlet context** with `isKonnectOrganization`.
25. **Outlet-context consumer that is easy to miss:**
`organization.$organizationId.project.$projectId._index.tsx` reads
`const { activeSidebarTab } = useProjectRouteContext()` and gates `FirstRequestCreation`
on `activeSidebarTab === 'projects'`. Re-gate on `!isKonnectOrganization`.
26. `use-project-navigation-sidebar-navigation.ts`: delete `setActiveTab` from the hook signature,
the `setActiveTabRef`, and the auto-switch
(`resources.project.konnectControlPlaneId != null ? 'konnect' : 'projects'`). The rest of the
hook (selection, expansion, `scrollToIndex`) is tab-independent and stays.
27. `use-sidebar-drag-and-drop.tsx`: `canDrop` only blocks cross-project moves for **remote**
projects. Konnect projects are local, so a collection workspace can be dragged between two
Konnect projects — which would orphan its `konnectServiceId` and make the next sync delete or
duplicate it. Add an explicit guard rejecting cross-project drops when either side has
`konnectControlPlaneId`.
28. `use-konnect-sync.ts` call sites: pass the Konnect organization id. `konnect/sync.ts` internals
are unchanged.
29. `konnect-settings-modal.tsx` / sync orchestration ownership:
- **Move the sync trigger out of the sidebar** so the modal no longer depends on the sidebar
being mounted, per decision 8. No Settings panel is added in this change.
- After disconnecting, if the organization becomes invisible, redirect the current route to the
first real organization.
30. **Orphaned localStorage keys** — decide explicitly:
- `${organizationId}:sidebar-tab` — fully dead.
- `${organizationId}:project-navigation-konnect-filter` — fully dead.
- `${organizationId}:konnect-last-synced-at` — the value now lives under the Konnect org id;
per decision 10 the migration copies the old value across (step 31).
Keys that stay valid: `${organizationId}:nav-expanded-projects-and-workspaces`,
`${organizationId}:local-workspace-orders`.
### Phase 4 — One-time migration (*depends on Phase 0; modal depends on Phase 2*)
> **Audit finding — database buffering is NOT re-entrant.**
> `database.bufferChanges()` increments a module-level `bufferChangesId`, and `flushChanges(id)`
> only acts when `bufferChangesId === id`. `db.remove()` internally calls `bufferChanges()` and then
> `flushChanges(itsOwnId)` — so wrapping `services.project.remove()` calls in an outer
> `bufferChangesIndefinitely()` is **ineffective**: the first inner remove flushes the outer buffer
> and clears `bufferingChanges`, and the outer `flushChanges(outerId)` becomes a no-op.
> The existing disconnect flow in `konnect-settings-modal.tsx` has this latent bug.
> Consequence for us: buffering works for the **`update()` (parentId rewrite)** path and should be
> used there; do not pretend it batches the **delete** path.
31. New file `packages/insomnia/src/konnect/migrate-konnect-organization.ts`:
- `detectKonnectOrgMigration({ accountId })` — list projects with
`konnectControlPlaneId != null`, group by `parentId`, drop those already under the Konnect
org, drop those whose `parentId` is not in `${accountId}:spaces` (orphans, per decision 3),
and return `{ status: 'none' | 'auto' | 'conflict', groups }` where each group carries
`organizationId`, `organizationName`, `projectCount`, `workspaceCount`.
- `runKonnectOrgMigration({ accountId, keepOrganizationId })` —
wrap the `parentId` rewrites in `bufferChangesIndefinitely()` / `flushChanges()` in a
`try/finally`; run the deletions for non-selected groups **outside** that buffer via
`services.project.remove` (which cascades through `getWithDescendants()` to workspaces,
requests, environments, cookie jars and meta docs).
- Take `projectLock` (`~/common/project`, the same lock `syncProjects` uses) around the whole
operation so it cannot interleave with a concurrent project sync.
- Copy `${keepOrganizationId}:konnect-last-synced-at` to
`${getKonnectOrganizationId(accountId)}:konnect-last-synced-at` (decision 10).
- **Do not add a `${accountId}:konnect-org-migrated` flag.** The repo has no such convention —
`migrateProjectsUnderOrganization` relies purely on an idempotent detection predicate — and a
flag would prevent re-prompting after the user dismisses the conflict modal.
32. Trigger points — mirror **both** call sites of the existing `migrateProjectsUnderOrganization()`,
because a normal app start does *not* pass through `organization._index.tsx`
(`getInitialEntry()` jumps straight to the project route):
- `packages/insomnia/src/routes/organization._index.tsx` `clientLoader` (login-redirect path).
- the `AsyncTask.MigrateProjects` branch of
`packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx` (startup path).
`auto` runs immediately; `conflict` is surfaced to the UI and nothing is migrated until the user
chooses.
Concurrency: the tasks there run via `Promise.all`, so the migration runs alongside
`syncProjects`. **Verified safe** — `syncTeamProjects` never deletes projects; it only nulls
`remoteId` for projects matching `parentId: organizationId, remoteId: { $nin: …, $ne: null }`,
and Konnect projects have `remoteId: null`, so they are excluded. The `clientAction` also
blocks until `Promise.all` resolves, so the renderer never observes a half-migrated state.
33. New component `packages/insomnia/src/ui/components/modals/konnect-org-migration-modal.tsx`:
single-select list of candidate organizations (name + Konnect project count), explicit warning
that the non-selected data will be deleted, then calls `runKonnectOrgMigration`.
34. Post-migration route hygiene: `locationHistoryEntry:${organizationId}` may still point at a
Konnect project that has moved organizations. `getInitialRouteForOrganization()` already
validates that the project exists and falls back, so this should self-heal — verify rather than
code around it.
### Phase 5 — Tests & cleanup
35. Smoke tests:
- `packages/insomnia-smoke-test/tests/smoke/konnect.test.ts` — replace the
`sidebar-tab-konnect` / `sidebar-tab-projects` clicks with navigating to Control Planes via
the organization dropdown; replace the "tab hidden when flag off" case with "organization
absent from the dropdown when flag off".
- `packages/insomnia-smoke-test/playwright/pages/components/navigation-sidebar.ts` — remove or
repurpose `clickProjectsTab()` / `clickKonnectTab()`.
- `packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts` toggles the `konnectSync`
flag — confirm it still passes now that the flag is aggregated, not per-org.
36. Add `packages/insomnia/src/konnect/__tests__/migrate-konnect-organization.test.ts` covering
auto / conflict / orphan-ignored / idempotency, plus a case asserting that deleting a
non-selected group also removes its workspaces and requests.
Harness: `packages/insomnia/setup-vitest.ts` already runs
`initDatabase(mainDatabase, { inMemoryOnly: true }, true)` + `initServices(...)`.
37. Run `npm run lint`, `npm run type-check`, `npm test -w packages/insomnia`.
## Verification
1. Single account, single organization with existing Konnect data → no modal on startup, data
appears under Control Planes, the original organization's project list is clean.
2. Two organizations each holding Konnect data → modal on startup; after choosing one, the other
group is deleted; restarting does not prompt again.
3. All organizations report `konnectSync = false` and there is no local data → Control Planes is
absent from the dropdown.
4. All false but local Konnect data exists → organization is visible, Sync button disabled.
5. Inside Control Planes with DevTools Network open → no `features`, `storage-rule`,
`team-projects`, `collaborators`, `check-seats`, `presence` or `streams` requests are issued.
6. Inside Control Planes: reload the page, enter via deep link, delete the last project → no blank
screen and no redirect to login. Specifically confirm `permissions.tsx` does not bounce the user
back to `/organization`.
7. Switch to Control Planes and restart the app → `lastVisitedOrganizationId` restores correctly.
8. Inside Control Planes the organization dropdown is still rendered and can switch back to a real
organization (regression guard for step 2E).
9. Inside Control Planes the untracked-projects banner does not appear, and Konnect projects are
absent from the untracked list.
10. `HeaderPlanIndicator` shows the same plan/usage inside Control Planes as in a real organization.
11. Sign in as account B on the same machine while account A's Konnect data exists → Control Planes
is not visible to B on the strength of A's data alone.
12. Trigger a project create/update/delete action inside Control Planes via the command palette or
project dropdown → no `team-projects` or `PATCH /organizations/:id` request is issued.
### Phase 3 specific
13. Delete the currently open Konnect project while inside Control Planes → the loader falls back to
another project in the same organization, not to a real organization's project list.
14. Open an empty Konnect project → `FirstRequestCreation` does not appear.
15. Attempt to drag a collection workspace from one Konnect project into another → the drop is
rejected. Then re-sync and confirm no workspace was duplicated or deleted.
16. Connect a PAT from the gear icon → sync starts automatically (i.e. the sync trigger is still
wired after the tab removal).
17. Disconnect the PAT while inside Control Planes with the flag off → all Konnect projects are
removed, the organization disappears from the dropdown, and the app navigates to a real
organization instead of sitting on a dead route.
18. Enter a real organization → no Sync/gear buttons, no sync-progress line, no sync-result panel,
and the New Project button is present.
### Phase 4 specific
19. Conflict case: after choosing an organization, confirm the non-selected organization's Konnect
projects **and their workspaces, requests, environments and cookie jars** are all gone from the
database, not just the project documents.
20. Dismiss the conflict modal without choosing → nothing is migrated, and the modal appears again
on the next start (no idempotency flag suppressing it).
21. Run the migration with a large data set and confirm the sidebar does not flicker per-document
during the `parentId` rewrite (buffering is effective on the update path), and that the app is
not left permanently non-revalidating afterwards (buffer was flushed in `finally`).
22. Trigger a startup where migration and `syncProjects` run concurrently → Konnect projects are
neither deleted nor have their `remoteId` mutated.
23. After migration, restart the app → `locationHistoryEntry:<oldOrgId>` pointing at a moved project
self-heals via `getInitialRouteForOrganization()` rather than landing on a broken route.
24. After migration, the Sync button tooltip shows the previous "Last synced" timestamp rather than
"Not yet synced" (decision 10 carry-over).
## Scope
**Included:** the fake organization, aggregated feature flag, route/API guards, sidebar tab removal,
one-time migration with conflict resolution, tests.
**Excluded:** the "Dev Portals" organization visible in the reference design; any backend changes;
changes to the Konnect sync algorithm itself (`konnect/sync.ts` internals).
## Implementation Notes
Deviations from the plan as built:
- **Step 29** — instead of a React context, the sync trigger lives in a small module-level registry
(`packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts`, `registerKonnectSyncTrigger` /
`runKonnectSync`). This removes the `syncKonnectProjectsAndNotifyRef` prop from
`KonnectSettingsModal` entirely and no-ops when the sidebar is not mounted.
- **Step 16** — the guard sits in `HeaderInviteButton` itself (returning before
`getOrgUserPermissions`) rather than inside the invite modal, since the modal is only reachable
through that button.
- **Step 12** — the `syncProjects` guard was moved *above* `getAllTeamProjects`; the original
scratchpad guard sat after it and the `fetchTeamProjects` request had already been issued.
- **Step 32** — the startup entry point is `migrateKonnectProjectsIfUnambiguous(accountId)`, which
runs `detectKonnectOrgMigration` and only auto-migrates the unambiguous case. The conflict modal
is mounted in `organization.tsx` and calls `runKonnectOrgMigration` directly rather than going
through the `showModal` registry, because it needs to trigger a router revalidation on completion.

View File

@@ -1,8 +1,34 @@
import type { PersonalPlanType } from 'insomnia-api';
import type { Organization, PersonalPlanType } from 'insomnia-api';
export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad';
export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID;
export const KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_';
export const KONNECT_ORGANIZATION_NAME = 'Control Planes';
// The Konnect organization is local-only, but the database is not partitioned per user, so the
// account id is baked into the id to keep one account's Konnect data out of another's.
export const getKonnectOrganizationId = (accountId: string) => `${KONNECT_ORGANIZATION_ID_PREFIX}${accountId}`;
export const isKonnectOrganizationId = (organizationId: string) =>
organizationId.startsWith(KONNECT_ORGANIZATION_ID_PREFIX);
/** Organizations that exist only on this machine and must never be used for organization-scoped API calls. */
export const isLocalOrganizationId = (organizationId: string) =>
isScratchpadOrganizationId(organizationId) || isKonnectOrganizationId(organizationId);
export const buildKonnectOrganization = (accountId: string, { isOwner }: { isOwner: boolean }): Organization => ({
id: getKonnectOrganizationId(accountId),
name: KONNECT_ORGANIZATION_NAME,
picture: null,
owner_first_name: null,
owner_last_name: null,
owner_email: null,
total_members: 1,
total_invites: 0,
is_owner: isOwner,
can_leave: false,
});
export const formatCurrentPlanType = (type: PersonalPlanType) => {
switch (type) {
case 'free': {

View File

@@ -15,15 +15,16 @@ export class NavigationSidebar {
}
// ===========================================================================
// Tab controls
// Organization switching
// ===========================================================================
async clickProjectsTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-projects').click();
async selectOrganization(name: string): Promise<void> {
await this.page.getByRole('button', { name: 'Organizations' }).click();
await this.page.getByRole('option', { name }).click();
}
async clickKonnectTab(): Promise<void> {
await this.root.getByTestId('sidebar-tab-konnect').click();
async openControlPlanesOrganization(): Promise<void> {
await this.selectOrganization('Control Planes');
}
// ===========================================================================

View File

@@ -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();
});
});
});

View File

@@ -18,7 +18,7 @@ export async function fetchAndCacheOrganizationStorageRule(
): Promise<StorageRules> {
invariant(organizationId, 'Organization ID is required');
if (models.organization.isScratchpadOrganizationId(organizationId)) {
if (models.organization.isLocalOrganizationId(organizationId)) {
return {
enableCloudSync: false,
enableLocalVault: true,

View File

@@ -0,0 +1,165 @@
/**
* Tests run against the in-memory NeDB initialized by setup-vitest.ts.
* localStorage is stubbed per-test to supply the cached organization list.
*/
import { initDatabase, models, services } from 'insomnia-data';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mainDatabase } from '../../main/database.main';
import {
detectKonnectOrgMigration,
migrateKonnectProjectsIfUnambiguous,
runKonnectOrgMigration,
} from '../migrate-konnect-organization';
const ACCOUNT_ID = 'acct_1';
const ORG_A = 'org_a';
const ORG_B = 'org_b';
const KONNECT_ORG_ID = models.organization.getKonnectOrganizationId(ACCOUNT_ID);
function stubLocalStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial));
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
});
return store;
}
async function createKonnectProject(parentId: string, controlPlaneId: string) {
const project = await services.project.create({
name: `CP ${controlPlaneId}`,
parentId,
konnectControlPlaneId: controlPlaneId,
});
await services.workspace.create({
name: `Service of ${controlPlaneId}`,
parentId: project._id,
scope: 'collection',
konnectServiceId: `svc-${controlPlaneId}`,
});
return project;
}
const listKonnectProjects = () => services.project.list({ konnectControlPlaneId: { $ne: null } });
beforeEach(async () => {
await initDatabase(mainDatabase, { inMemoryOnly: true }, true);
stubLocalStorage({
[`${ACCOUNT_ID}:spaces`]: JSON.stringify([
{ id: ORG_A, name: 'Org A' },
{ id: ORG_B, name: 'Org B' },
]),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('detectKonnectOrgMigration', () => {
it('reports nothing to do when there are no Konnect projects', async () => {
expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});
it('reports a single source organization as auto-migratable', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_A, 'cp-2');
const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID });
expect(plan.status).toBe('auto');
expect(plan.groups).toEqual([
{ organizationId: ORG_A, organizationName: 'Org A', projectCount: 2, workspaceCount: 2 },
]);
});
it('reports multiple source organizations as a conflict', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_B, 'cp-2');
const plan = await detectKonnectOrgMigration({ accountId: ACCOUNT_ID });
expect(plan.status).toBe('conflict');
expect(plan.groups.map(g => g.organizationId).sort()).toEqual([ORG_A, ORG_B]);
});
it('ignores Konnect projects owned by another account on the same machine', async () => {
await createKonnectProject('org_someone_else', 'cp-1');
expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});
it('ignores Konnect projects that already live under the Konnect organization', async () => {
await createKonnectProject(KONNECT_ORG_ID, 'cp-1');
expect(await detectKonnectOrgMigration({ accountId: ACCOUNT_ID })).toEqual({ status: 'none', groups: [] });
});
});
describe('runKonnectOrgMigration', () => {
it('re-parents the chosen organization and deletes the rest with their descendants', async () => {
await createKonnectProject(ORG_A, 'cp-1');
const discarded = await createKonnectProject(ORG_B, 'cp-2');
await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });
const projects = await listKonnectProjects();
expect(projects).toHaveLength(1);
expect(projects[0].parentId).toBe(KONNECT_ORG_ID);
expect(projects[0].konnectControlPlaneId).toBe('cp-1');
expect(await services.workspace.count({ parentId: discarded._id })).toBe(0);
});
it('leaves another account\'s Konnect projects untouched', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject('org_someone_else', 'cp-other');
await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });
const projects = await listKonnectProjects();
expect(projects.map(p => p.parentId).sort()).toEqual([KONNECT_ORG_ID, 'org_someone_else'].sort());
});
it('carries the last-synced timestamp over to the Konnect organization', async () => {
const store = stubLocalStorage({
[`${ACCOUNT_ID}:spaces`]: JSON.stringify([{ id: ORG_A, name: 'Org A' }]),
[`${ORG_A}:konnect-last-synced-at`]: '1700000000000',
});
await createKonnectProject(ORG_A, 'cp-1');
await runKonnectOrgMigration({ accountId: ACCOUNT_ID, keepOrganizationId: ORG_A });
expect(store.get(`${KONNECT_ORG_ID}:konnect-last-synced-at`)).toBe('1700000000000');
expect(store.get(`${ORG_A}:konnect-last-synced-at`)).toBeUndefined();
});
});
describe('migrateKonnectProjectsIfUnambiguous', () => {
it('migrates automatically and is idempotent on a second run', async () => {
await createKonnectProject(ORG_A, 'cp-1');
expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] });
const afterFirstRun = await listKonnectProjects();
expect(afterFirstRun.map(p => p.parentId)).toEqual([KONNECT_ORG_ID]);
expect(await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID)).toEqual({ status: 'none', groups: [] });
expect(await listKonnectProjects()).toHaveLength(1);
});
it('defers to the user when the source organization is ambiguous', async () => {
await createKonnectProject(ORG_A, 'cp-1');
await createKonnectProject(ORG_B, 'cp-2');
const plan = await migrateKonnectProjectsIfUnambiguous(ACCOUNT_ID);
expect(plan.status).toBe('conflict');
// Nothing moved until the user picks one.
const projects = await listKonnectProjects();
expect(projects.map(p => p.parentId).sort()).toEqual([ORG_A, ORG_B]);
});
});

View File

@@ -0,0 +1,147 @@
import type { Organization } from 'insomnia-api';
import type { Project } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { database } from '~/common/database';
import { projectLock } from '~/common/project';
export interface KonnectMigrationGroup {
organizationId: string;
organizationName: string;
projectCount: number;
workspaceCount: number;
}
export interface KonnectMigrationPlan {
/** `auto` means a single source organization, `conflict` means the user has to choose one. */
status: 'none' | 'auto' | 'conflict';
groups: KonnectMigrationGroup[];
}
const lastSyncedAtKey = (organizationId: string) => `${organizationId}:konnect-last-synced-at`;
function readCachedOrganizations(accountId: string): Organization[] {
try {
return JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
} catch {
return [];
}
}
async function listMigratableKonnectProjects(accountId: string): Promise<{
projects: Project[];
organizationNameById: Map<string, string>;
}> {
const organizationNameById = new Map(readCachedOrganizations(accountId).map(o => [o.id, o.name]));
if (organizationNameById.size === 0) {
return { projects: [], organizationNameById };
}
// Konnect projects parented to an organization this account does not belong to came from another
// account sharing the same database; they are deliberately left alone.
const projects = await services.project.list({
konnectControlPlaneId: { $ne: null },
parentId: { $in: [...organizationNameById.keys()] },
});
return { projects, organizationNameById };
}
export async function detectKonnectOrgMigration({ accountId }: { accountId: string }): Promise<KonnectMigrationPlan> {
if (!accountId) {
return { status: 'none', groups: [] };
}
const { projects, organizationNameById } = await listMigratableKonnectProjects(accountId);
if (projects.length === 0) {
return { status: 'none', groups: [] };
}
const projectsByOrganizationId = new Map<string, Project[]>();
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<KonnectMigrationPlan> {
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<void> {
if (!accountId) {
return;
}
const konnectOrganizationId = models.organization.getKonnectOrganizationId(accountId);
await projectLock.lock();
try {
const { projects } = await listMigratableKonnectProjects(accountId);
const projectsToKeep = projects.filter(p => p.parentId === keepOrganizationId);
const projectsToRemove = projects.filter(p => p.parentId !== keepOrganizationId);
if (projectsToKeep.length > 0) {
const bufferId = await database.bufferChangesIndefinitely();
try {
for (const project of projectsToKeep) {
await services.project.update(project, { parentId: konnectOrganizationId });
}
} finally {
await database.flushChanges(bufferId);
}
}
// `project.remove` buffers and flushes internally, so it must not run inside the buffer above.
for (const project of projectsToRemove) {
await services.project.remove(project);
}
try {
const previousLastSyncedAt = localStorage.getItem(lastSyncedAtKey(keepOrganizationId));
if (previousLastSyncedAt && !localStorage.getItem(lastSyncedAtKey(konnectOrganizationId))) {
localStorage.setItem(lastSyncedAtKey(konnectOrganizationId), previousLastSyncedAt);
}
localStorage.removeItem(lastSyncedAtKey(keepOrganizationId));
} catch {
// A stale timestamp is cosmetic; never fail the migration over it.
}
} finally {
await projectLock.unlock();
}
}

View File

@@ -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));

View File

@@ -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

View File

@@ -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());

View File

@@ -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;

View File

@@ -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');

View File

@@ -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());

View File

@@ -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');

View File

@@ -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 {

View File

@@ -81,7 +81,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const Component = ({ loaderData }: Route.ComponentProps) => {
const { localFiles, remoteFilesPromise } = loaderData;
const { activeProject, activeProjectGitRepository, projects } = useProjectLoaderData()!;
const { activeSidebarTab } = useProjectRouteContext();
const { isKonnectOrganization } = useProjectRouteContext();
const { organizationId, projectId } = useParams() as {
organizationId: string;
projectId: string;
@@ -119,7 +119,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
'There are issues with one or more Insomnia files in this project. Use the git CLI and your local file system to resolve them and continue.';
useEffect(() => {
if (!models.organization.isScratchpadOrganizationId(organizationId)) {
if (!models.organization.isLocalOrganizationId(organizationId)) {
const load = storageRuleFetcher.load;
load({ organizationId });
}
@@ -365,7 +365,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<Fragment>
<OrganizationTabList showActiveStatus={false} />
<div className="px-4 pt-4">
{activeSidebarTab === 'projects' && (
{!isKonnectOrganization && (
<FirstRequestCreation
collectionItems={collectionItems}
selectedCollectionId={selectedCollectionId}

View File

@@ -1,6 +1,6 @@
import { getLearningFeature } from 'insomnia-api';
import { models, services } from 'insomnia-data';
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Button, Heading } from 'react-aria-components';
import { type ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { href, Outlet, redirect, useOutletContext, useParams, useRouteLoaderData, useSearchParams } from 'react-router';
@@ -17,13 +17,11 @@ import { ScratchPadTutorialPanel } from '~/ui/components/panes/scratchpad-tutori
import {
ProjectNavigationSidebar,
type ProjectNavigationSidebarHandle,
type ProjectNavigationSidebarTabId,
} from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar';
import { SyncBar } from '~/ui/components/sidebar/sync-bar';
import { useSidebarContext } from '~/ui/context/app/insomnia-sidebar-context';
import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils';
import type { Route } from './+types/organization.$organizationId.project.$projectId';
@@ -71,27 +69,16 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const project = await services.project.getById(projectId);
if (!project) {
// When a project is not found (e.g., after deletion), check if user was on Konnect tab
// and try to redirect to another Konnect project to avoid switching tabs
const storedTab = localStorage.getItem(`${organizationId}:sidebar-tab`);
if (storedTab) {
try {
const parsedTab = JSON.parse(storedTab);
if (parsedTab === 'konnect') {
const allProjects = await services.project.listByOrganizationIds(organizationId);
const konnectProjects = models.project.sortProjects(allProjects.filter(p => 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<SetStateAction<ProjectNavigationSidebarTabId | undefined>>;
isKonnectOrganization: boolean;
}
export function useProjectRouteContext() {
@@ -181,12 +167,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
}
}, [isSidebarCollapsed]);
const { features } = useOrganizationPermissions();
const [storedSidebarTab, setActiveSidebarTab] = reactUse.useLocalStorage<ProjectNavigationSidebarTabId>(
`${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) => {
>
<div className="flex flex-1 flex-col divide-y divide-solid divide-(--hl-md) overflow-hidden">
<ProjectNavigationSidebar
activeTab={activeSidebarTab}
storageRules={storageRules}
konnectSyncEnabled={features.konnectSync.enabled}
onCreateProject={() => setIsNewProjectModalOpen(true)}
setActiveTab={setActiveSidebarTab}
ref={navigationSidebarRef}
/>
{isScratchPad && <ScratchPadTutorialPanel />}
@@ -264,8 +242,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<GitFileIssuesProvider value={gitFileIssues}>
<Outlet
context={{
activeSidebarTab,
setActiveSidebarTab,
isKonnectOrganization,
}}
/>
</GitFileIssuesProvider>

View File

@@ -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');

View File

@@ -215,7 +215,7 @@ const Component = ({ params }: Route.ComponentProps) => {
const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` });
useEffect(() => {
if (!models.organization.isScratchpadOrganizationId(organizationId)) {
if (!models.organization.isLocalOrganizationId(organizationId)) {
const load = storageRuleFetcher.load;
load({ organizationId });
}

View File

@@ -100,7 +100,7 @@ const Component = () => {
const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` });
useEffect(() => {
if (!models.organization.isScratchpadOrganizationId(organizationId)) {
if (!models.organization.isLocalOrganizationId(organizationId)) {
const load = storageRuleFetcher.load;
load({ organizationId });
}

View File

@@ -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({

View File

@@ -3,8 +3,9 @@ import { services } from 'insomnia-data';
import { href, redirect } from 'react-router';
import { invariant } from '~/common/utils/invariant';
import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization';
import * as session from '~/ui/account/session';
import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncOrganizations } from '~/ui/organization-utils';
import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncKonnectSyncEnabled, syncOrganizations } from '~/ui/organization-utils';
import type { Route } from './+types/organization._index';
@@ -18,6 +19,8 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) {
const landingOrganizationId = organizations[0].id;
await migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId);
await syncKonnectSyncEnabled(sessionId, accountId);
await migrateKonnectProjectsIfUnambiguous(accountId);
const specificOrgRedirectAfterAuthorize = window.localStorage.getItem('specificOrgRedirectAfterAuthorize');
if (specificOrgRedirectAfterAuthorize && specificOrgRedirectAfterAuthorize !== '') {

View File

@@ -3,7 +3,8 @@ import { services } from 'insomnia-data';
import { href, redirect } from 'react-router';
import { invariant } from '~/common/utils/invariant';
import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncOrganizations, syncProjects } from '~/ui/organization-utils';
import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization';
import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization, syncKonnectSyncEnabled, syncOrganizations, syncProjects } from '~/ui/organization-utils';
import { AsyncTask, createFetcherSubmitHook } from '~/ui/utils/router';
import type { Route } from './+types/organization.sync-organizations-and-projects';
@@ -28,14 +29,27 @@ export async function clientAction({ request }: Route.ClientActionArgs) {
if (asyncTaskList.includes(AsyncTask.SyncOrganization)) {
invariant(sessionId, 'sessionId is required');
invariant(accountId, 'accountId is required');
taskPromiseList.push(syncOrganizations(sessionId, accountId));
// The Konnect flag is derived from the organization list, so it has to wait for that sync.
taskPromiseList.push(
asyncTaskList.includes(AsyncTask.SyncKonnectFeature)
? syncOrganizations(sessionId, accountId).then(() => syncKonnectSyncEnabled(sessionId, accountId))
: syncOrganizations(sessionId, accountId),
);
} else if (asyncTaskList.includes(AsyncTask.SyncKonnectFeature)) {
invariant(sessionId, 'sessionId is required');
invariant(accountId, 'accountId is required');
taskPromiseList.push(syncKonnectSyncEnabled(sessionId, accountId));
}
if (asyncTaskList.includes(AsyncTask.MigrateProjects)) {
const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
invariant(organizations.length, 'Failed to fetch organizations.');
invariant(sessionId, 'sessionId is required');
taskPromiseList.push(migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId));
taskPromiseList.push(
migrateProjectsUnderOrganization(findMigrationTargetSpaceId(organizations), sessionId).then(() =>
migrateKonnectProjectsIfUnambiguous(accountId),
),
);
}
if (asyncTaskList.includes(AsyncTask.SyncProjects)) {

View File

@@ -3,9 +3,11 @@ import type { Settings } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components';
import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router';
import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRevalidator, useRouteLoaderData } from 'react-router';
import * as reactUse from 'react-use';
import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organization';
import { detectKonnectOrgMigration } from '~/konnect/migrate-konnect-organization';
import { useRootLoaderData } from '~/root';
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects';
@@ -22,6 +24,7 @@ import { Icon } from '~/ui/components/icon';
import { InsomniaLogo } from '~/ui/components/insomnia-icon';
import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder';
import { showModal } from '~/ui/components/modals';
import { KonnectOrgMigrationModal } from '~/ui/components/modals/konnect-org-migration-modal';
import { SettingsModal, showSettingsModal } from '~/ui/components/modals/settings-modal';
import { PresentUsers } from '~/ui/components/present-users';
import { OrganizationSelect } from '~/ui/components/project/organization-select';
@@ -30,6 +33,7 @@ import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context';
import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context';
import { RunnerProvider } from '~/ui/context/app/runner-context';
import { useCloseConnection } from '~/ui/hooks/use-close-connection';
import { getKonnectSyncEnabled } from '~/ui/organization-utils';
import type { AsyncTask } from '~/ui/utils/router';
import type { Route } from './+types/organization';
@@ -40,22 +44,53 @@ export interface OrganizationLoaderData {
currentPlan?: CurrentPlan;
}
/**
* The Konnect organization is local-only, so it is surfaced when the account-wide feature flag is
* on, or when this account already has Konnect data from a previous version (in which case sync is
* disabled but the data stays reachable). The database is not partitioned per user, so the
* "existing data" probe is restricted to parents this account can actually own.
*/
async function getKonnectOrganization(accountId: string, organizations: Organization[]) {
const konnectOrganizationId = models.organization.getKonnectOrganizationId(accountId);
const konnectSyncEnabled = getKonnectSyncEnabled(accountId);
if (!konnectSyncEnabled) {
const existingKonnectProjectCount = await services.project.count({
konnectControlPlaneId: { $ne: null },
parentId: { $in: [konnectOrganizationId, ...organizations.map(o => o.id)] },
});
if (existingKonnectProjectCount === 0) {
return { konnectSyncEnabled, konnectOrganization: null };
}
}
return {
konnectSyncEnabled,
konnectOrganization: models.organization.buildKonnectOrganization(accountId, {
isOwner: organizations.some(o => o.is_owner === true),
}),
};
}
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const { id, accountId } = await services.userSession.get();
if (id) {
const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[];
const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as User;
const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan;
const { konnectSyncEnabled, konnectOrganization } = await getKonnectOrganization(accountId, organizations);
return {
organizations,
organizations: konnectOrganization ? [konnectOrganization, ...organizations] : organizations,
user,
currentPlan,
konnectSyncEnabled,
};
}
return {
organizations: [],
user: undefined,
currentPlan: undefined,
konnectSyncEnabled: false,
};
}
@@ -176,17 +211,23 @@ const LoginUserActions = ({
user: User;
currentPlan?: CurrentPlan;
}) => {
// Collaboration is meaningless in an organization that only exists on this machine.
const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId);
return (
<>
<PresentUsers />
<HeaderInviteButton
organizationId={organizationId}
className={
!isMinimal
? 'border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)'
: 'text-(--color-font)'
}
/>
{!isLocalOrganization && (
<>
<PresentUsers />
<HeaderInviteButton
organizationId={organizationId}
className={
!isMinimal
? 'border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)'
: 'text-(--color-font)'
}
/>
</>
)}
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</>
@@ -195,7 +236,9 @@ const LoginUserActions = ({
const Component = ({ loaderData }: Route.ComponentProps) => {
const { organizations, user, currentPlan } = loaderData;
const { settings } = useRootLoaderData()!;
const { settings, userSession } = useRootLoaderData()!;
const { revalidate } = useRevalidator();
const [konnectMigrationGroups, setKonnectMigrationGroups] = useState<KonnectMigrationGroup[]>([]);
const workspaceData = useWorkspaceLoaderData();
@@ -248,10 +291,22 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
return () => window.main.setCurrentOrganizationId(undefined);
}, [organizationId]);
useEffect(() => {
const accountId = userSession.accountId;
if (!accountId) {
return;
}
// The unambiguous case already migrated during startup; only a genuine conflict reaches the UI.
detectKonnectOrgMigration({ accountId }).then(plan => {
setKonnectMigrationGroups(plan.status === 'conflict' ? plan.groups : []);
});
}, [userSession.accountId]);
const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || [];
const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || [];
const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0;
const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID;
const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId);
useCloseConnection({
organizationId,
@@ -390,7 +445,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<Hotkey keyBindings={settings.hotKeyRegistry.preferences_showGeneral} />
</Tooltip>
</TooltipTrigger>
{!isScratchpadWorkspace && hasUntrackedData && (
{!isScratchpadWorkspace && !isLocalOrganization && hasUntrackedData && (
<TooltipTrigger delay={500}>
<Button
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-warning) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
@@ -436,6 +491,16 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
</div>
</div>
</div>
{konnectMigrationGroups.length > 0 && userSession.accountId && (
<KonnectOrgMigrationModal
accountId={userSession.accountId}
groups={konnectMigrationGroups}
onDone={() => {
setKonnectMigrationGroups([]);
revalidate();
}}
/>
)}
</SidebarContext.Provider>
</InsomniaTabProvider>
</InsomniaEventStreamProvider>

View File

@@ -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 },

View File

@@ -75,7 +75,7 @@ export const GitProjectSyncDropdown: FC<Props> = ({ gitRepository, activeProject
const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` });
useEffect(() => {
if (!models.organization.isScratchpadOrganizationId(organizationId)) {
if (!models.organization.isLocalOrganizationId(organizationId)) {
const load = storageRuleFetcher.load;
load({ organizationId });
}

View File

@@ -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,

View File

@@ -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 (
<ModalOverlay
isOpen
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
>
<Modal className="flex w-full max-w-2xl flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)">
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-hidden">
<div className="flex h-full flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
<KongLogo width={20} height={20} />
<Heading slot="title" className="text-2xl">
Choose which Konnect data to keep
</Heading>
</div>
<p className="text-sm text-(--hl)">
Konnect data is now stored once per account in the <strong>Control Planes</strong> 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.
</p>
<RadioGroup
aria-label="Organization to keep"
value={keepOrganizationId}
onChange={setKeepOrganizationId}
className="flex flex-col gap-2"
>
{groups.map(group => (
<Radio
key={group.organizationId}
value={group.organizationId}
className="flex cursor-pointer items-center justify-between gap-3 rounded-sm border border-solid border-(--hl-sm) p-3 text-sm transition-colors hover:bg-(--hl-xs) data-selected:border-(--color-surprise) data-selected:bg-(--hl-xs)"
>
<span className="min-w-0 truncate font-semibold">{group.organizationName}</span>
<span className="shrink-0 text-(--hl)">
{group.projectCount} control plane(s), {group.workspaceCount} collection(s)
</span>
</Radio>
))}
</RadioGroup>
<div className="flex items-center justify-end gap-3 pt-2">
<Button
isDisabled={isMigrating || !keepOrganizationId}
onPress={handleConfirm}
className="flex items-center gap-2 rounded-sm bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{isMigrating && <Icon icon="spinner" className="animate-spin" />}
Keep this organization
</Button>
</div>
</div>
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -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<void>>;
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();
};

View File

@@ -56,7 +56,7 @@ export const RequestSettingsModal = ({ request, onHide }: ModalProps & RequestSe
const workspacesFetcher = useProjectListWorkspacesLoaderFetcher();
useEffect(() => {
const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data;
if (isIdleAndUninitialized && !models.organization.isScratchpadOrganizationId(organizationId)) {
if (isIdleAndUninitialized && !models.organization.isLocalOrganizationId(organizationId)) {
workspacesFetcher.load({
organizationId,
projectId,

View File

@@ -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}
>
<Button className="flex flex-1 items-center justify-center gap-2 rounded-xs px-4 py-1 text-sm font-bold text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)">
{models.organization.isKonnectOrganizationId(organizationId) && <KongLogo />}
<SelectValue<Organization> className="flex items-center justify-center gap-2 truncate">
{({ selectedItems }) => {
return selectedItems?.[0]?.name || 'Select an organization';
@@ -99,6 +102,7 @@ export const OrganizationSelect = ({
>
{({ isSelected }) => (
<Fragment>
{models.organization.isKonnectOrganizationId(item.id) && <KongLogo />}
<span>{item.name}</span>
{isSelected && <Icon icon="check" className="justify-self-end text-(--color-success)" />}
</Fragment>

View File

@@ -661,7 +661,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
isIdleAndUninitialized &&
organizationId &&
projectId &&
!models.organization.isScratchpadOrganizationId(organizationId)
!models.organization.isLocalOrganizationId(organizationId)
) {
workspacesFetcher.load({
organizationId,

View File

@@ -4,10 +4,8 @@ import type { RequestGroup, Workspace } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { fuzzyMatchAll } from 'insomnia-data/common';
import {
type Dispatch,
type ForwardedRef,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
@@ -21,9 +19,6 @@ import {
GridListItem,
Input,
SearchField,
Tab,
TabList,
Tabs,
Tooltip,
TooltipTrigger,
} from 'react-aria-components';
@@ -51,11 +46,12 @@ import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sid
import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus';
import { registerKonnectSyncTrigger } from '~/ui/hooks/konnect-sync-trigger';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { useKonnectSync } from '~/ui/hooks/use-konnect-sync';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import insomniaLogo from '~/ui/images/insomnia-logo.svg';
import { getKonnectSyncEnabled } from '~/ui/organization-utils';
import { isPrimaryClickModifier } from '~/ui/utils';
import { getAllRemoteBackendProjectsOfOrg } from '~/ui/utils/remote-projects';
@@ -79,18 +75,13 @@ import { WorkspaceNode } from './workspace-node';
interface ProjectNavigationSidebarProps {
storageRules: StorageRules;
activeNodeId?: string;
activeTab: ProjectNavigationSidebarTabId;
konnectSyncEnabled: boolean;
onCreateProject: () => void;
setActiveTab: Dispatch<SetStateAction<ProjectNavigationSidebarTabId | undefined>>;
}
export interface ProjectNavigationSidebarHandle {
expandProject: (projectId: string) => void;
}
export type ProjectNavigationSidebarTabId = 'projects' | 'konnect';
function LastSyncedLabel({ lastSyncedAt }: { lastSyncedAt: number | null }) {
return lastSyncedAt ? `Last synced: ${getRelativeTimeString(lastSyncedAt, Date.now())}` : 'Not yet synced';
}
@@ -143,45 +134,6 @@ const SidebarSearchField = ({
</SearchField>
);
const SideBarTabList = ({
konnectSyncEnabled,
isScratchPad,
nonKonnectProjectLength,
konnectProjectsLength,
}: {
konnectSyncEnabled: boolean;
isScratchPad: boolean;
nonKonnectProjectLength: number;
konnectProjectsLength: number;
}) => {
return (
<TabList
aria-label="Sidebar navigation"
className="flex h-(--line-height-sm) w-full shrink-0 border-b border-solid border-b-(--hl-md)"
>
<Tab
id="projects"
className={`flex h-full shrink-0 items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none ${konnectSyncEnabled ? 'cursor-pointer hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)' : 'text-(--color-font)!'}`}
data-testid="sidebar-tab-projects"
>
{isScratchPad ? 'Projects' : `Projects (${nonKonnectProjectLength})`}
</Tab>
{konnectSyncEnabled && !isScratchPad && (
<Tab
id="konnect"
className="flex h-full shrink-0 cursor-pointer items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
data-testid="sidebar-tab-konnect"
>
<span className="flex items-center gap-2">
<KongLogo />
Konnect ({konnectProjectsLength})
</span>
</Tab>
)}
</TabList>
);
};
const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => (
<BasicButton
aria-label="Create new Project"
@@ -195,7 +147,7 @@ const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisa
);
const ProjectNavigationSidebarInner = (
{ storageRules, konnectSyncEnabled, onCreateProject, activeTab, setActiveTab }: ProjectNavigationSidebarProps,
{ storageRules, onCreateProject }: ProjectNavigationSidebarProps,
ref: ForwardedRef<ProjectNavigationSidebarHandle>,
) => {
const navigate = useNavigate();
@@ -230,25 +182,19 @@ const ProjectNavigationSidebarInner = (
`${organizationId}:project-navigation-sidebar-filter`,
'',
);
const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage(
`${organizationId}:project-navigation-konnect-filter`,
'',
);
const [expandedProjectAndWorkspaceIds, setExpandedProjectAndWorkspaceIds] = reactUse.useLocalStorage<string[]>(
`${organizationId}:nav-expanded-projects-and-workspaces`,
[],
);
const isProjectTabActive = activeTab === 'projects';
const isKonnectOrganization = models.organization.isKonnectOrganizationId(organizationId);
const konnectSyncEnabled = getKonnectSyncEnabled(userSession.accountId);
const { syncing, progress, startSync, cancelSync } = useKonnectSync();
const [lastSyncedAt, setLastSyncedAt] = reactUse.useLocalStorage<number | null>(
`${organizationId}:konnect-last-synced-at`,
null,
);
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
const [filterInputValue, setFilterInputValue] = useState(projectNavigationSidebarFilter || '');
const [konnectFilterInputValue, setKonnectFilterInputValue] = useState(konnectFilter || '');
useEffect(() => {
// Keep input state aligned with storage only when organization context switches.
@@ -266,13 +212,11 @@ const ProjectNavigationSidebarInner = (
}
};
setFilterInputValue(readLocalStorageString(`${organizationId}:project-navigation-sidebar-filter`));
setKonnectFilterInputValue(readLocalStorageString(`${organizationId}:project-navigation-konnect-filter`));
}, [organizationId]);
// Debounce update filter
reactUse.useDebounce(() => setProjectNavigationSidebarFilter(filterInputValue), 300, [filterInputValue]);
reactUse.useDebounce(() => setKonnectFilter(konnectFilterInputValue), 300, [konnectFilterInputValue]);
const activeFilter = ((isProjectTabActive ? projectNavigationSidebarFilter : konnectFilter) || '').trim();
const activeFilter = (projectNavigationSidebarFilter || '').trim();
// ref to cache queried workspaces by project id
const cachedWorkspacesRef = useRef<Map<string, WorkspaceWithSyncStatus[]>>(new Map());
// ref to cache queried collection children (request & requestGroups) data and meta by workspace id
@@ -285,17 +229,11 @@ const ProjectNavigationSidebarInner = (
cachedCollectionChildrenAndMetaRef.current.clear();
}, []);
const syncKonnectProjectsAndNotifyRef = useRef<(konnectOrganizationId?: string | null) => Promise<void>>(
async () => {},
);
const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID;
const projectsWithPresence = useMemo(
() =>
projects
.filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null)
.map(project => {
projects.map(project => {
const projectPresence = presence
.filter(p => p.project === project.remoteId)
.filter(p => p.acct !== userSession.accountId)
@@ -315,7 +253,7 @@ const ProjectNavigationSidebarInner = (
project.gitRepository?.hasUnpushedChanges,
};
}),
[projects, isProjectTabActive, presence, checkAllProjectSyncStatus, userSession.accountId],
[projects, presence, checkAllProjectSyncStatus, userSession.accountId],
);
const cloudSyncProjects = useMemo(() => projects.filter(p => models.project.isRemoteProject(p)), [projects]);
@@ -425,14 +363,13 @@ const ProjectNavigationSidebarInner = (
}
}
};
syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify;
registerKonnectSyncTrigger(syncKonnectProjectsAndNotify);
const handleSync = async () => {
if (!konnectSyncEnabled) {
return;
}
const isResync = konnectProjects.length > 0;
const isResync = projects.length > 0;
if (isResync) {
showModal(AskModal, {
title: 'Sync updates from Konnect',
@@ -483,7 +420,7 @@ const ProjectNavigationSidebarInner = (
};
useEffect(() => {
if (projectNavigationSidebarFilter || konnectFilter) {
if (projectNavigationSidebarFilter) {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectListFiltered,
properties: {
@@ -491,7 +428,7 @@ const ProjectNavigationSidebarInner = (
},
});
}
}, [projectNavigationSidebarFilter, konnectFilter, activeProjectId]);
}, [projectNavigationSidebarFilter, activeProjectId]);
useEffect(() => {
getAllRemoteFilesByProjectId();
@@ -789,7 +726,6 @@ const ProjectNavigationSidebarInner = (
collectionSortOrders,
projectWorkspaceSortOrder,
expandedProjectAndWorkspaceIds,
isProjectTabActive,
localWorkspaceOrders,
organizationId,
projectsWithPresence,
@@ -1015,7 +951,6 @@ const ProjectNavigationSidebarInner = (
expandedProjectAndWorkspaceIds,
});
const { selectedItemId, routeInfo } = useProjectNavigationSidebarNavigation({
setActiveTab,
toggleRequestGroups,
expandProjectOrWorkspaces,
visibleFlatItems,
@@ -1044,7 +979,7 @@ const ProjectNavigationSidebarInner = (
useDocBodyKeyboardShortcuts({
sidebar_showCreateDropdown: event => {
if (!isProjectTabActive) {
if (isKonnectOrganization) {
return;
}
@@ -1098,7 +1033,7 @@ const ProjectNavigationSidebarInner = (
});
const { hasKonnectPat } = settings;
const showKonnectSyncIntro = konnectSyncEnabled && !isProjectTabActive && !hasKonnectPat;
const showKonnectSyncIntro = isKonnectOrganization && !hasKonnectPat;
const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false);
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null);
const [showSyncDetails, setShowSyncDetails] = useState(false);
@@ -1122,27 +1057,17 @@ const ProjectNavigationSidebarInner = (
return (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
<Tabs selectedKey={activeTab} onSelectionChange={key => setActiveTab(key as ProjectNavigationSidebarTabId)}>
<SideBarTabList
konnectSyncEnabled={konnectSyncEnabled}
isScratchPad={isScratchPad}
nonKonnectProjectLength={nonKonnectProjects.length}
konnectProjectsLength={konnectProjects.length}
/>
</Tabs>
{showKonnectSyncIntro ? (
<KonnectSyncIntro onConfigure={() => setShowKonnectConfigModal(true)} />
) : (
<>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SidebarSearchField
value={isProjectTabActive ? filterInputValue : konnectFilterInputValue}
value={filterInputValue}
isDisabled={projects.length === 0}
onChange={isProjectTabActive ? setFilterInputValue : setKonnectFilterInputValue}
onChange={setFilterInputValue}
/>
{isProjectTabActive ? (
!isScratchPad && <NewProjectButton onPress={onCreateProject} isDisabled={projects.length === 0} />
) : (
{isKonnectOrganization ? (
<div className="flex items-center gap-1">
{syncing ? (
<Button
@@ -1158,7 +1083,8 @@ const ProjectNavigationSidebarInner = (
<Button
aria-label="Sync Konnect"
onPress={handleSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
isDisabled={!konnectSyncEnabled}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<Icon icon="refresh" />
Sync
@@ -1167,7 +1093,11 @@ const ProjectNavigationSidebarInner = (
placement="bottom"
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-1.5 text-xs text-(--color-font) shadow-lg select-none"
>
<LastSyncedLabel lastSyncedAt={lastSyncedAt ?? null} />
{konnectSyncEnabled ? (
<LastSyncedLabel lastSyncedAt={lastSyncedAt ?? null} />
) : (
'Konnect sync is not enabled for any of your organizations.'
)}
</Tooltip>
</TooltipTrigger>
)}
@@ -1179,10 +1109,12 @@ const ProjectNavigationSidebarInner = (
<Icon icon="gear" />
</Button>
</div>
) : (
!isScratchPad && <NewProjectButton onPress={onCreateProject} isDisabled={projects.length === 0} />
)}
</div>
{!isProjectTabActive && syncing && (
{isKonnectOrganization && syncing && (
<p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>
)}
@@ -1387,7 +1319,7 @@ const ProjectNavigationSidebarInner = (
/>
)}
{!isProjectTabActive && lastSyncResult && (
{isKonnectOrganization && lastSyncResult && (
<div
className={`m-2 flex items-start justify-between gap-2 rounded-sm p-3 text-xs ${
!lastSyncResult.success
@@ -1518,7 +1450,6 @@ const ProjectNavigationSidebarInner = (
{showKonnectConfigModal && (
<KonnectSettingsModal
onClose={() => setShowKonnectConfigModal(false)}
syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef}
onDisconnect={() => setLastSyncedAt(null)}
/>
)}
@@ -1536,19 +1467,37 @@ export const ProjectNavigationSidebar = forwardRef<ProjectNavigationSidebarHandl
export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreateProject: () => 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 (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
{!settings.hasKonnectPat ? (
<KonnectSyncIntro onConfigure={() => setShowKonnectConfigModal(true)} />
) : (
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SidebarSearchField value="" isDisabled onChange={() => {}} />
<Button
aria-label="Konnect settings"
onPress={() => setShowKonnectConfigModal(true)}
className="flex aspect-square h-full items-center justify-center rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
<Icon icon="gear" />
</Button>
</div>
)}
{showKonnectConfigModal && (
<KonnectSettingsModal onClose={() => setShowKonnectConfigModal(false)} />
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
<Tabs>
<SideBarTabList
konnectSyncEnabled={features.konnectSync.enabled}
isScratchPad={isScratchPad}
nonKonnectProjectLength={0}
konnectProjectsLength={0}
/>
</Tabs>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SidebarSearchField value="" isDisabled onChange={() => {}} />
{!isScratchPad && <NewProjectButton onPress={onCreateProject} />}

View File

@@ -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<SetStateAction<'projects' | 'konnect' | undefined>>;
toggleRequestGroups: (requestGroupIds: string[], workspace: Workspace, collapsed?: boolean) => Promise<void>;
expandProjectOrWorkspaces: (ids: string[]) => void;
visibleFlatItems: FlatItem[];
@@ -42,10 +39,8 @@ export const useProjectNavigationSidebarNavigation = ({
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const lastHandledScrollKeyRef = useRef<string | null>(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);

View File

@@ -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;

View File

@@ -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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ 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`);

View File

@@ -0,0 +1,15 @@
type KonnectSyncTrigger = (konnectOrganizationId?: string | null) => Promise<void>;
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);
};

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -10,6 +10,7 @@ export const enum AsyncTask {
SyncOrganization,
MigrateProjects,
SyncProjects,
SyncKonnectFeature,
}
const getMatchParams = (location: string) => {
@@ -133,7 +134,12 @@ export const getInitialEntry = async () => {
// Check if the user has a last visited organization
try {
const lastVisitedOrganizationId = localStorage.getItem('lastVisitedOrganizationId');
if (lastVisitedOrganizationId && organizations.find(o => o.id === lastVisitedOrganizationId)) {
// The Konnect organization is local-only, so it is never in the cached organization list.
const isKnownOrganization =
lastVisitedOrganizationId ===
(user.accountId && models.organization.getKonnectOrganizationId(user.accountId)) ||
organizations.some(o => o.id === lastVisitedOrganizationId);
if (lastVisitedOrganizationId && isKnownOrganization) {
organizationId = lastVisitedOrganizationId;
}
} catch {}
@@ -142,7 +148,12 @@ export const getInitialEntry = async () => {
pathname: await getInitialRouteForOrganization({ organizationId, navigateToWorkspace: true }),
state: {
// async task need to execute when first entry
asyncTaskList: [AsyncTask.SyncOrganization, AsyncTask.MigrateProjects, AsyncTask.SyncProjects],
asyncTaskList: [
AsyncTask.SyncOrganization,
AsyncTask.MigrateProjects,
AsyncTask.SyncProjects,
AsyncTask.SyncKonnectFeature,
],
},
};
}