feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
@@ -0,0 +1,36 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { BillingTabGroup } from "./components";
export const BillingPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: t("billing.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl px-6">
<div className="my-6">
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
<div />
</div>
<OrgPermissionCan
passThrough={false}
I={OrgPermissionActions.Read}
a={OrgPermissionSubjects.Billing}
>
<BillingTabGroup />
</OrgPermissionCan>
</div>
</div>
</div>
);
};
@@ -0,0 +1,11 @@
import { CurrentPlanSection } from "./CurrentPlanSection";
import { PreviewSection } from "./PreviewSection";
export const BillingCloudTab = () => {
return (
<div>
<PreviewSection />
<CurrentPlanSection />
</div>
);
};
@@ -0,0 +1,72 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgPlanTable } from "@app/hooks/api";
export const CurrentPlanSection = () => {
const { currentOrg } = useOrganization();
const { data, isPending } = useGetOrgPlanTable(currentOrg?.id ?? "");
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-";
if (typeof value === "boolean") {
if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
}
return value;
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Current usage</h2>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">Feature</Th>
<Th className="w-1/3">Allowed</Th>
<Th className="w-1/3">Used</Th>
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.rows?.length > 0 &&
data.rows.map(({ name, allowed, used }) => {
return (
<Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{name}</Td>
<Td>{displayCell(allowed)}</Td>
<Td>{used}</Td>
</Tr>
);
})}
{isPending && <TableSkeleton columns={5} innerKey="invoices" />}
{!isPending && data && data?.rows?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No plan details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1,62 @@
import { Fragment } from "react";
import { Tab } from "@headlessui/react";
import { Modal, ModalContent } from "@app/components/v2";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { ManagePlansTable } from "./ManagePlansTable";
type Props = {
popUp: UsePopUpState<["managePlan"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["managePlan"]>, state?: boolean) => void;
};
export const ManagePlansModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.managePlan?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("managePlan", isOpen);
}}
>
<ModalContent className="max-w-screen-lg" title="Infisical Cloud Plans">
<Tab.Group>
<Tab.List className="max-w-screen-lg border-b-2 border-mineshaft-600">
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${
selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"
} w-30 font-semibold outline-none`}
>
Bill monthly
</button>
)}
</Tab>
<Tab as={Fragment}>
{({ selected }) => (
<button
type="button"
className={`p-4 ${
selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"
} w-30 font-semibold outline-none`}
>
Bill yearly
</button>
)}
</Tab>
</Tab.List>
<Tab.Panels className="mt-4">
<Tab.Panel>
<ManagePlansTable billingCycle="monthly" />
</Tab.Panel>
<Tab.Panel>
<ManagePlansTable billingCycle="yearly" />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,138 @@
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { useCreateCustomerPortalSession, useGetOrgPlansTable } from "@app/hooks/api";
type Props = {
billingCycle: "monthly" | "yearly";
};
export const ManagePlansTable = ({ billingCycle }: Props) => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data: tableData, isPending: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?.id ?? "",
billingCycle
});
const createCustomerPortalSession = useCreateCustomerPortalSession();
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "Unlimited";
if (typeof value === "boolean") {
if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
}
return value;
};
return (
<TableContainer>
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({ name, priceLine }) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="flex-1 text-center"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription &&
!isTableDataLoading &&
tableData &&
tableData.rows.map(({ name, starter, pro, enterprise }) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">{displayCell(starter)}</Td>
<Td className="text-center">{displayCell(pro)}</Td>
<Td className="text-center">{displayCell(enterprise)}</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} innerKey="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No cloud product details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({ slug, tier }) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) {
subscriptionText = "Downgrade";
}
if (tier === 3) {
subscriptionText = "Contact sales";
}
return isCurrentPlan ? (
<Td>
<Button colorSchema="secondary" className="w-full" isDisabled>
Current
</Button>
</Td>
) : (
<Td>
<Button
onClick={async () => {
if (!currentOrg?.id) return;
if (tier !== 3) {
const { url } = await createCustomerPortalSession.mutateAsync(
currentOrg.id
);
window.location.href = url;
return;
}
window.location.href = "https://infisical.com/scheduledemo";
}}
color="mineshaft"
className="w-full"
>
{subscriptionText}
</Button>
</Td>
);
})}
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1,172 @@
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import {
useCreateCustomerPortalSession,
useGetOrgPlanBillingInfo,
useGetOrgTrialUrl
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ManagePlansModal } from "./ManagePlansModal";
export const PreviewSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isPending } = useGetOrgPlanBillingInfo(currentOrg?.id ?? "");
const getOrgTrialUrl = useGetOrgTrialUrl();
const createCustomerPortalSession = useCreateCustomerPortalSession();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["managePlan"] as const);
const formatAmount = (amount: number) => {
const formattedTotal = (Math.floor(amount) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
return formattedTotal;
};
const formatDate = (date: number) => {
const createdDate = new Date(date * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return formattedDate;
};
function formatPlanSlug(slug: string) {
return slug.replace(/(\b[a-z])/g, (match) => match.toUpperCase()).replace(/-/g, " ");
}
const handleUpgradeBtnClick = async () => {
try {
if (!subscription || !currentOrg) return;
if (!subscription.has_used_trial) {
// direct user to start pro trial
const url = await getOrgTrialUrl.mutateAsync({
orgId: currentOrg.id,
success_url: window.location.href
});
window.location.href = url;
} else {
// open compare plans modal
handlePopUpOpen("managePlan");
}
} catch (err) {
console.error(err);
}
};
return (
<div>
{subscription &&
subscription?.slug !== "enterprise" &&
subscription?.slug !== "pro" &&
subscription?.slug !== "pro-annual" && (
<div className="flex flex-row space-x-6">
<div className="mb-6 flex flex-1 items-center rounded-lg border border-primary/40 bg-primary/10 p-4">
<div className="flex-1">
<h2 className="text-xl font-medium text-mineshaft-100">
Unleash the full power of{" "}
<span className="bg-gradient-to-r from-primary-500 to-yellow bg-clip-text font-semibold text-transparent">
Infisical
</span>
</h2>
<p className="mt-4 text-gray-400">
Get unlimited members, projects, RBAC, smart alerts, and so much more.
</p>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={() => handleUpgradeBtnClick()}
color="mineshaft"
isDisabled={!isAllowed}
>
{!subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
</Button>
)}
</OrgPermissionCan>
</div>
<div className="mb-6 flex w-full max-w-[12rem] flex-col items-center rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="mb-4 flex w-full justify-center font-semibold text-mineshaft-200">
Want to learn more?{" "}
</div>
<div className="flex w-full justify-center">
<a
href="https://infisical.com/schedule-demo"
target="_blank"
rel="noopener noreferrer"
>
<span className="cursor-pointer rounded-full border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 duration-200 hover:border-primary/40 hover:bg-primary/10">
Book a demo{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</div>
</div>
</div>
)}
{!isPending && subscription && data && (
<div className="mb-6 flex">
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Current plan</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{`${formatPlanSlug(subscription.slug)} ${
subscription.status === "trialing" ? "(Trial)" : ""
}`}
</p>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<button
type="button"
onClick={async () => {
if (!currentOrg?.id) return;
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg.id);
window.location.href = url;
}}
disabled={!isAllowed}
className="text-primary"
>
Manage plan &rarr;
</button>
)}
</OrgPermissionCan>
</div>
<div className="mr-4 flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Price</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{subscription.status === "trialing"
? "$0.00 / month"
: `${formatAmount(data.amount)} / ${data.interval}`}
</p>
</div>
<div className="flex-1 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-2 text-gray-400">Subscription renews on</p>
<p className="mb-8 text-2xl font-semibold text-mineshaft-50">
{formatDate(data.currentPeriodEnd)}
</p>
</div>
</div>
)}
<ManagePlansModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</div>
);
};
@@ -0,0 +1 @@
export { BillingCloudTab } from "./BillingCloudTab";
@@ -0,0 +1,15 @@
import { CompanyNameSection } from "./CompanyNameSection";
import { InvoiceEmailSection } from "./InvoiceEmailSection";
import { PmtMethodsSection } from "./PmtMethodsSection";
import { TaxIDSection } from "./TaxIDSection";
export const BillingDetailsTab = () => {
return (
<>
<CompanyNameSection />
<InvoiceEmailSection />
<PmtMethodsSection />
<TaxIDSection />
</>
);
};
@@ -0,0 +1,91 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
.object({
name: z.string()
})
.required();
export const CompanyNameSection = () => {
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
name: ""
},
resolver: zodResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?.id ?? "");
const { mutateAsync, isPending } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
name: data?.name ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ name }: { name: string }) => {
try {
if (!currentOrg?.id) return;
if (name === "") return;
await mutateAsync({
name,
organizationId: currentOrg.id
});
createNotification({
text: "Successfully updated business name",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update business name",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Business name</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Acme Corp" {...field} className="bg-mineshaft-800" />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
type="submit"
colorSchema="secondary"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};
@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api";
const schema = z
.object({
email: z.string()
})
.required();
export const InvoiceEmailSection = () => {
const { currentOrg } = useOrganization();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
email: ""
},
resolver: zodResolver(schema)
});
const { data } = useGetOrgBillingDetails(currentOrg?.id ?? "");
const { mutateAsync, isPending } = useUpdateOrgBillingDetails();
useEffect(() => {
if (data) {
reset({
email: data?.email ?? ""
});
}
}, [data]);
const onFormSubmit = async ({ email }: { email: string }) => {
try {
if (!currentOrg?.id) return;
if (email === "") return;
await mutateAsync({
email,
organizationId: currentOrg.id
});
createNotification({
text: "Successfully updated invoice email recipient",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update invoice email recipient",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Invoice email recipient</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="[email protected]" {...field} className="bg-mineshaft-800" />
</FormControl>
)}
control={control}
name="email"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
type="submit"
colorSchema="secondary"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};
@@ -0,0 +1,47 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useAddOrgPmtMethod } from "@app/hooks/api";
import { PmtMethodsTable } from "./PmtMethodsTable";
export const PmtMethodsSection = () => {
const { currentOrg } = useOrganization();
const { mutateAsync, isPending } = useAddOrgPmtMethod();
const handleAddPmtMethodBtnClick = async () => {
if (!currentOrg?.id) return;
const url = await mutateAsync({
organizationId: currentOrg.id,
success_url: window.location.href,
cancel_url: window.location.href
});
window.location.href = url;
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">Payment methods</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={handleAddPmtMethodBtnClick}
colorSchema="secondary"
isLoading={isPending}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Add method
</Button>
)}
</OrgPermissionCan>
</div>
<PmtMethodsTable />
</div>
);
};
@@ -0,0 +1,117 @@
import { faCreditCard, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
export const PmtMethodsTable = () => {
const { currentOrg } = useOrganization();
const { data, isPending } = useGetOrgPmtMethods(currentOrg?.id ?? "");
const deleteOrgPmtMethod = useDeleteOrgPmtMethod();
const { handlePopUpOpen, handlePopUpClose, handlePopUpToggle, popUp } = usePopUp([
"removeCard"
] as const);
const pmtMethodToRemove = popUp.removeCard.data as { id: string; last4: string } | undefined;
const handleDeletePmtMethodBtnClick = async () => {
if (!currentOrg?.id || !pmtMethodToRemove) return;
try {
await deleteOrgPmtMethod.mutateAsync({
organizationId: currentOrg.id,
pmtMethodId: pmtMethodToRemove.id
});
createNotification({
type: "success",
text: "Successfully removed payment method"
});
handlePopUpClose("removeCard");
} catch (error: any) {
createNotification({
type: "error",
text: error.message ?? "Error removing payment method"
});
}
};
return (
<>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Brand</Th>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Last 4 Digits</Th>
<Th className="flex-1">Expiration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.length > 0 &&
data.map(({ _id: id, brand, exp_month, exp_year, funding, last4 }) => (
<Tr key={`pmt-method-${id}`} className="h-10">
<Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td>
<Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td>
<Td>{last4}</Td>
<Td>{`${exp_month}/${exp_year}`}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<IconButton
onClick={() => handlePopUpOpen("removeCard", { id, last4 })}
size="lg"
isDisabled={!isAllowed}
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
</Tr>
))}
{isPending && <TableSkeleton columns={5} innerKey="pmt-methods" />}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No payment methods on file" icon={faCreditCard} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.removeCard.isOpen}
deleteKey="confirm"
onChange={(isOpen) => handlePopUpToggle("removeCard", isOpen)}
title={`Remove payment method ending in *${pmtMethodToRemove?.last4}?`}
onDeleteApproved={handleDeletePmtMethodBtnClick}
/>
</>
);
};
@@ -0,0 +1,182 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddOrgTaxId } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const taxIDTypes = [
{ label: "Australia ABN", value: "au_abn" },
{ label: "Australia ARN", value: "au_arn" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Brazil CNPJ", value: "br_cnpj" },
{ label: "Brazil CPF", value: "br_cpf" },
{ label: "Canada BN", value: "ca_bn" },
{ label: "Canada GST/HST", value: "ca_gst_hst" },
{ label: "Canada PST BC", value: "ca_pst_bc" },
{ label: "Canada PST MB", value: "ca_pst_mb" },
{ label: "Canada PST SK", value: "ca_pst_sk" },
{ label: "Canada QST", value: "ca_qst" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Chile TIN", value: "cl_tin" },
{ label: "Egypt TIN", value: "eg_tin" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "EU OSS VAT", value: "eu_oss_vat" },
{ label: "EU VAT", value: "eu_vat" },
{ label: "GB VAT", value: "gb_vat" },
{ label: "Georgia VAT", value: "ge_vat" },
{ label: "Hong Kong BR", value: "hk_br" },
{ label: "Hungary TIN", value: "hu_tin" },
{ label: "Indonesia NPWP", value: "id_npwp" },
{ label: "Israel VAT", value: "il_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "Japan CN", value: "jp_cn" },
{ label: "Japan RN", value: "jp_rn" },
{ label: "Japan TRN", value: "jp_trn" },
{ label: "Kenya PIN", value: "ke_pin" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Liechtenstein UID", value: "li_uid" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "Malaysia FRP", value: "my_frp" },
{ label: "Malaysia ITN", value: "my_itn" },
{ label: "Malaysia SST", value: "my_sst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "New Zealand GST", value: "nz_gst" },
{ label: "Philippines TIN", value: "ph_tin" },
{ label: "Russia INN", value: "ru_inn" },
{ label: "Russia KPP", value: "ru_kpp" },
{ label: "Saudi Arabia VAT", value: "sa_vat" },
{ label: "Singapore GST", value: "sg_gst" },
{ label: "Singapore UEN", value: "sg_uen" },
{ label: "Slovenia TIN", value: "si_tin" },
{ label: "Thailand VAT", value: "th_vat" },
{ label: "Turkey TIN", value: "tr_tin" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Ukraine VAT", value: "ua_vat" },
{ label: "US EIN", value: "us_ein" },
{ label: "South Africa VAT", value: "za_vat" }
];
const schema = z
.object({
type: z.string(),
value: z.string()
})
.required();
export type AddTaxIDFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addTaxID"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addTaxID"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addTaxID"]>, state?: boolean) => void;
};
export const TaxIDModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const addOrgTaxId = useAddOrgTaxId();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<AddTaxIDFormData>({
resolver: zodResolver(schema)
});
const onTaxIDModalSubmit = async ({ type, value }: AddTaxIDFormData) => {
try {
if (!currentOrg?.id) return;
await addOrgTaxId.mutateAsync({
organizationId: currentOrg.id,
type,
value
});
createNotification({
text: "Successfully added Tax ID",
type: "success"
});
handlePopUpClose("addTaxID");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to add Tax ID",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addTaxID?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addTaxID", isOpen);
reset();
}}
>
<ModalContent title="Add Tax ID">
<form onSubmit={handleSubmit(onTaxIDModalSubmit)}>
<Controller
control={control}
name="type"
defaultValue="eu_vat"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{taxIDTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="value"
render={({ field, fieldState: { error } }) => (
<FormControl label="Value" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="DE000000000" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,42 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
import { TaxIDTable } from "./TaxIDTable";
export const TaxIDSection = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addTaxID"
] as const);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">Tax ID</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Billing}>
{(isAllowed) => (
<Button
onClick={() => handlePopUpOpen("addTaxID")}
colorSchema="secondary"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add method
</Button>
)}
</OrgPermissionCan>
</div>
<TaxIDTable />
<TaxIDModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
};
@@ -0,0 +1,139 @@
import { faFileInvoice, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = {
au_abn: "Australia ABN",
au_arn: "Australia ARN",
bg_uic: "Bulgaria UIC",
br_cnpj: "Brazil CNPJ",
br_cpf: "Brazil CPF",
ca_bn: "Canada BN",
ca_gst_hst: "Canada GST/HST",
ca_pst_bc: "Canada PST BC",
ca_pst_mb: "Canada PST MB",
ca_pst_sk: "Canada PST SK",
ca_qst: "Canada QST",
ch_vat: "Switzerland VAT",
cl_tin: "Chile TIN",
eg_tin: "Egypt TIN",
es_cif: "Spain CIF",
eu_oss_vat: "EU OSS VAT",
eu_vat: "EU VAT",
gb_vat: "GB VAT",
ge_vat: "Georgia VAT",
hk_br: "Hong Kong BR",
hu_tin: "Hungary TIN",
id_npwp: "Indonesia NPWP",
il_vat: "Israel VAT",
in_gst: "India GST",
is_vat: "Iceland VAT",
jp_cn: "Japan CN",
jp_rn: "Japan RN",
jp_trn: "Japan TRN",
ke_pin: "Kenya PIN",
kr_brn: "South Korea BRN",
li_uid: "Liechtenstein UID",
mx_rfc: "Mexico RFC",
my_frp: "Malaysia FRP",
my_itn: "Malaysia ITN",
my_sst: "Malaysia SST",
no_vat: "Norway VAT",
nz_gst: "New Zealand GST",
ph_tin: "Philippines TIN",
ru_inn: "Russia INN",
ru_kpp: "Russia KPP",
sa_vat: "Saudi Arabia VAT",
sg_gst: "Singapore GST",
sg_uen: "Singapore UEN",
si_tin: "Slovenia TIN",
th_vat: "Thailand VAT",
tr_tin: "Turkey TIN",
tw_vat: "Taiwan VAT",
ua_vat: "Ukraine VAT",
us_ein: "US EIN",
za_vat: "South Africa VAT"
};
export const TaxIDTable = () => {
const { currentOrg } = useOrganization();
const { data, isPending } = useGetOrgTaxIds(currentOrg?.id ?? "");
const deleteOrgTaxId = useDeleteOrgTaxId();
const handleDeleteTaxIdBtnClick = async (taxId: string) => {
if (!currentOrg?.id) return;
await deleteOrgTaxId.mutateAsync({
organizationId: currentOrg.id,
taxId
});
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="flex-1">Type</Th>
<Th className="flex-1">Value</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.length > 0 &&
data.map(({ id, type, value }) => (
<Tr key={`tax-id-${id}`} className="h-10">
<Td>{taxIDTypeLabelMap[type]}</Td>
<Td>{value}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Billing}
>
{(isAllowed) => (
<IconButton
onClick={async () => {
await handleDeleteTaxIdBtnClick(id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
</Tr>
))}
{isPending && <TableSkeleton columns={3} innerKey="tax-ids" />}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No Tax IDs on file" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1 @@
export { BillingDetailsTab } from "./BillingDetailsTab";
@@ -0,0 +1,10 @@
import { InvoicesTable } from "./InvoicesTable";
export const BillingReceiptsTab = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="flex-1 text-xl font-semibold text-white">Invoices</h2>
<InvoicesTable />
</div>
);
};
@@ -0,0 +1,80 @@
import { faDownload, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgInvoices } from "@app/hooks/api";
export const InvoicesTable = () => {
const { currentOrg } = useOrganization();
const { data, isPending } = useGetOrgInvoices(currentOrg?.id ?? "");
return (
<TableContainer className="mt-8">
<Table>
<THead>
<Tr>
<Th className="flex-1">Invoice #</Th>
<Th className="flex-1">Date</Th>
<Th className="flex-1">Status</Th>
<Th className="flex-1">Amount</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.length > 0 &&
data.map(({ id, created, paid, number, total, invoice_pdf }) => {
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
const createdDate = new Date(created * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return (
<Tr key={`invoice-${id}`} className="h-10">
<Td>{number}</Td>
<Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td>
<Td>
<IconButton
onClick={async () => window.open(invoice_pdf)}
size="lg"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Td>
</Tr>
);
})}
{isPending && <TableSkeleton columns={5} innerKey="invoices" />}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No invoices on file" icon={faFileInvoice} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1 @@
export { BillingReceiptsTab } from "./BillingReceiptsTab";
@@ -0,0 +1,9 @@
import { LicensesSection } from "./LicensesSection";
export const BillingSelfHostedTab = () => {
return (
<div>
<LicensesSection />
</div>
);
};
@@ -0,0 +1,63 @@
import { faFileContract } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetOrgLicenses } from "@app/hooks/api";
export const LicensesSection = () => {
const { currentOrg } = useOrganization();
const { data, isPending } = useGetOrgLicenses(currentOrg?.id ?? "");
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="mb-8 flex-1 text-xl font-semibold text-white">Enterprise licenses</h2>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>License Key</Th>
<Th>Status</Th>
<Th>Issued Date</Th>
<Th>Expiry Date</Th>
</Tr>
</THead>
<TBody>
{!isPending &&
data &&
data?.length > 0 &&
data.map(({ id, licenseKey, isActivated, createdAt, expiresAt }) => {
const formattedCreatedAt = new Date(createdAt).toISOString().split("T")[0];
const formattedExpiresAt = new Date(expiresAt).toISOString().split("T")[0];
return (
<Tr key={`license-${id}`} className="h-10">
<Td>{licenseKey}</Td>
<Td>{isActivated ? "Active" : "Inactive"}</Td>
<Td>{formattedCreatedAt}</Td>
<Td>{formattedExpiresAt}</Td>
</Tr>
);
})}
{isPending && <TableSkeleton columns={4} innerKey="licenses" />}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No enterprise licenses on file" icon={faFileContract} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export * from "./BillingSelfHostedTab";
@@ -0,0 +1,42 @@
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { BillingCloudTab } from "../BillingCloudTab";
import { BillingDetailsTab } from "../BillingDetailsTab";
import { BillingReceiptsTab } from "../BillingReceiptsTab";
import { BillingSelfHostedTab } from "../BillingSelfHostedTab";
const tabs = [
{ name: "Infisical Cloud", key: "tab-infisical-cloud" },
{ name: "Infisical Self-Hosted", key: "tab-infisical-self-hosted" },
{ name: "Receipts", key: "tab-receipts" },
{ name: "Billing details", key: "tab-billing-details" }
];
export const BillingTabGroup = withPermission(
() => {
return (
<Tabs defaultValue={tabs[0].key}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key}>{tab.name}</Tab>
))}
</TabList>
<TabPanel value={tabs[0].key}>
<BillingCloudTab />
</TabPanel>
<TabPanel value={tabs[1].key}>
<BillingSelfHostedTab />
</TabPanel>
<TabPanel value={tabs[2].key}>
<BillingReceiptsTab />
</TabPanel>
<TabPanel value={tabs[3].key}>
<BillingDetailsTab />
</TabPanel>
</Tabs>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing }
);
@@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";
@@ -0,0 +1 @@
export { BillingTabGroup } from "./BillingTabGroup";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { BillingPage } from "./BillingPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/organization/_layout/billing"
)({
component: BillingPage
});