Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions frontend/app/src/components/forms/new-tenant-saver-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import {
WELCOME_KEY,
WELCOME_TRIGGER,
} from '@/components/modals/welcome-modal-state';
import { UpgradeRequiredCard } from '@/components/v1/cloud/billing/upgrade-required';
import { useAnalytics } from '@/hooks/use-analytics';
import useControlPlane from '@/hooks/use-control-plane';
import { useOrganizationEntitlements } from '@/hooks/use-organization-entitlements';
import api, { Tenant } from '@/lib/api';
import { controlPlaneApi } from '@/lib/api/api';
import { OrganizationTenant } from '@/lib/api/generated/cloud/data-contracts';
import { useOrganizationApi } from '@/lib/api/organization-wrapper';
import { useApiError } from '@/lib/hooks';
import { useUserUniverse } from '@/providers/user-universe';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useEffect, useState } from 'react';
import invariant from 'tiny-invariant';

Expand All @@ -24,19 +27,25 @@ type NewTenantSaverFormProps = {
| { type: 'cloud'; tenant: OrganizationTenant; organizationId: string }
| { type: 'regular'; tenant: Tenant },
) => void;
// Called when the user leaves the form to upgrade (e.g. so a host modal can
// close before navigating to billing).
onUpgradeNavigate?: () => void;
};

const useSaveTenant = ({
afterSave,
onLimitReached,
}: {
afterSave: NewTenantSaverFormProps['afterSave'];
onLimitReached?: () => void;
}) => {
const { isCloudEnabled, invalidate: invalidateUserUniverse } =
useUserUniverse();
const { isControlPlaneEnabled } = useControlPlane();
const { capture } = useAnalytics();
const { handleApiError } = useApiError();
const orgApi = useOrganizationApi();
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({
Expand Down Expand Up @@ -77,21 +86,31 @@ const useSaveTenant = ({
await new Promise((resolve) => setTimeout(resolve, 0));
if (data.type === 'cloud') {
localStorage.setItem(WELCOME_KEY, WELCOME_TRIGGER.TenantCreated);
queryClient.invalidateQueries({
queryKey: ['organization:entitlements:get', data.organizationId],
});
}
capture('onboarding_tenant_created', {
tenant_type: data.type,
is_cloud: data.type === 'cloud',
});
afterSave(data);
},
onError: handleApiError,
onError: (error) => {
if (error instanceof AxiosError && error.response?.status === 403) {
onLimitReached?.();
return;
}
handleApiError(error as AxiosError);
},
});
};

export function NewTenantSaverForm({
defaultTenantName,
defaultOrganizationId,
afterSave,
onUpgradeNavigate,
}: NewTenantSaverFormProps) {
const {
isCloudEnabled,
Expand All @@ -102,11 +121,14 @@ export function NewTenantSaverForm({
const [selectedOrgId, setSelectedOrgId] = useState<string | undefined>(
defaultOrganizationId,
);
const [limitReached, setLimitReached] = useState(false);

useEffect(() => {
setSelectedOrgId(defaultOrganizationId);
}, [defaultOrganizationId]);

const { canCreateTenant } = useOrganizationEntitlements(selectedOrgId);

const shardsQuery = useQuery({
queryKey: ['organization:available-shards', selectedOrgId ?? ''] as const,
queryFn: async () =>
Expand All @@ -115,7 +137,10 @@ export function NewTenantSaverForm({
enabled: Boolean(isCloudEnabled && isControlPlaneEnabled && selectedOrgId),
});

const saveTenantMutation = useSaveTenant({ afterSave });
const saveTenantMutation = useSaveTenant({
afterSave,
onLimitReached: () => setLimitReached(true),
});

if (!isUserUniverseLoaded) {
return <></>;
Expand All @@ -134,6 +159,16 @@ export function NewTenantSaverForm({
);
}

if (selectedOrgId && (limitReached || !canCreateTenant)) {
return (
<UpgradeRequiredCard
resource="tenants"
organizationId={selectedOrgId}
onNavigate={onUpgradeNavigate}
/>
);
}

return (
<NewTenantInputForm
defaultTenantName={defaultTenantName}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UpgradeRequiredCard } from '@/components/v1/cloud/billing/upgrade-required';
import { Button } from '@/components/v1/ui/button';
import {
Dialog,
Expand All @@ -15,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/v1/ui/select';
import { useOrganizationEntitlements } from '@/hooks/use-organization-entitlements';
import { useOrganizations } from '@/hooks/use-organizations';
import { TenantMemberRole } from '@/lib/api';
import { TenantInvite } from '@/lib/api/generated/data-contracts';
Expand All @@ -23,6 +25,7 @@ import { useApiError } from '@/lib/hooks';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
Expand Down Expand Up @@ -140,26 +143,59 @@ export const CreateTenantInviteModal = ({
}) => {
const { getOrganizationIdForTenant, isCloudEnabled } = useOrganizations();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [limitReached, setLimitReached] = useState(false);
const { handleApiError } = useApiError({
setFieldErrors,
});

const queryClient = useQueryClient();
const organizationId = getOrganizationIdForTenant(tenantId);

const { canInviteUser } = useOrganizationEntitlements(organizationId);

const { tenantInviteCreateMutation } = useTenantApi();
const createMutation = useMutation({
...tenantInviteCreateMutation(tenantId),
onSuccess: (invite) => {
queryClient.invalidateQueries({
queryKey: ['tenant-invite:list', tenantId],
});
if (organizationId) {
queryClient.invalidateQueries({
queryKey: ['organization:entitlements:get', organizationId],
});
}
onCreated(invite);
onClose();
},
onError: handleApiError,
onError: (error) => {
if (error instanceof AxiosError && error.response?.status === 403) {
setLimitReached(true);
return;
}
handleApiError(error as AxiosError);
},
});

const showUpgrade = (limitReached || !canInviteUser) && !!organizationId;

if (showUpgrade && organizationId) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="w-fit min-w-[500px] max-w-[80%]">
<DialogHeader>
<DialogTitle>Invite new tenant member</DialogTitle>
</DialogHeader>
<UpgradeRequiredCard
resource="users"
organizationId={organizationId}
onNavigate={onClose}
/>
</DialogContent>
</Dialog>
);
}

return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<CreateTenantInviteForm
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UpgradeRequiredCard } from '@/components/v1/cloud/billing/upgrade-required';
import { Button } from '@/components/v1/ui/button';
import {
Dialog,
Expand All @@ -8,6 +9,7 @@ import {
} from '@/components/v1/ui/dialog';
import { Input } from '@/components/v1/ui/input';
import { Label } from '@/components/v1/ui/label';
import { useOrganizationEntitlements } from '@/hooks/use-organization-entitlements';
import {
CreateOrganizationInviteRequest,
OrganizationMemberRoleType,
Expand All @@ -17,6 +19,7 @@ import { useApiError } from '@/lib/hooks';
import { UserPlusIcon } from '@heroicons/react/24/outline';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
Expand All @@ -39,6 +42,9 @@ export const OrganizationInviteMemberModal = ({
onCreated,
}: OrganizationInviteMemberModalProps) => {
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [limitReached, setLimitReached] = useState(false);

const { canInviteUser } = useOrganizationEntitlements(organizationId);

const { handleApiError } = useApiError({
setFieldErrors,
Expand Down Expand Up @@ -74,11 +80,20 @@ export const OrganizationInviteMemberModal = ({
queryClient.invalidateQueries({
queryKey: ['organization-invites:list', organizationId],
});
queryClient.invalidateQueries({
queryKey: ['organization:entitlements:get', organizationId],
});
reset();
onCreated(request);
onClose();
},
onError: handleApiError,
onError: (error) => {
if (error instanceof AxiosError && error.response?.status === 403) {
setLimitReached(true);
return;
}
handleApiError(error as AxiosError);
},
});

const emailError = errors.email?.message?.toString() || fieldErrors?.email;
Expand All @@ -88,6 +103,31 @@ export const OrganizationInviteMemberModal = ({
setFieldErrors({});
}, [reset]);

const showUpgrade = limitReached || !canInviteUser;

if (showUpgrade) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlusIcon className="h-5 w-5" />
Invite Member
</DialogTitle>
<DialogDescription>
Invite a new member to {organizationName}
</DialogDescription>
</DialogHeader>
<UpgradeRequiredCard
resource="users"
organizationId={organizationId}
onNavigate={onClose}
/>
</DialogContent>
</Dialog>
);
}

return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/src/components/v1/cloud/billing/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './plan-selector';
export * from './subscription';
export * from './subscription-history';
export * from './upgrade-required';
export * from './usage-summary';
Loading
Loading