diff --git a/packages/insomnia-smoke-test/server/insomnia-api.ts b/packages/insomnia-smoke-test/server/insomnia-api.ts index 6fd91b527c..4715257ab9 100644 --- a/packages/insomnia-smoke-test/server/insomnia-api.ts +++ b/packages/insomnia-smoke-test/server/insomnia-api.ts @@ -635,4 +635,10 @@ export default (app: Application) => { app.delete('/v1/desktop/organizations/:organizationId/collaborators/:collaboratorId/unlink', (_req, res) => { res.json(null); }); + + app.post('/v1/organizations/:organizationId/check-seats', (_req, res) => { + res.json({ + isAllowed: true, + }); + }); }; diff --git a/packages/insomnia/src/basic-components/icon.tsx b/packages/insomnia/src/basic-components/icon.tsx new file mode 100644 index 0000000000..7541e729f6 --- /dev/null +++ b/packages/insomnia/src/basic-components/icon.tsx @@ -0,0 +1,2 @@ +export * from '../ui/components/icon'; +// https://fontawesome.com/search?q=user&ic=free&o=r diff --git a/packages/insomnia/src/basic-components/modal.tsx b/packages/insomnia/src/basic-components/modal.tsx new file mode 100644 index 0000000000..02c57b7756 --- /dev/null +++ b/packages/insomnia/src/basic-components/modal.tsx @@ -0,0 +1,69 @@ +import classnames from 'classnames'; +import type React from 'react'; +import { Button, Dialog, Heading, Modal as RAModal, ModalOverlay } from 'react-aria-components'; + +import { Icon } from '~/basic-components/icon'; + +interface Props { + isOpen: boolean; + onClose?: () => void; + title?: React.ReactNode; + closable?: boolean; + className?: string; +} + +export const Modal: React.FC> = ({ + isOpen, + onClose, + className, + title, + closable, + children, +}) => { + return ( + { + !isOpen && onClose?.(); + }} + isDismissable + className="fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-black/30" + > + { + !isOpen && onClose?.(); + }} + className={classnames( + 'flex flex-col rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] p-[--padding-lg] text-[--color-font]', + className, + )} + > + + {({ close }) => ( + <> +
+ {' '} +
+ {title && ( + + {title} + + )} + {closable && ( + + )} +
+
+ {children} + + )} +
+
+
+ ); +}; diff --git a/packages/insomnia/src/basic-components/progress.tsx b/packages/insomnia/src/basic-components/progress.tsx new file mode 100644 index 0000000000..a15992d02e --- /dev/null +++ b/packages/insomnia/src/basic-components/progress.tsx @@ -0,0 +1,32 @@ +import classNames from 'classnames'; + +interface Props { + percent: number; + className?: string; + status?: 'success' | 'error' | 'normal'; +} + +export const Progress = ({ className, percent, status }: Props) => { + return ( +
+
+
+ ); +}; diff --git a/packages/insomnia/src/common/documentation.ts b/packages/insomnia/src/common/documentation.ts index 5f4d924d96..63d6cd8cd4 100644 --- a/packages/insomnia/src/common/documentation.ts +++ b/packages/insomnia/src/common/documentation.ts @@ -14,6 +14,8 @@ export const docsIntroductionToInsoCLI = insomniaDocs('/inso-cli/introduction'); export const docsPreRequestScript = insomniaDocs('/insomnia/pre-request-script'); export const docsAfterResponseScript = insomniaDocs('/insomnia/after-response-script'); export const docsMcpClient = insomniaDocs('/insomnia/mcp-clients-in-insomnia'); +export const docsPricingLearnMoreLink = + 'https://developer.konghq.com/insomnia/storage/#what-are-the-user-and-git-sync-limits-for-the-essentials-plan'; export const docsGitAccessToken = { github: 'https://docs.github.com/github/authenticating-to-github/creating-a-personal-access-token', diff --git a/packages/insomnia/src/common/import.ts b/packages/insomnia/src/common/import.ts index 3e13ed2fb4..60f9d5d299 100644 --- a/packages/insomnia/src/common/import.ts +++ b/packages/insomnia/src/common/import.ts @@ -130,6 +130,7 @@ interface ResourceCacheType { let resourceCacheList: ResourceCacheType[] = []; +// All models that can be exported should be listed here export const MODELS_BY_EXPORT_TYPE: Record = { request: 'Request', websocket_payload: 'WebSocketPayload', diff --git a/packages/insomnia/src/models/organization.ts b/packages/insomnia/src/models/organization.ts index 9ae90c6702..42c4c5f376 100644 --- a/packages/insomnia/src/models/organization.ts +++ b/packages/insomnia/src/models/organization.ts @@ -65,7 +65,7 @@ export type PersonalPlanType = 'free' | 'individual' | 'team' | 'enterprise' | ' export const formatCurrentPlanType = (type: PersonalPlanType) => { switch (type) { case 'free': { - return 'Hobby'; + return 'Essentials'; } case 'individual': { return 'Individual'; @@ -94,4 +94,6 @@ export interface CurrentPlan { quantity: number; type: PersonalPlanType; planName: string; + status: 'trialing' | 'active'; + trialingEnd: string; } diff --git a/packages/insomnia/src/models/request-version.ts b/packages/insomnia/src/models/request-version.ts index 037f5f43ce..49db6b5429 100644 --- a/packages/insomnia/src/models/request-version.ts +++ b/packages/insomnia/src/models/request-version.ts @@ -10,6 +10,11 @@ import { isRequest, type Request } from './request'; import { isSocketIORequest, type SocketIORequest } from './socket-io-request'; import { isWebSocketRequest, type WebSocketRequest } from './websocket-request'; +/* When viewing a specific request, the user can click the Send button to test-send it. +Each time the user sends the request, the parameters may differ—they might edit the body, headers, and so on—and Insomnia records every sent request as history. +When the user browses the send history for a request and selects one of the entries, the current request is restored to the exact state it had when that request was sent, including the body, headers, and other settings. +A Request Version is essentially a snapshot of the request at the moment it was test-sent. */ + export const name = 'Request Version'; export const type = 'RequestVersion'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx b/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx new file mode 100644 index 0000000000..6441c54a74 --- /dev/null +++ b/packages/insomnia/src/routes/organization.$organizationId.collaborators-check-seats.tsx @@ -0,0 +1,47 @@ +import { href } from 'react-router'; +import { v4 as uuidv4 } from 'uuid'; + +import { userSession } from '~/models'; +import { insomniaFetch } from '~/ui/insomniaFetch'; +import { createFetcherLoadHook } from '~/utils/router'; + +import type { Route } from './+types/organization.$organizationId.collaborators-check-seats'; + +export const needsToUpgrade = 'NEEDS_TO_UPGRADE'; +export const needsToIncreaseSeats = 'NEEDS_TO_INCREASE_SEATS'; + +export interface CheckSeatsResponse { + isAllowed: boolean; + code?: typeof needsToUpgrade | typeof needsToIncreaseSeats; +} + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + const { id: sessionId } = await userSession.get(); + + const { organizationId } = params; + + try { + // Check whether the user can add a new collaborator + // Use a random email to avoid hitting any existing member emails + const checkResponseData = await insomniaFetch({ + method: 'POST', + path: `/v1/organizations/${organizationId}/check-seats`, + data: { emails: [`insomnia-mock-check-seats-${uuidv4()}@example.net`] }, + sessionId, + onlyResolveOnSuccess: true, + }); + return checkResponseData; + } catch { + return { isAllowed: true }; + } +} + +export const useCollaboratorsCheckSeatsLoaderFetcher = createFetcherLoadHook( + load => + ({ organizationId, query }: { organizationId: string; query?: string }) => { + return load( + `${href(`/organization/:organizationId/collaborators-check-seats`, { organizationId })}?${encodeURIComponent(query || '')}`, + ); + }, + clientLoader, +); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index c3519bf0a8..4a5432d579 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -3,6 +3,7 @@ import { href, redirect } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; import * as models from '~/models'; +import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { insomniaFetch } from '~/ui/insomniaFetch'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook, getInitialRouteForOrganization } from '~/utils/router'; @@ -53,6 +54,8 @@ export async function clientAction({ params }: Route.ClientActionArgs) { await database.flushChanges(bufferId); + project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId); + // When redirect to `/organizations/:organizationId`, it sometimes doesn't reload the index loader, so manually redirect to the initial route for the organization const initialOrganizationRoute = await getInitialRouteForOrganization({ organizationId }); return redirect(initialOrganizationRoute); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index fe29bfc143..5702e521d3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -7,6 +7,7 @@ import type { OauthProviderName } from '~/models/git-credentials'; import type { GitCredentials } from '~/models/git-repository'; import { EMPTY_GIT_PROJECT_ID } from '~/models/project'; import type { WorkspaceMeta } from '~/models/workspace-meta'; +import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { SegmentEvent } from '~/ui/analytics'; import { insomniaFetch } from '~/ui/insomniaFetch'; import { invariant } from '~/utils/invariant'; @@ -179,6 +180,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) } await models.project.update(project, { name, remoteId: newCloudProject.id, gitRepositoryId: null }); + + project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId); return { success: true, }; @@ -278,6 +281,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) } } + reportGitProjectCount(organizationId, sessionId); + return { success: true, }; @@ -290,6 +295,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) gitRepository && (await models.gitRepository.remove(gitRepository)); await models.project.update(project, { name, gitRepositoryId: null }); + reportGitProjectCount(organizationId, sessionId); + return { success: true, }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx index b9f97773f0..99bf0a7e4c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx @@ -1,9 +1,11 @@ import { href, redirect } from 'react-router'; +import { database } from '~/common/database'; +import { isNotNullOrUndefined } from '~/common/misc'; import { projectLock } from '~/common/project'; import * as models from '~/models'; import type { GitCredentials, OauthProviderName } from '~/models/git-repository'; -import { EMPTY_GIT_PROJECT_ID } from '~/models/project'; +import { EMPTY_GIT_PROJECT_ID, type Project } from '~/models/project'; import { SegmentEvent } from '~/ui/analytics'; import { insomniaFetch } from '~/ui/insomniaFetch'; import { invariant } from '~/utils/invariant'; @@ -29,6 +31,34 @@ export interface CreateProjectData { connectRepositoryLater?: boolean; } +export const reportGitProjectCount = async (organizationId: string, sessionId: string, maxRetries = 3) => { + const projects = await database.find(models.project.type, { + parentId: organizationId, + }); + const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); + const gitProjectsCount = gitRepositoryIds.length; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await insomniaFetch({ + method: 'PATCH', + path: `/v1/organizations/${organizationId}/git-projects`, + sessionId, + onlyResolveOnSuccess: true, + data: { + count: gitProjectsCount, + }, + }); + return; + } catch (err) { + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, attempt * 1000)); + } + } + } + + console.warn('Report git project count failed'); +}; + export const createProject = async (organizationId: string, newProjectData: CreateProjectData) => { const createProjectImpl = async (organizationId: string, newProjectData: CreateProjectData) => { const user = await models.userSession.getOrCreate(); @@ -51,6 +81,7 @@ export const createProject = async (organizationId: string, newProjectData: Crea parentId: organizationId, gitRepositoryId: EMPTY_GIT_PROJECT_ID, }); + reportGitProjectCount(organizationId, sessionId); return project._id; } @@ -91,6 +122,7 @@ export const createProject = async (organizationId: string, newProjectData: Crea if (errors) { throw new Error(errors.join(', ')); } + reportGitProjectCount(organizationId, sessionId); return projectId; } diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index aa05564956..35251e8959 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -27,6 +27,7 @@ import { getLoginUrl } from '~/ui/auth-session-provider.client'; import { CommandPalette } from '~/ui/components/command-palette'; import { GitHubStarsButton } from '~/ui/components/github-stars-button'; import { HeaderInviteButton } from '~/ui/components/header-invite-button'; +import { HeaderPlanIndicator } from '~/ui/components/header-plan-indicator'; import { HeaderUserButton } from '~/ui/components/header-user-button'; import { Hotkey } from '~/ui/components/hotkey'; import { Icon } from '~/ui/components/icon'; @@ -58,7 +59,6 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as UserProfileResponse; const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; - return { organizations: sortOrganizations(accountId, organizations), user, @@ -240,12 +240,12 @@ const Component = ({ loaderData }: Route.ComponentProps) => { 'organizationSidebarOpen', true, ); - const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false); useCloseConnection({ organizationId, }); + const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false); return ( @@ -262,11 +262,15 @@ const Component = ({ loaderData }: Route.ComponentProps) => { {!user ? : null}
-
+
{user ? ( - + + ) : ( @@ -583,7 +587,8 @@ const Component = ({ loaderData }: Route.ComponentProps) => { {user ? ( - + + ) : ( diff --git a/packages/insomnia/src/routes/resource.usage.tsx b/packages/insomnia/src/routes/resource.usage.tsx new file mode 100644 index 0000000000..b79e07c547 --- /dev/null +++ b/packages/insomnia/src/routes/resource.usage.tsx @@ -0,0 +1,119 @@ +import { href } from 'react-router'; + +import { userSession } from '~/models'; +import type { CurrentPlan } from '~/models/organization'; +import { getTrialEligibility } from '~/routes/trial.check'; +import { insomniaFetch } from '~/ui/insomniaFetch'; +import { createFetcherLoadHook } from '~/utils/router'; + +interface ResourceUsage { + mocks: { + quota: number; + calls: number; + autoPurchase: { + enabled: boolean; + unit: number; + }; + }; +} + +interface EnterpriseOwner { + id: string; + name: string; + role: string; +} + +interface AccountUsedSeats { + memberCount: number; + inviteCount: number; + used: number; + total: number; +} + +interface LicenseUsage { + used: number; + total: number; + memberCount: number; + inviteCount: number; + free: number; +} + +function getResourceUsage(sessionId: string) { + return insomniaFetch({ + method: 'GET', + path: '/v1/user/resource-usage', + sessionId, + }); +} + +function getOwnEnterprises(sessionId: string) { + return insomniaFetch({ + method: 'GET', + path: '/v1/user/enterprises', + sessionId, + }); +} + +async function getCurrentEnterprise(sessionId: string) { + const enterprises = await getOwnEnterprises(sessionId); + if (!Array.isArray(enterprises)) { + return null; + } + return enterprises.find(ent => ent.role === 'owner') ?? enterprises[0]; +} + +function getAccountUsedSeats(sessionId: string) { + return insomniaFetch({ + method: 'GET', + path: '/v1/accounts/seats', + sessionId, + }); +} + +function getEnterpriseLicenseUsage(sessionId: string, enterpriseId: string) { + return insomniaFetch({ + method: 'GET', + path: `/v1/enterprise/${enterpriseId}/license-usage`, + sessionId, + }); +} + +function getLicenseUsage(sessionId: string, enterpriseId?: string | null) { + return enterpriseId ? getEnterpriseLicenseUsage(sessionId, enterpriseId) : getAccountUsedSeats(sessionId); +} + +export async function clientLoader() { + const { id: sessionId, accountId } = await userSession.get(); + + if (!sessionId) { + return { + resourceUsage: null, + licenseUsage: null, + isEligible: false, + }; + } + + const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; + const enterpriseId = currentPlan?.type === 'enterprise' ? (await getCurrentEnterprise(sessionId))?.id : null; + const [resourceUsage, licenseUsage, trialEligibility] = await Promise.allSettled([ + getResourceUsage(sessionId), + getLicenseUsage(sessionId, enterpriseId), + getTrialEligibility(sessionId), + ]); + + return { + resourceUsage: resourceUsage.status === 'fulfilled' ? resourceUsage.value : null, + licenseUsage: licenseUsage.status === 'fulfilled' ? licenseUsage.value : null, + isEligible: + trialEligibility.status === 'fulfilled' && 'isEligible' in trialEligibility.value + ? trialEligibility.value?.isEligible + : false, + }; +} + +export const useResourceUsageFetcher = createFetcherLoadHook( + load => () => { + return load(href('/resource/usage')); + }, + clientLoader, +); diff --git a/packages/insomnia/src/routes/trial.check.tsx b/packages/insomnia/src/routes/trial.check.tsx new file mode 100644 index 0000000000..fa21059236 --- /dev/null +++ b/packages/insomnia/src/routes/trial.check.tsx @@ -0,0 +1,47 @@ +import { href } from 'react-router'; + +import { userSession } from '~/models'; +import { insomniaFetch } from '~/ui/insomniaFetch'; +import { createFetcherLoadHook } from '~/utils/router'; + +import type { Route } from './+types/settings.update'; + +interface Eligible { + isEligible: boolean; +} + +export function getTrialEligibility(sessionId: string) { + return insomniaFetch({ + method: 'GET', + path: '/v1/trials/eligibility', + sessionId, + }); +} + +export async function clientLoader(_args: Route.ClientLoaderArgs) { + const { id: sessionId } = await userSession.get(); + + if (!sessionId) { + return { + isEligible: false, + }; + } + + try { + const check = await getTrialEligibility(sessionId); + return { + isEligible: 'isEligible' in check ? check.isEligible : false, + }; + } catch (err) { + return { + isEligible: false, + }; + } +} + +export const useTrialCheckLoaderFetcher = createFetcherLoadHook( + load => () => { + return load(href('/trial/check')); + }, + clientLoader, +); diff --git a/packages/insomnia/src/routes/trial.start.tsx b/packages/insomnia/src/routes/trial.start.tsx new file mode 100644 index 0000000000..35dd0e69a5 --- /dev/null +++ b/packages/insomnia/src/routes/trial.start.tsx @@ -0,0 +1,47 @@ +import { userSession } from '~/models'; +import { insomniaFetch } from '~/ui/insomniaFetch'; +import { syncCurrentPlan } from '~/ui/organization-utils'; +import { createFetcherSubmitHook } from '~/utils/router'; + +import type { Route } from './+types/settings.update'; + +interface StartResult { + success: boolean; +} + +export async function clientAction(_args: Route.ClientActionArgs) { + const { id: sessionId, accountId } = await userSession.get(); + + if (!sessionId || !accountId) { + return { + success: false, + }; + } + + try { + const result = await insomniaFetch({ + method: 'POST', + path: '/v1/trials/start', + sessionId, + }); + if (result.success) { + await syncCurrentPlan(sessionId, accountId); + } + return result; + } catch (err) { + return { + success: false, + }; + } +} + +export const useTrialStartActionFetcher = createFetcherSubmitHook( + submit => () => { + return submit(null, { + method: 'POST', + action: '/trial/start', + encType: 'application/json', + }); + }, + clientAction, +); diff --git a/packages/insomnia/src/ui/analytics.ts b/packages/insomnia/src/ui/analytics.ts index 5a633eda11..98f370f6d3 100644 --- a/packages/insomnia/src/ui/analytics.ts +++ b/packages/insomnia/src/ui/analytics.ts @@ -43,7 +43,7 @@ export enum SegmentEvent { vcsSyncComplete = 'VCS Sync Completed', vcsAction = 'VCS Action Executed', buttonClick = 'Button Clicked', - inviteMember = 'Invite Member', + inviteMember = 'Invite Sent', inviteResent = 'Invite Resent', inviteRevoked = 'Invite Revoked', projectCreated = 'Project Created', @@ -56,6 +56,7 @@ export enum SegmentEvent { recommendCommitsClicked = 'Recommend Commits Clicked', mcpClientWorkspaceCreate = 'MCP Client Workspace Created', mcpClientAdded = 'MCP Client Added', + inviteNotPermitted = 'Invite Not Permitted', } type PushPull = 'push' | 'pull'; diff --git a/packages/insomnia/src/ui/components/header-invite-button.tsx b/packages/insomnia/src/ui/components/header-invite-button.tsx index 4ce75b5ef9..6e53d4485f 100644 --- a/packages/insomnia/src/ui/components/header-invite-button.tsx +++ b/packages/insomnia/src/ui/components/header-invite-button.tsx @@ -1,28 +1,158 @@ -import React, { useState } from 'react'; -import { Button } from 'react-aria-components'; +import React, { useEffect, useState } from 'react'; +import { Button, Heading, Link, Radio, RadioGroup } from 'react-aria-components'; + +import { Modal } from '~/basic-components/modal'; +import { getAppWebsiteBaseURL } from '~/common/constants'; +import { SegmentEvent } from '~/ui/analytics'; +import { Tooltip } from '~/ui/components/tooltip'; import { Icon } from './icon'; -import { InviteModalContainer } from './modals/invite-modal/invite-modal'; +import { + getCurrentUserPermissionsInOrg, + InviteModalContainer, + type Permission, +} from './modals/invite-modal/invite-modal'; -export const HeaderInviteButton = ({ className = '' }) => { +export const HeaderInviteButton = ({ + className = '', + organizationId, +}: { + className?: string; + organizationId: string; +}) => { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const [userPermission, setUserPermission] = useState | null>(null); + + // TODO: should manage this in the scope of organization context + useEffect(() => { + if (organizationId) { + getCurrentUserPermissionsInOrg(organizationId).then(permissions => { + setUserPermission(permissions); + }); + } + }, [organizationId]); + + // TODO: let backend handle the license check currently + const hasAvailableLicenses = true; + // if backend API fails, we still allow user to invite, and let backend handle the error + const hasPermissions = + userPermission == null || (userPermission['create:invitation'] && userPermission['read:membership']); + const tip = !hasAvailableLicenses ? ( + hasPermissions ? ( +
+ You cannot invite anyone as there are no available licenses.{' '} + + You can review your usage here. + +
+ ) : ( + 'You cannot invite anyone as there are no available licenses. Contact your organization’s Insomnia admins for more info.' + ) + ) : ( + // !hasPermissions && hasAvailableLicenses: will popup 'missing someone' + '' + ); + + const [missingOpen, setMissingOpen] = React.useState(false); + + const button = ( + + ); + + if (tip) { + return ( + + {button} + + + ); + } return ( <> - + {button} + {!hasPermissions && setMissingOpen(false)} />} ); }; + +const MissingSomeoneModal = ({ isOpen, onClose }: any) => { + const [reason, setReason] = useState(null); + const handleClose = () => { + if (reason) { + window.main.trackSegmentEvent({ + event: SegmentEvent.inviteNotPermitted, + properties: { + collaboration_type: reason, + }, + }); + } + onClose?.(); + }; + return ( + +

+ You're on a paid plan, so please contact your company's Insomnia admins to get anyone added to this account. +

+

Just curious - why do you want to invite someone?

+ { + setReason(reason); + }} + className="flex flex-col gap-2" + > + +
+ To work together in Insomnia long-term +
+
+ +
+ Just to show them something +
+
+
+
+ +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/header-plan-indicator.tsx b/packages/insomnia/src/ui/components/header-plan-indicator.tsx new file mode 100644 index 0000000000..8b4f611a5d --- /dev/null +++ b/packages/insomnia/src/ui/components/header-plan-indicator.tsx @@ -0,0 +1,216 @@ +import classNames from 'classnames'; +import { useEffect, useRef, useState } from 'react'; +import { Button, Dialog, DialogTrigger, Link, Popover, Separator } from 'react-aria-components'; + +import { Progress } from '~/basic-components/progress'; +import { getAppWebsiteBaseURL } from '~/common/constants'; +import type { CurrentPlan } from '~/models/organization'; +import { useResourceUsageFetcher } from '~/routes/resource.usage'; +import { useTrialStartActionFetcher } from '~/routes/trial.start'; +import { Icon } from '~/ui/components/icon'; +import { Tooltip } from '~/ui/components/tooltip'; +import { usePlanData } from '~/ui/hooks/use-plan'; +import { useUserService } from '~/ui/hooks/use-user-service'; +import { formatNumber } from '~/utils'; + +interface Props { + isMinimal?: boolean; + currentPlan?: CurrentPlan; +} + +export const HeaderPlanIndicator = ({ isMinimal }: Props) => { + const { planDisplayName } = usePlanData(); + const { isEnterpriseMember, isEssential, isEnterpriseOwner, isEnterpriseLike, trialDaysLeft, isTrailing } = + useUserService(); + const [open, _setOpen] = useState(false); + const [canTrial, setCanTrial] = useState(false); + const planName = `${planDisplayName} Plan`; + + const startFetcher = useTrialStartActionFetcher(); + const { load: usageLoad, state: usageState, data: usageData } = useResourceUsageFetcher(); + + function handleStartTrial() { + if (startFetcher.state === 'idle') { + startFetcher.submit(); + } + } + + const checked = useRef(false); + const setOpen = (value: boolean) => { + if (!value) { + checked.current = false; + } + _setOpen(value); + }; + + useEffect(() => { + if (!open) { + return; + } + if (usageState === 'idle' && !checked.current) { + checked.current = true; + usageLoad(); + } + }, [usageLoad, usageState, open]); + + useEffect(() => { + if (typeof usageData?.isEligible === 'boolean') { + setCanTrial(usageData.isEligible); + } + }, [usageData?.isEligible]); + + useEffect(() => { + if (startFetcher.data?.success) { + setCanTrial(false); + } + }, [startFetcher.data?.success]); + + const isUnlimited = isEnterpriseLike; + + // resourceUsage + const usedMocks = Math.min(usageData?.resourceUsage?.mocks?.calls || 0, usageData?.resourceUsage?.mocks?.quota || 0); + const mockUsage = ((usedMocks || 0) / (usageData?.resourceUsage?.mocks?.quota || 1)) * 100; + const mockStatus = isUnlimited ? 'success' : mockUsage >= 100 ? 'error' : 'normal'; + const mockTip = + mockStatus === 'error' + ? 'You have reached your monthly limit of mock server requests. Add more by Upgrading your plan.' + : 'This number represents the amount of mock server requests are in your plan.'; + + // licenseUsage + const total = usageData?.licenseUsage?.total; + const used = usageData?.licenseUsage?.used; + const isUserUnlimited = total === -1; + const free = + usageData?.licenseUsage && 'free' in usageData.licenseUsage ? (usageData?.licenseUsage.free as number) : null; + const seatsUsage = ((used || 0) / (total || 1)) * 100; + const userStatus = isUserUnlimited ? 'success' : seatsUsage >= 100 ? 'error' : 'normal'; + const userTip = + userStatus === 'error' + ? 'You have reached your limit of licensed users. Invite more by Upgrading your plan.' + : 'This number represents the amount of licensed users are in your plan.'; + + return ( + + + + +
+
+
+ + {planName} + {isTrailing && ( + + Trial + + )} + + {isTrailing && {trialDaysLeft ?? '--'} days left of free trial} +
+ {!isEnterpriseMember && ( + + {isEnterpriseLike ? 'Add seats' : 'Upgrade'} + + )} +
+ + + +
+ {!isEnterpriseMember && ( +
+
+ {isEnterpriseLike ? 'managed ' : ''}users + + + + + {used}/{isUserUnlimited ? 'Unlimited' : formatNumber(total || 0)} + +
+ +
+ )} + + {isEnterpriseOwner && ( +
+
+ Unmanaged users + + + + {free || 0} +
+ +
+ )} + +
+
+ Mock Requests + + + + + {formatNumber(usedMocks)}/{' '} + {isUnlimited ? 'Unlimited' : formatNumber(usageData?.resourceUsage?.mocks?.quota || 0)} + +
+ +
+
+ + {!isEnterpriseMember && ( + + )} + {canTrial && ( +
+ +
+ )} +
+
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/header-user-button.tsx b/packages/insomnia/src/ui/components/header-user-button.tsx index e494cf4525..d5e1b86e5c 100644 --- a/packages/insomnia/src/ui/components/header-user-button.tsx +++ b/packages/insomnia/src/ui/components/header-user-button.tsx @@ -1,74 +1,18 @@ import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; import { getAppWebsiteBaseURL } from '~/common/constants'; -import type { CurrentPlan, PersonalPlanType, UserProfileResponse } from '~/models/organization'; +import type { CurrentPlan, UserProfileResponse } from '~/models/organization'; import { useLogoutFetcher } from '~/routes/auth.logout'; import { Avatar } from '~/ui/components/avatar'; import { Icon } from '~/ui/components/icon'; - -const formatCurrentPlanType = (type: PersonalPlanType) => { - switch (type) { - case 'free': { - return 'Hobby'; - } - case 'individual': { - return 'Individual'; - } - case 'team': { - return 'Pro'; - } - case 'enterprise': { - return 'Enterprise'; - } - case 'enterprise-member': { - return 'Enterprise Member'; - } - default: { - return 'Free'; - } - } -}; - -const UpgradeButton = ({ currentPlan }: { currentPlan: CurrentPlan }) => { - // For the enterprise-member plan we don't show the upgrade button. - if (currentPlan?.type === 'enterprise-member') { - return null; - } - - // If user has a team or enterprise plan we navigate them to the Enterprise contact page. - if (['team', 'enterprise'].includes(currentPlan?.type || '')) { - return ( - - {currentPlan?.type === 'enterprise' ? '+ Add more seats' : 'Upgrade'} - - ); - } - - let to = '/app/subscription/update?plan=individual&pay_schedule=year'; - - if (currentPlan?.type === 'individual') { - to = `/app/subscription/update?plan=team&pay_schedule=${currentPlan?.period}`; - } - - return ( - - Upgrade - - ); -}; +import { showSettingsModal } from '~/ui/components/modals/settings-modal'; interface UserButtonProps { user: UserProfileResponse; currentPlan?: CurrentPlan; isMinimal?: boolean; } -export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserButtonProps) => { +export const HeaderUserButton = ({ user, isMinimal = false }: UserButtonProps) => { const logoutFetcher = useLogoutFetcher(); return ( @@ -78,16 +22,9 @@ export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserB className="flex flex-shrink-0 items-center justify-center gap-2 rounded-md px-1 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm] data-[pressed]:bg-[--hl-sm]" > - {user.name} - {currentPlan && Boolean(currentPlan.type) && ( -
- {currentPlan?.planName ?? formatCurrentPlanType(currentPlan.type)} Plan - -
- )} { @@ -95,30 +32,30 @@ export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserB logoutFetcher.submit(); } - if (action === 'account-settings') { - window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/settings/account`); + if (action === 'my-profile') { + window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/settings/profile`); } - if (action === 'manage-organizations') { - window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/dashboard/organizations`); + if (action === 'preferences') { + showSettingsModal(); } }} > - - Manage Organizations - - - Account Settings + Preferences + + + + My Profile void; } +const upgradeModalWording = { + [needsToUpgrade]: { + ownerTitle: 'Upgrade plan to invite more people', + memberTitle: 'Ask plan owner to upgrade to invite more people', + ownerDescription: ( + <> + Your Essentials plan contains Git Sync projects, so you can only collaborate with up to 3 members. Upgrade to + collaborate with unlimited users.{' '} + + Learn more ↗ + + + ), + memberDescription: ( + <> + Your Essentials plan contains Git Sync projects, so you can only collaborate with up to 3 members. Contact your + plan owner to upgrade your team's plan to collaborate with more people.{' '} + + Learn more ↗ + + + ), + submitText: 'Upgrade', + submitLink: getAppWebsiteBaseURL() + '/app/pricing', + }, + [needsToIncreaseSeats]: { + ownerTitle: 'Increase plan seats to invite more people', + memberTitle: 'Your team is full', + ownerDescription: ( + <> + Your team has reached your plan's total purchased seats. Increase your plan's number of seats to continue + inviting new people.{' '} + + Learn more ↗ + + + ), + memberDescription: ( + <> + Your team has reached your plan's total purchased seats. Tell your plan's owner to increase the number of seats + to continue inviting new people.{' '} + + Learn more ↗ + + + ), + submitText: 'Increase seats', + submitLink: getAppWebsiteBaseURL() + '/app/pricing', + }, +}; + export interface EmailInput { email: string; isValid: boolean; @@ -58,10 +124,21 @@ const isValidEmail = (email: string): boolean => { const defaultRoleName = 'member'; -export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) => { +export const InviteForm = ({ + allRoles, + onInviteCompleted, + senderRole, + checkSeatsResponseData, +}: EmailsInputProps & { checkSeatsResponseData: CheckSeatsResponse | undefined }) => { + const organizationId = useParams().organizationId as string; const [searchParams, setSearchParams] = useSearchParams(); - const organizationId = useParams().organizationId as string; + const { userSession } = useRootLoaderData()!; + const organizationData = useOrganizationLoaderData(); + const organization = organizationData?.organizations.find(o => o.id === organizationId); + const isUserOwner = + organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId }); + const sessionId = userSession.id; const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -75,17 +152,39 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) => const selectedRoleRef = React.useRef(allRoles.find(role => role.name === defaultRoleName) as Role); const collaboratorSearchLoader = useCollaboratorsSearchLoaderFetcher(); + let upgradeBannerStatus: 'closed' | typeof needsToUpgrade | typeof needsToIncreaseSeats = 'closed'; + if (checkSeatsResponseData && !checkSeatsResponseData.isAllowed) { + if (checkSeatsResponseData.code === needsToUpgrade) { + upgradeBannerStatus = needsToUpgrade; + } else if (checkSeatsResponseData.code === needsToIncreaseSeats) { + upgradeBannerStatus = needsToIncreaseSeats; + } + } const searchResult = useMemo(() => collaboratorSearchLoader.data || [], [collaboratorSearchLoader.data]); useEffect(() => { - if (searchResult.length > 0) { - setShowResults(true); - } else { - setShowResults(false); - } + setShowResults(searchResult.length > 0); }, [searchResult]); + useEffect(() => { + const checkSeats = async () => { + const validEmails = emails.filter(e => e.isValid); + if (validEmails.length === 0) { + setError(''); + } else { + const data = await insomniaFetch({ + method: 'POST', + path: `/v1/organizations/${organizationId}/check-seats`, + data: { emails: validEmails.map(e => e.email) }, + sessionId, + }); + setError(data.isAllowed ? '' : 'You cannot invite more people than the seats you have remaining'); + } + }; + checkSeats(); + }, [emails, organizationId, sessionId]); + const addEmail = ({ email, teamId, @@ -154,8 +253,40 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) => } }; + const isLightTheme = useIsLightTheme(); + return (
+ {upgradeBannerStatus !== 'closed' && ( +
+ +
+ + {isUserOwner + ? upgradeModalWording[upgradeBannerStatus].ownerTitle + : upgradeModalWording[upgradeBannerStatus].memberTitle} + +

+ {isUserOwner + ? upgradeModalWording[upgradeBannerStatus].ownerDescription + : upgradeModalWording[upgradeBannerStatus].memberDescription} +

+ {isUserOwner && ( + + {upgradeModalWording[upgradeBannerStatus].submitText} + + )} +
+
+ )}
0 ? 'Enter more emails...' : 'Enter emails, separated by comma...'} onKeyDown={handleInputKeyPress} onBlur={handleInputBlur} onPaste={handlePaste} onChange={e => handleSearch(e.currentTarget.value)} + disabled={checkSeatsResponseData && !checkSeatsResponseData.isAllowed} />
@@ -228,9 +360,9 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
+
+
+

+ You’re on your way to easier and safer API testing. Before you dive in, check out how our Enterprise + plan can help you and your team work efficiently and at scale! +

+

Upgrade to Enterprise now for access to...

+
    +
  • +
    + + User Governance +
    + + SSO, SCIM, RBAC and Teams let you control who can access what + +
  • +
  • +
    + + Increased Storage & Security +
    + + Mandate Git, Cloud or Local project storage, plus E2EE + +
  • +
  • +
    + + World Class Support +
    + + A dedicated CSM that understands you, support access, and optional pro services to start quickly + +
  • +
+
+
+ + {checkerData?.isEligible && ( + + )} + +
+
+ )} + + + + ); +}; + +UpgradePlanModal.displayName = 'UpgradePlanModal'; diff --git a/packages/insomnia/src/ui/components/project/project-settings-form.tsx b/packages/insomnia/src/ui/components/project/project-settings-form.tsx index 8b6f3cb424..81145ce231 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; import { @@ -22,10 +23,20 @@ import { } from 'react-aria-components'; import { useParams } from 'react-router'; +import { getAppWebsiteBaseURL } from '~/common/constants'; +import { docsPricingLearnMoreLink } from '~/common/documentation'; import { isGitCredentialsOAuth } from '~/models/git-repository'; -import type { StorageRules } from '~/models/organization'; +import { isOwnerOfOrganization, type StorageRules } from '~/models/organization'; +import { useRootLoaderData } from '~/root'; import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone'; +import { useOrganizationLoaderData } from '~/routes/organization'; +import { + fallbackFeatures, + useOrganizationPermissionsLoaderFetcher, +} from '~/routes/organization.$organizationId.permissions'; import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new'; +import { useIsLightTheme } from '~/ui/hooks/theme'; +import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import type { OauthProviderName } from '../../../models/git-credentials'; import type { GitRepository } from '../../../models/git-repository'; @@ -87,6 +98,17 @@ export const ProjectSettingsForm: FC = ({ }) => { const { organizationId } = useParams() as { organizationId: string }; + const permissionsFetcher = useOrganizationPermissionsLoaderFetcher({ key: `permissions:${organizationId}` }); + const permissionsFetcherLoad = permissionsFetcher.load; + useEffect(() => { + permissionsFetcherLoad({ + organizationId, + }); + }, [organizationId, permissionsFetcherLoad]); + const { featuresPromise } = permissionsFetcher.data || {}; + const [features = fallbackFeatures] = useLoaderDeferData(featuresPromise, organizationId); + isGitSyncEnabled = features.gitSync.enabled; + const [storageType, setStorageType] = useState<'local' | 'remote' | 'git'>( getDefaultProjectStorageType(storageRules, project), ); @@ -221,6 +243,14 @@ export const ProjectSettingsForm: FC = ({ } }; + const organizationData = useOrganizationLoaderData(); + const { userSession } = useRootLoaderData()!; + const organization = organizationData?.organizations.find(o => o.id === organizationId); + const isUserOwner = + organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId }); + + const isLightTheme = useIsLightTheme(); + return (
{error && ( @@ -283,7 +313,7 @@ export const ProjectSettingsForm: FC = ({

@@ -296,6 +326,57 @@ export const ProjectSettingsForm: FC = ({

+ {storageType === 'git' && !isGitSyncEnabled && ( +
+ +
+ + Git Sync limited to organizations of 3 or fewer users + + {isUserOwner ? ( + <> +

+ Git Sync is included on your plan for up to 3 users. Since your team is larger, you’ll need to + upgrade your plan to use it.{' '} + + Learn more ↗ + +

+ + Upgrade + + + ) : ( + <> +

+ Git Sync is included on your plan for up to 3 users. Because your team is larger, your admin + will need to upgrade the plan for you to access it. +

+ + Learn More ↗ + + + )} +
+
+ )} {showStorageRestrictionMessage && (
@@ -319,6 +400,7 @@ export const ProjectSettingsForm: FC = ({ )} {storageType === 'git' && (