feat: new pricing features (#9176)

* feat: new pricing

feat: invite check

fix: fixes based on comments

feat: invite_not_permitted event

fix: sync user info after trial

Popup for upgrade plan when creating a git project

change wording

fix theme color

feat: add segment events

feat: report user local git project count

fix theme issue

feat: INS-1582 new design for upgrade modal

Add pop-up for inviting members

feat: add the plan indicator

feat: new design of top-right corner

fix: fix quota

fix: fix top-right corner width

* fix: remove button

* Add upgrade plan banner in invite modal

* fix type issue

* fix smoke test

* Remove console.log

* fix: fix quota issue

* update useIsLightTheme

* fix type

* add annotation

* move doc link

* fix: fix source

---------

Co-authored-by: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com>
This commit is contained in:
xdm
2025-10-29 19:17:21 +08:00
committed by GitHub
parent 94442896f0
commit 519014a709
31 changed files with 1342 additions and 134 deletions

View File

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

View File

@@ -0,0 +1,2 @@
export * from '../ui/components/icon';
// https://fontawesome.com/search?q=user&ic=free&o=r

View File

@@ -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<React.PropsWithChildren<Props>> = ({
isOpen,
onClose,
className,
title,
closable,
children,
}) => {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={isOpen => {
!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"
>
<RAModal
onOpenChange={isOpen => {
!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,
)}
>
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-none">
{({ close }) => (
<>
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
{' '}
<div className="flex flex-shrink-0 items-center justify-between gap-2">
{title && (
<Heading slot="title" className="text-3xl">
{title}
</Heading>
)}
{closable && (
<Button
className="flex aspect-square h-6 flex-shrink-0 items-center justify-center rounded-sm 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]"
onPress={() => close()}
>
<Icon icon="x" />
</Button>
)}
</div>
</div>
{children}
</>
)}
</Dialog>
</RAModal>
</ModalOverlay>
);
};

View File

@@ -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 (
<div
// FIXME: use css variables for colors
className={classNames(
'h-[10px] flex-grow overflow-hidden rounded-full bg-[#f1e6ff]',
status === 'error' && 'bg-[#db110040]',
status === 'success' && 'bg-[#00bf7340]',
className,
)}
>
<div
className={classNames(
'transition-width h-full rounded-full bg-[--color-surprise] duration-1000 ease-in-out',
status === 'error' && 'bg-[--color-danger]',
status === 'success' && 'bg-[--color-success]',
)}
style={{
width: `${percent}%`,
}}
/>
</div>
);
};

View File

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

View File

@@ -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<AllExportTypes, AllTypes> = {
request: 'Request',
websocket_payload: 'WebSocketPayload',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<InsomniaEventStreamProvider>
<InsomniaTabProvider>
@@ -262,11 +262,15 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
{!user ? <GitHubStarsButton /> : null}
</div>
<CommandPalette />
<div className="flex items-center justify-end gap-[--padding-sm] p-2">
<div className="flex min-w-min items-center justify-end gap-[--padding-sm] space-x-3 p-2">
{user ? (
<Fragment>
<PresentUsers />
<HeaderInviteButton className="border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 font-semibold text-[--color-font-surprise]" />
<HeaderInviteButton
organizationId={organizationId}
className="border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 font-semibold text-[--color-font-surprise]"
/>
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</Fragment>
) : (
@@ -583,7 +587,8 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
{user ? (
<Fragment>
<PresentUsers />
<HeaderInviteButton className="text-[--color-font]" />
<HeaderInviteButton className="text-[--color-font]" organizationId={organizationId} />
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</Fragment>
) : (

View File

@@ -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<ResourceUsage>({
method: 'GET',
path: '/v1/user/resource-usage',
sessionId,
});
}
function getOwnEnterprises(sessionId: string) {
return insomniaFetch<EnterpriseOwner[]>({
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<AccountUsedSeats>({
method: 'GET',
path: '/v1/accounts/seats',
sessionId,
});
}
function getEnterpriseLicenseUsage(sessionId: string, enterpriseId: string) {
return insomniaFetch<LicenseUsage>({
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,
);

View File

@@ -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<Eligible | { error: string }>({
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,
);

View File

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

View File

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

View File

@@ -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<Record<Permission, boolean> | 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 ? (
<div>
You cannot invite anyone as there are no available licenses.{' '}
<Link href={`${getAppWebsiteBaseURL()}/app/home`} className="text-[var(--color-surprise)]">
You can review your usage here.
</Link>
</div>
) : (
'You cannot invite anyone as there are no available licenses. Contact your organizations Insomnia admins for more info.'
)
) : (
// !hasPermissions && hasAvailableLicenses: will popup 'missing someone'
''
);
const [missingOpen, setMissingOpen] = React.useState(false);
const button = (
<Button
isDisabled={Boolean(tip)}
aria-label="Invite collaborators"
className={`${className} flex h-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80`}
onPress={() => {
if (!hasPermissions) {
setMissingOpen(true);
return;
}
setIsInviteModalOpen(true);
}}
>
<Icon icon="user-plus" />
<span className="truncate">Invite</span>
</Button>
);
if (tip) {
return (
<Tooltip message={tip} position="bottom">
{button}
<InviteModalContainer
{...{
isOpen: isInviteModalOpen,
setIsOpen: setIsInviteModalOpen,
}}
/>
</Tooltip>
);
}
return (
<>
<Button
aria-label="Invite collaborators"
className={`${className} flex h-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80`}
onPress={() => setIsInviteModalOpen(true)}
>
<Icon icon="user-plus" />
<span className="truncate">Invite</span>
</Button>
{button}
<InviteModalContainer
{...{
isOpen: isInviteModalOpen,
setIsOpen: setIsInviteModalOpen,
}}
/>
{!hasPermissions && <MissingSomeoneModal isOpen={missingOpen} onClose={() => setMissingOpen(false)} />}
</>
);
};
const MissingSomeoneModal = ({ isOpen, onClose }: any) => {
const [reason, setReason] = useState<string | null>(null);
const handleClose = () => {
if (reason) {
window.main.trackSegmentEvent({
event: SegmentEvent.inviteNotPermitted,
properties: {
collaboration_type: reason,
},
});
}
onClose?.();
};
return (
<Modal title="Missing someone?" isOpen={isOpen} onClose={handleClose}>
<p className="mt-8">
You're on a paid plan, so please contact your company's Insomnia admins to get anyone added to this account.
</p>
<p className="my-2 font-semibold">Just curious - why do you want to invite someone?</p>
<RadioGroup
name="inviteReason"
value={reason}
onChange={reason => {
setReason(reason);
}}
className="flex flex-col gap-2"
>
<Radio
value="long-term"
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
>
<div className="flex items-center gap-2">
<Heading className="text-lg font-bold">To work together in Insomnia long-term</Heading>
</div>
</Radio>
<Radio
value="one-time"
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
>
<div className="flex items-center gap-2">
<Heading className="text-lg font-bold">Just to show them something</Heading>
</div>
</Radio>
</RadioGroup>
<div className="flex justify-end">
<Button
className="mt-8 rounded-md bg-[--color-surprise] px-4 py-2 text-white hover:brightness-90 focus:brightness-90"
onPress={handleClose}
>
Close
</Button>
</div>
</Modal>
);
};

View File

@@ -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 (
<DialogTrigger isOpen={open} onOpenChange={setOpen}>
<Button className="flex h-[30px] flex-shrink-0 items-center justify-center gap-2 rounded-md px-2 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]">
<span>{planName}</span>
{isTrailing && (
<span className="flex h-[20px] items-center rounded bg-[--color-surprise] px-[4px] text-[--color-font-surprise]">
Trial
</span>
)}
<Icon className="w-4" icon={isMinimal ? 'caret-up' : 'caret-down'} />
</Button>
<Popover
className="max-h-[85vh] min-w-max select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] py-2 text-sm shadow-lg focus:outline-none"
placement="bottom end"
>
<Dialog className="focus:outline-none">
<div className="mt-[8px] flex w-[250px] flex-col text-[--color-font]">
<div className="flex items-center justify-between px-[12px]">
<div className="flex flex-col gap-1">
<span className="text[--color-font-surprise] flex items-center gap-[4px] font-semibold">
{planName}
{isTrailing && (
<span className="flex h-[20px] items-center rounded bg-[--color-surprise] px-[4px] text-[--color-font-surprise]">
Trial
</span>
)}
</span>
{isTrailing && <span>{trialDaysLeft ?? '--'} days left of free trial</span>}
</div>
{!isEnterpriseMember && (
<Link
className="rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
href={
isEssential
? getAppWebsiteBaseURL() + '/app/pricing'
: getAppWebsiteBaseURL() + '/app/subscription/update?plan=enterprise&source=app_topbar'
}
>
{isEnterpriseLike ? 'Add seats' : 'Upgrade'}
</Link>
)}
</div>
<Separator className="mt-[12px] border border-solid border-[--hl-sm]" />
<div className="my-[8px] flex flex-col gap-[18px] px-[12px]">
{!isEnterpriseMember && (
<div>
<div className="flex items-center">
<span className="capitalize">{isEnterpriseLike ? 'managed ' : ''}users</span>
<Tooltip position="bottom" message={userTip}>
<Icon
icon={userStatus === 'error' ? 'exclamation-triangle' : 'info-circle'}
className={classNames('ml-2', userStatus === 'error' && 'text-[--color-danger]')}
/>
</Tooltip>
<span className="ml-auto">
{used}/{isUserUnlimited ? 'Unlimited' : formatNumber(total || 0)}
</span>
</div>
<Progress className="mt-2" status={userStatus} percent={isUserUnlimited ? 100 : seatsUsage} />
</div>
)}
{isEnterpriseOwner && (
<div>
<div className="flex items-center">
<span className="capitalize">Unmanaged users</span>
<Tooltip
position="bottom"
message="This number represents the amount of free users are in your plan."
>
<Icon icon="info-circle" className="ml-2" />
</Tooltip>
<span className="ml-auto">{free || 0}</span>
</div>
<Progress className="mt-2" status={free ? 'success' : 'normal'} percent={free ? 100 : 0} />
</div>
)}
<div>
<div className="flex items-center">
<span className="capitalize">Mock Requests</span>
<Tooltip position="bottom" message={mockTip}>
<Icon
icon={mockStatus === 'error' ? 'exclamation-triangle' : 'info-circle'}
className={classNames('ml-2', mockStatus === 'error' && 'text-[--color-danger]')}
/>
</Tooltip>
<span className="ml-auto">
{formatNumber(usedMocks)}/{' '}
{isUnlimited ? 'Unlimited' : formatNumber(usageData?.resourceUsage?.mocks?.quota || 0)}
</span>
</div>
<Progress className="mt-2" status={mockStatus} percent={isUnlimited ? 100 : mockUsage} />
</div>
</div>
{!isEnterpriseMember && (
<div className="my-[8px] px-[12px]">
<Icon icon="gear" className="text-[--color-font]" />
<a
href={getAppWebsiteBaseURL() + '/app/home'}
className="px-3 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
>
Manage
</a>
</div>
)}
{canTrial && (
<div className="my-[8px] px-[12px]">
<Button
className="h-[22px] rounded bg-[--color-surprise] px-[12px] text-center text-sm text-[--color-font-surprise]"
onPress={() => handleStartTrial()}
>
Free Enterprise Trial
</Button>
</div>
)}
</div>
</Dialog>
</Popover>
</DialogTrigger>
);
};

View File

@@ -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 (
<a
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
href={'https://insomnia.rest/pricing/contact'}
>
{currentPlan?.type === 'enterprise' ? '+ Add more seats' : 'Upgrade'}
</a>
);
}
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 (
<a
href={getAppWebsiteBaseURL() + to}
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
>
Upgrade
</a>
);
};
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]"
>
<Avatar src={user.picture} alt={user.name} />
<span className="truncate">{user.name}</span>
<Icon className="w-4 pr-2" icon={isMinimal ? 'caret-up' : 'caret-down'} />
</Button>
<Popover className="max-h-[85vh] min-w-max select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] py-2 text-sm shadow-lg focus:outline-none">
{currentPlan && Boolean(currentPlan.type) && (
<div className="flex h-[--line-height-xs] w-full items-center justify-between gap-2 whitespace-nowrap border-b border-solid border-[--hl-sm] px-[--padding-md] pb-2 capitalize text-[--color-font]">
<span>{currentPlan?.planName ?? formatCurrentPlanType(currentPlan.type)} Plan</span>
<UpgradeButton currentPlan={currentPlan} />
</div>
)}
<Menu
className="focus:outline-none"
onAction={action => {
@@ -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();
}
}}
>
<MenuItem
id="manage-organizations"
className="flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
aria-label="Manage organizations"
>
<Icon icon="users" />
<span>Manage Organizations</span>
</MenuItem>
<MenuItem
id="account-settings"
className="flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
aria-label="Account settings"
id="preferences"
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
aria-label="preferences"
>
<Icon icon="gear" />
<span>Account Settings</span>
<span>Preferences</span>
</MenuItem>
<MenuItem
id="my-profile"
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
aria-label="My profile"
>
<Icon icon="user" />
<span>My Profile</span>
</MenuItem>
<MenuItem
id="logout"

View File

@@ -1,6 +1,8 @@
import classNames from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Heading,
type Key,
ListBox,
ListBoxItem,
@@ -11,10 +13,22 @@ import {
} from 'react-aria-components';
import { useParams, useSearchParams } from 'react-router';
import { getAppWebsiteBaseURL } from '~/common/constants';
import { docsPricingLearnMoreLink } from '~/common/documentation';
import { debounce } from '~/common/misc';
import { isOwnerOfOrganization } from '~/models/organization';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import {
type CheckSeatsResponse,
needsToIncreaseSeats,
needsToUpgrade,
} from '~/routes/organization.$organizationId.collaborators-check-seats';
import { useCollaboratorsSearchLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-search';
import { SegmentEvent } from '~/ui/analytics';
import { Icon } from '~/ui/components/icon';
import { useIsLightTheme } from '~/ui/hooks/theme';
import { insomniaFetch } from '~/ui/insomniaFetch';
import { startInvite } from './encryption';
import { OrganizationMemberRolesSelector, type Role, SELECTOR_TYPE } from './organization-member-roles-selector';
@@ -38,9 +52,61 @@ export function getSearchParamsString(
interface EmailsInputProps {
allRoles: Role[];
senderRole: Role;
onInviteCompleted?: () => 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.{' '}
<a href={docsPricingLearnMoreLink} className="underline">
Learn more
</a>
</>
),
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.{' '}
<a href={docsPricingLearnMoreLink} className="underline">
Learn more ↗
</a>
</>
),
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.{' '}
<a href={docsPricingLearnMoreLink} className="underline">
Learn more ↗
</a>
</>
),
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.{' '}
<a href={docsPricingLearnMoreLink} className="underline">
Learn more ↗
</a>
</>
),
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<Role>(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<CheckSeatsResponse>({
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 (
<div className="flex w-full flex-col gap-1">
{upgradeBannerStatus !== 'closed' && (
<div
className={classNames('mb-5 mt-3 flex items-start justify-start gap-5 rounded-md px-6 py-5', {
'bg-[#292535]': !isLightTheme,
'bg-[#EEEBFF]': isLightTheme,
})}
>
<Icon icon="circle-info" className="pt-1.5" />
<div className="flex flex-col items-start justify-start gap-3.5">
<Heading className="text-lg font-bold">
{isUserOwner
? upgradeModalWording[upgradeBannerStatus].ownerTitle
: upgradeModalWording[upgradeBannerStatus].memberTitle}
</Heading>
<p>
{isUserOwner
? upgradeModalWording[upgradeBannerStatus].ownerDescription
: upgradeModalWording[upgradeBannerStatus].memberDescription}
</p>
{isUserOwner && (
<a
href={upgradeModalWording[upgradeBannerStatus].submitLink}
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
>
{upgradeModalWording[upgradeBannerStatus].submitText}
</a>
)}
</div>
</div>
)}
<div className="flex w-full items-center gap-4">
<div
className="flex flex-1 justify-between gap-3 rounded-md border border-[#4c4c4c] bg-[--hl-xs] p-2"
@@ -207,12 +338,13 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
<input
ref={inputRef}
type="text"
className="min-h-[24px] grow-[inherit] border-none px-2 py-1 leading-6 outline-none"
className="min-h-[24px] grow-[inherit] border-none px-2 py-1 leading-6 outline-none disabled:cursor-not-allowed"
placeholder={emails.length > 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}
/>
</div>
<div className="flex w-[81px] items-center">
@@ -228,9 +360,9 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
</div>
</div>
<Button
className="h-[40px] w-[67px] shrink-0 self-end rounded bg-[#4000bf] text-center text-[--color-font-surprise] disabled:opacity-70"
isDisabled={loading}
onPress={() => {
className="h-[40px] w-[67px] shrink-0 self-end rounded bg-[#4000bf] text-center text-[--color-font-surprise] disabled:cursor-not-allowed disabled:opacity-70"
isDisabled={loading || (checkSeatsResponseData && !checkSeatsResponseData.isAllowed)}
onPress={async () => {
if (emails.some(({ isValid }) => !isValid)) {
setError('Some emails are invalid, please correct them before inviting.');
return;
@@ -256,7 +388,8 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
properties: {
numberOfInvites: emailsToInvite.length,
numberOfTeams: groupsToInvite.length,
role: selectedRoleRef.current.name,
receiver_role: selectedRoleRef.current.name,
sender_role: senderRole.name,
},
});

View File

@@ -20,6 +20,7 @@ import { debounce } from '~/common/misc';
import { type Collaborator, useCollaboratorsFetcher } from '~/routes/organization.$organizationId.collaborators';
import { useInviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId';
import { useReinviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite';
import { useCollaboratorsCheckSeatsLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-check-seats';
import { useOrganizationMemberRolesActionFetcher } from '~/routes/organization.$organizationId.members.$userId.roles';
import { SegmentEvent } from '~/ui/analytics';
import { PromptButton } from '~/ui/components/base/prompt-button';
@@ -128,9 +129,16 @@ const InviteModal: FC<{
setSearchParams(getSearchParamsString(searchParams, { page, filter: queryInputString }));
};
const collaboratorsCheckSeatsLoader = useCollaboratorsCheckSeatsLoaderFetcher();
const checkSeatsResponseData = collaboratorsCheckSeatsLoader.data;
const collaboratorsCheckSeatsLoaderLoad = collaboratorsCheckSeatsLoader.load;
useEffect(() => {
collaboratorsCheckSeatsLoaderLoad({ organizationId });
}, [collaboratorsCheckSeatsLoaderLoad, organizationId]);
return (
<ModalOverlay
isDismissable={true}
isDismissable={false}
isOpen={true}
onOpenChange={setIsOpen}
className="theme--transparent-overlay fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-[--color-bg]"
@@ -149,9 +157,12 @@ const InviteModal: FC<{
onInviteCompleted={() => {
if (organizationId) {
resetCollaboratorsList();
collaboratorsCheckSeatsLoaderLoad({ organizationId });
}
}}
senderRole={currentUserRoleInOrg}
allRoles={allRoles}
checkSeatsResponseData={checkSeatsResponseData}
/>
<hr className="my-[24px] border" />
</>
@@ -214,6 +225,9 @@ const InviteModal: FC<{
revalidateCurrentUserRoleAndPermissionsInOrg={revalidateCurrentUserRoleAndPermissionsInOrg}
onResetCurrentPage={resetCurrentPage}
onError={setError}
onRemoveMember={() => {
collaboratorsCheckSeatsLoaderLoad({ organizationId });
}}
/>
))}
</ListBox>
@@ -258,6 +272,7 @@ const MemberListItem: FC<{
revalidateCurrentUserRoleAndPermissionsInOrg: (organizationId: string) => Promise<[void, void]>;
onResetCurrentPage: () => void;
onError: (error: string | null) => void;
onRemoveMember: () => void;
}> = ({
organizationId,
member,
@@ -270,6 +285,7 @@ const MemberListItem: FC<{
revalidateCurrentUserRoleAndPermissionsInOrg,
onResetCurrentPage,
onError,
onRemoveMember,
}) => {
const reinviteCollaboratorFetcher = useReinviteFetcher();
const reinviting = reinviteCollaboratorFetcher.state !== 'idle';
@@ -447,7 +463,15 @@ const MemberListItem: FC<{
doneMessage={isFailed ? 'Failed' : isAcceptedMember || isGroup ? 'Removed' : 'Revoked'}
disabled={memberRoleName === 'owner' || isCurrentUser}
onClick={() => {
if (!permissionRef.current['delete:membership']) {
if (isPendingMember && member.metadata.invitationId) {
if (!permissionRef.current['delete:invitation']) {
showModal(AlertModal, {
title: 'Permission required',
message: "You don't have permission to make this action, please contact the organization owner.",
});
return;
}
} else if (!permissionRef.current['delete:membership']) {
showModal(AlertModal, {
title: 'Permission required',
message: "You don't have permission to make this action, please contact the organization owner.",
@@ -462,6 +486,7 @@ const MemberListItem: FC<{
deleteMember(organizationId, member.metadata.userId!)
.then(() => {
onResetCurrentPage();
onRemoveMember();
})
.catch(error => {
onError(error.message);
@@ -473,6 +498,7 @@ const MemberListItem: FC<{
revokeOrganizationInvite(organizationId, member.metadata.invitationId)
.then(() => {
onResetCurrentPage();
onRemoveMember();
window.main.trackSegmentEvent({ event: SegmentEvent.inviteRevoked });
})
.catch(error => {
@@ -485,6 +511,7 @@ const MemberListItem: FC<{
unlinkTeam(organizationId, member.id)
.then(() => {
onResetCurrentPage();
onRemoveMember();
})
.catch(error => {
onError(error.message);

View File

@@ -41,7 +41,9 @@ export const UpgradeModal = forwardRef<UpgradeModalHandle, ModalProps>((_, ref)
const message = `${featureName} is only enabled for ${planDetail}, ${upgradeDetail}`;
const onDone = async (isYes: boolean) => {
if (isYes) {
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team`);
window.main.openInBrowser(
`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team&source=app_feature_${featureName}`,
);
}
};
if (isOwner) {

View File

@@ -0,0 +1,169 @@
import classnames from 'classnames';
import React, { useEffect, useLayoutEffect, useState } from 'react';
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
import { getAppWebsiteBaseURL } from '~/common/constants';
import { useRootLoaderData } from '~/root';
import { useTrialCheckLoaderFetcher } from '~/routes/trial.check';
import { useTrialStartActionFetcher } from '~/routes/trial.start';
import { Icon } from '~/ui/components/icon';
import { usePlanData } from '~/ui/hooks/use-plan';
export interface UpgradeModalOptions extends Partial<any> {
featureName: string;
isOwner: boolean;
}
export interface UpgradeModalHandle {
show: (options: UpgradeModalOptions) => void;
hide: () => void;
}
const SIXTY_DAYS = 60 * 24 * 60 * 60 * 1000;
export const UpgradePlanModal = () => {
const { userSession } = useRootLoaderData()!;
const { isFreePlan } = usePlanData();
const { firstName, email, accountId } = userSession;
const [open, setOpen] = useState(false);
const { load: checkerLoad, data: checkerData } = useTrialCheckLoaderFetcher();
const startFetcher = useTrialStartActionFetcher();
const handleUpgrade = () => {
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/pricing`);
};
const handleClose = () => {
window.localStorage.setItem(`upgrade-modal-dismissed:${accountId}`, new Date().toISOString());
setOpen(false);
};
const handleStartTrial = () => {
if (startFetcher.state === 'idle') {
startFetcher.submit();
}
setOpen(false);
};
// show once every 60 days, it is more safe to use useLayoutEffect in case of localStorage failure
useLayoutEffect(() => {
// only show for free plan
if (!isFreePlan) {
return;
}
const dismissedDate = window.localStorage.getItem(`upgrade-modal-dismissed:${accountId}`);
if (!dismissedDate || new Date(dismissedDate).getTime() + SIXTY_DAYS < Date.now()) {
checkerLoad();
}
}, [checkerLoad, accountId, isFreePlan]);
useEffect(() => {
if (checkerData?.isEligible) {
setOpen(true);
}
}, [checkerData?.isEligible]);
useEffect(() => {
if (startFetcher.data?.success) {
setOpen(false);
}
}, [startFetcher.data?.success]);
return (
<ModalOverlay
isOpen={open}
onOpenChange={isOpen => {
!isOpen && handleClose();
}}
className="fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-black/30"
>
<Modal
onOpenChange={isOpen => {
!isOpen && handleClose();
}}
className={classnames(
'flex w-[540px] 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-none">
{({ close }) => (
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
<div className="flex flex-shrink-0 items-center justify-between gap-2">
<Heading slot="title" className="text-[18px] text-[--color-font]">
Welcome to Insomnia, {firstName || email} 🎉
</Heading>
<Button
className="ml-auto flex h-6 flex-shrink-0 items-center justify-center rounded-sm text-sm text-[--color-font] ring-1 ring-transparent transition-all focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
onPress={() => close()}
>
<Icon icon="x" />
</Button>
</div>
<div className="flex flex-col">
<p className="text-md">
Youre 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!
</p>
<p className="mt-[26px] text-[16px]">Upgrade to Enterprise now for access to...</p>
<ul className="mt-2 flex flex-col items-start gap-2">
<li className="flex flex-col py-2">
<div className="flex items-center gap-[8px]">
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
<span className="font-semibold">User Governance</span>
</div>
<span className="ml-[24px] mt-1 text-sm">
SSO, SCIM, RBAC and Teams let you control who can access what
</span>
</li>
<li className="flex flex-1 flex-col py-2">
<div className="flex items-center gap-[8px]">
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
<span className="font-semibold">Increased Storage & Security</span>
</div>
<span className="ml-[24px] mt-1 text-sm">
Mandate Git, Cloud or Local project storage, plus E2EE
</span>
</li>
<li className="flex flex-1 flex-col py-2">
<div className="flex items-center gap-[8px]">
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
<span className="font-semibold">World Class Support</span>
</div>
<span className="ml-[24px] mt-1 text-sm">
A dedicated CSM that understands you, support access, and optional pro services to start quickly
</span>
</li>
</ul>
</div>
<div className="mt-3 flex justify-start gap-[20px]">
<Button
className="h-[30px] rounded bg-[--color-surprise] px-[12px] text-center text-sm text-[--color-font-surprise]"
onPress={handleUpgrade}
>
Buy Now
</Button>
{checkerData?.isEligible && (
<Button
className="h-[30px] rounded border border-solid !border-[--hl-md] px-[12px] text-sm text-[--color-font] hover:bg-[--hl-xs]"
isDisabled={startFetcher.state !== 'idle'}
onPress={handleStartTrial}
>
Try Free for 14 Days
</Button>
)}
<Button
className="ml-auto h-[30px] rounded !border-none px-[0px] text-sm text-[--color-font]"
onPress={() => close()}
>
No, Thanks
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};
UpgradePlanModal.displayName = 'UpgradePlanModal';

View File

@@ -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<Props> = ({
}) => {
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<Props> = ({
}
};
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 (
<div className="flex w-full max-w-[600px] flex-col gap-4">
{error && (
@@ -283,7 +313,7 @@ export const ProjectSettingsForm: FC<Props> = ({
</p>
</Radio>
<Radio
isDisabled={!isGitSyncEnabled || !storageRules.enableGitSync}
isDisabled={!storageRules.enableGitSync}
value="git"
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
>
@@ -296,6 +326,57 @@ export const ProjectSettingsForm: FC<Props> = ({
</p>
</Radio>
</div>
{storageType === 'git' && !isGitSyncEnabled && (
<div
className={classNames('mt-3 flex items-start justify-start gap-5 rounded-md px-6 py-5', {
'bg-[#292535]': !isLightTheme,
'bg-[#EEEBFF]': isLightTheme,
})}
>
<Icon icon="circle-info" className="pt-1.5" />
<div className="flex flex-col items-start justify-start gap-3.5">
<Heading className="text-lg font-bold">
Git Sync limited to organizations of 3 or fewer users
</Heading>
{isUserOwner ? (
<>
<p>
Git Sync is included on your plan for up to 3 users. Since your team is larger, youll need to
upgrade your plan to use it.{' '}
<a href={docsPricingLearnMoreLink} className="underline">
Learn more
</a>
</p>
<a
href={
getAppWebsiteBaseURL() +
'/app/subscription/update?plan=team&pay_schedule=year&source=app_feature_git_sync'
}
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
>
Upgrade
</a>
</>
) : (
<>
<p>
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.
</p>
<a
href={
getAppWebsiteBaseURL() +
'/app/subscription/update?plan=team&pay_schedule=year&source=app_feature_git_sync'
}
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
>
Learn More
</a>
</>
)}
</div>
</div>
)}
</RadioGroup>
{showStorageRestrictionMessage && (
<div className="flex items-center gap-2 rounded-sm bg-[rgba(var(--color-warning-rgb),0.5)] px-2 py-1 text-sm text-[--color-font-warning]">
@@ -319,6 +400,7 @@ export const ProjectSettingsForm: FC<Props> = ({
)}
{storageType === 'git' && (
<Button
isDisabled={!isGitSyncEnabled}
onPress={() => setActiveView('git-clone')}
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 px-4 py-2 text-sm font-semibold text-[--color-font-surprise] ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80"
>

View File

@@ -19,7 +19,7 @@ export const UpgradeNotice = (props: UpgradeNoticeProps) => {
: 'Please contact the organization owner to upgrade the plan.';
const message = `${featureName} is only enabled for ${planDetail}.`;
const handleUpgradePlan = () => {
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team`);
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team&source=app_${featureName}`);
};
return (

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as reactUse from 'react-use';
import { useRootLoaderData } from '~/root';
@@ -91,20 +91,54 @@ export const useThemes = () => {
export const useIsLightTheme = () => {
const rootLoaderData = useRootLoaderData();
const isLightTheme = useMemo(() => {
let lightTheme = 'default';
let darkTheme = 'default';
let theme = 'default';
let autoDetectColorScheme = false;
if (rootLoaderData?.settings) {
lightTheme = rootLoaderData.settings.lightTheme;
darkTheme = rootLoaderData.settings.darkTheme;
theme = rootLoaderData.settings.theme;
autoDetectColorScheme = rootLoaderData.settings.autoDetectColorScheme;
}
const calcIsLightTheme = useCallback(() => {
let isLightTheme = false;
if (rootLoaderData?.settings) {
const colorScheme = getColorScheme(rootLoaderData.settings);
if (colorScheme === 'light') {
isLightTheme = true;
} else if (colorScheme === 'dark') {
isLightTheme = false;
} else {
// check if user has selected a light theme
isLightTheme = rootLoaderData.settings.theme.includes('light');
}
const colorScheme = getColorScheme({
autoDetectColorScheme,
darkTheme,
lightTheme,
theme,
});
if (colorScheme === 'light') {
isLightTheme = lightTheme.includes('light');
} else if (colorScheme === 'dark') {
isLightTheme = darkTheme.includes('light');
} else {
// check if user has selected a light theme
isLightTheme = theme.includes('light');
}
return isLightTheme;
}, [rootLoaderData?.settings]);
}, [lightTheme, darkTheme, theme, autoDetectColorScheme]);
const [isLightTheme, setIsLightTheme] = useState<boolean>(calcIsLightTheme);
// Listen to system theme changes
useEffect(() => {
const matches = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => {
setIsLightTheme(calcIsLightTheme());
};
matches.addEventListener('change', onChange);
return () => {
matches.removeEventListener('change', onChange);
};
}, [calcIsLightTheme]);
// Listen to settings changes
useEffect(() => {
setIsLightTheme(calcIsLightTheme());
}, [calcIsLightTheme, lightTheme, darkTheme, theme, autoDetectColorScheme]);
return isLightTheme;
};

View File

@@ -0,0 +1,25 @@
import { useOrganizationLoaderData } from '~/routes/organization';
import { diffInDayCeil } from '~/utils';
export function useUserService() {
const { currentPlan, user } = useOrganizationLoaderData()!;
const isPro = currentPlan?.type === 'individual' || currentPlan?.type === 'team';
const isEnterpriseOwner = currentPlan?.type === 'enterprise';
const isEnterpriseMember = currentPlan?.type === 'enterprise-member';
const isEssential = currentPlan?.type === 'free';
const isEnterpriseLike = isEnterpriseOwner || isEnterpriseMember;
const isTrailing = currentPlan?.status === 'trialing';
const trialDaysLeft: number | null =
isTrailing && currentPlan?.trialingEnd ? diffInDayCeil(new Date(currentPlan?.trialingEnd), new Date()) : null;
return {
isPro,
isEnterpriseOwner,
isEnterpriseMember,
isEssential,
isEnterpriseLike,
canUpgrade: !isEnterpriseLike,
displayName: user?.name || user?.email,
isTrailing,
trialDaysLeft,
};
}

View File

@@ -1,5 +1,6 @@
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { OAuthAuthorizationStatusModal } from '~/ui/components/modals/oauth-authorization-status-modal';
import { UpgradePlanModal } from '~/ui/components/modals/upgrade-plan-modal';
import { ErrorBoundary } from './components/error-boundary';
import { registerModal } from './components/modals';
@@ -22,7 +23,6 @@ import { WrapperModal } from './components/modals/wrapper-modal';
const Modals = () => {
const workspaceData = useWorkspaceLoaderData();
const { activeWorkspace, activeEnvironment } = workspaceData || {};
return (
<div key="modals" className="modals">
<ErrorBoundary showAlert>
@@ -49,6 +49,8 @@ const Modals = () => {
<SettingsModal ref={instance => registerModal(instance, 'SettingsModal')} />
<UpgradePlanModal />
<ResponseDebugModal ref={instance => registerModal(instance, 'ResponseDebugModal')} />
<AddKeyCombinationModal ref={instance => registerModal(instance, 'AddKeyCombinationModal')} />

View File

@@ -54,6 +54,21 @@ export function sortOrganizations(accountId: string, organizations: Organization
return [...(home ? [home] : []), ...myOrgs, ...notMyOrgs];
}
export async function syncCurrentPlan(sessionId: string, accountId: string) {
const [currentPlanResult] = await Promise.allSettled([
insomniaFetch<CurrentPlan | void>({
method: 'GET',
path: '/v1/billing/current-plan',
sessionId,
}),
]);
if (currentPlanResult.status === 'fulfilled' && currentPlanResult.value) {
localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlanResult.value));
} else {
console.log('[current-plan] Failed to load current-plan', currentPlanResult.status);
}
}
export async function syncOrganizations(sessionId: string, accountId: string) {
try {
const [organizationsResult, user, currentPlan] = await Promise.all([

View File

@@ -78,3 +78,12 @@ export const moveAfter = (list: any[], key: Key, keys: Iterable<Key>) => {
export const typedKeys = <T extends object>(obj: T) => {
return Object.keys(obj) as (keyof T)[];
};
export function diffInDayCeil(dateA: Date, dateB: Date) {
const diffTime = dateA.getTime() - dateB.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
export function formatNumber(num: number) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d)(?=\.\d*|$))/g, ',');
}