mirror of
https://github.com/Kong/insomnia.git
synced 2026-07-30 09:16:44 -04:00
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:
451
docs/konnect-control-planes-organization-plan.md
Normal file
451
docs/konnect-control-planes-organization-plan.md
Normal 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.
|
||||
@@ -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': {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
147
packages/insomnia/src/konnect/migrate-konnect-organization.ts
Normal file
147
packages/insomnia/src/konnect/migrate-konnect-organization.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
15
packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts
Normal file
15
packages/insomnia/src/ui/hooks/konnect-sync-trigger.ts
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user