diff --git a/packages/web/package.json b/packages/web/package.json index a4f614f6..09ee7c7a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -44,6 +44,7 @@ "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index c70d4b55..8fad44bc 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; -import { ConnectionSyncStatus, Prisma, Invite } from "@sourcebot/db"; +import { ConnectionSyncStatus, Prisma, Invite, OrgRole } from "@sourcebot/db"; import { headers } from "next/headers" import { getStripe } from "@/lib/stripe" import { getUser } from "@/data/user"; @@ -58,6 +58,37 @@ export const withOrgMembership = async (session: Session, domain: string, fn: return fn(org.id); } +export const withOwner = async (session: Session, domain: string, fn: (orgId: number) => Promise) => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return notFound(); + } + + const userRole = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: session.user.id, + }, + }, + }); + + if (!userRole || userRole.role !== OrgRole.OWNER) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.MEMBER_NOT_OWNER, + message: "Only org owners can perform this action", + } satisfies ServiceError; + } + + return fn(org.id); +} + export const isAuthed = async () => { const session = await auth(); return session != null; @@ -282,9 +313,29 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr } })); -export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> => +export const getCurrentUserRole = async (domain: string): Promise => withAuth((session) => withOrgMembership(session, domain, async (orgId) => { + const userRole = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId, + userId: session.user.id, + }, + }, + }); + + if (!userRole) { + return notFound(); + } + + return userRole.role; + }) + ); + +export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOwner(session, domain, async (orgId) => { console.log("Creating invite for", email, userId, orgId); if (email === session.user.email) { @@ -377,6 +428,75 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su } }); +export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOwner(session, domain, async (orgId) => { + const currentUserId = session.user.id; + const currentUserRole = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: currentUserId, + orgId, + }, + }, + }); + + if (newOwnerId === currentUserId) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "You're already the owner of this org", + } satisfies ServiceError; + } + + const newOwner = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: newOwnerId, + orgId, + }, + }, + }); + + if (!newOwner) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "The user you're trying to make the owner doesn't exist", + } satisfies ServiceError; + } + + await prisma.$transaction([ + prisma.userToOrg.update({ + where: { + orgId_userId: { + userId: newOwnerId, + orgId, + }, + }, + data: { + role: "OWNER", + } + }), + prisma.userToOrg.update({ + where: { + orgId_userId: { + userId: currentUserId, + orgId, + }, + }, + data: { + role: "MEMBER", + } + }) + ]); + + return { + success: true, + } + }) + ); + const parseConnectionConfig = (connectionType: string, config: string) => { let parsedConfig: ConnectionConfig; try { @@ -530,7 +650,7 @@ export async function fetchStripeSession(sessionId: string) { export const getCustomerPortalSessionLink = async (domain: string): Promise => withAuth((session) => - withOrgMembership(session, domain, async (orgId) => { + withOwner(session, domain, async (orgId) => { const org = await prisma.org.findUnique({ where: { id: orgId, @@ -574,6 +694,69 @@ export const fetchSubscription = (domain: string): Promise => + withAuth(async (session) => + withOrgMembership(session, domain, async (orgId) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + const stripe = getStripe(); + const customer = await stripe.customers.retrieve(org.stripeCustomerId); + if (!('email' in customer) || customer.deleted) { + return notFound(); + } + return customer.email!; + }) + ); + +export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const userRole = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId, + userId: session.user.id, + } + } + }); + + if (!userRole || userRole.role !== "OWNER") { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.MEMBER_NOT_OWNER, + message: "Only org owners can change billing email", + } satisfies ServiceError; + } + + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + const stripe = getStripe(); + await stripe.customers.update(org.stripeCustomerId, { + email: newEmail, + }); + + return { + success: true, + } + }) + ); + export const checkIfUserHasOrg = async (userId: string): Promise => { const orgs = await prisma.userToOrg.findMany({ where: { diff --git a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx new file mode 100644 index 00000000..b644d896 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx @@ -0,0 +1,111 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { changeSubscriptionBillingEmail, getSubscriptionBillingEmail } from "@/actions" +import { isServiceError } from "@/lib/utils" +import { useDomain } from "@/hooks/useDomain" +import { OrgRole } from "@sourcebot/db" +import { useEffect, useState } from "react" +import { Mail } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { useToast } from "@/components/hooks/use-toast"; + +const formSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}) + +interface ChangeBillingEmailCardProps { + currentUserRole: OrgRole +} + +export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCardProps) { + const domain = useDomain() + const [billingEmail, setBillingEmail] = useState("") + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }) + + useEffect(() => { + const fetchBillingEmail = async () => { + const email = await getSubscriptionBillingEmail(domain) + if (!isServiceError(email)) { + setBillingEmail(email) + } + } + fetchBillingEmail() + }, [domain]) + + const onSubmit = async (values: z.infer) => { + setIsLoading(true) + const newEmail = values.email || billingEmail + const result = await changeSubscriptionBillingEmail(domain, newEmail) + if (!isServiceError(result)) { + setBillingEmail(newEmail) + form.reset({ email: "" }) + toast({ + description: "✅ Billing email updated successfully!", + }) + } else { + toast({ + description: "❌ Failed to update billing email. Please try again.", + }) + } + setIsLoading(false) + } + + return ( + + + + + Billing Email + + The email address for your billing account + + +
+ + ( + + Email address + + + + + + )} + /> + + + +
+
+ ) +} + diff --git a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx index dbb16bf6..47c263ba 100644 --- a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx +++ b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx @@ -6,8 +6,9 @@ import { isServiceError } from "@/lib/utils" import { Button } from "@/components/ui/button" import { getCustomerPortalSessionLink } from "@/actions" import { useDomain } from "@/hooks/useDomain"; +import { OrgRole } from "@sourcebot/db"; -export function ManageSubscriptionButton() { +export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) { const [isLoading, setIsLoading] = useState(false) const router = useRouter() const domain = useDomain(); @@ -28,9 +29,15 @@ export function ManageSubscriptionButton() { } } + const isOwner = currentUserRole === OrgRole.OWNER return ( - ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index bbcfd993..0cc24533 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -4,80 +4,98 @@ import { CalendarIcon, DollarSign, Users } from "lucide-react" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" import { ManageSubscriptionButton } from "./manageSubscriptionButton" -import { getSubscriptionData } from "@/actions" +import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" +import { ChangeBillingEmailCard } from "./changeBillingEmailCard" +import { CreditCard } from "lucide-react" + export const metadata: Metadata = { - title: "Billing | Settings", - description: "Manage your subscription and billing information", + title: "Billing | Settings", + description: "Manage your subscription and billing information", } interface BillingPageProps { - params: { - domain: string - } + params: { + domain: string + } } export default async function BillingPage({ - params: { domain }, + params: { domain }, }: BillingPageProps) { - const subscription = await getSubscriptionData(domain) + const subscription = await getSubscriptionData(domain) - if (isServiceError(subscription)) { - return
Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.
- } + if (isServiceError(subscription)) { + return
Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.
+ } - return ( -
-
-

Billing

-

Manage your subscription and billing information

-
- -
- - - Subscription Plan - - {subscription.status === "trialing" - ? "You are currently on a free trial" - : `You are currently on the ${subscription.plan} plan.`} - - - -
-
- -
-

Seats

-

{subscription.seats} active users

-
-
-
-
-
- -
-

{subscription.status === "trialing" ? "Trial End Date" : "Next Billing Date"}

-

{new Date(subscription.nextBillingDate * 1000).toLocaleDateString()}

-
-
+ const currentUserRole = await getCurrentUserRole(domain) + if (isServiceError(currentUserRole)) { + return
Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.
+ } + + const billingEmail = await getSubscriptionBillingEmail(domain); + if (isServiceError(billingEmail)) { + return
Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.
+ } + + return ( +
+
+

Billing

+

Manage your subscription and billing information

-
-
- -
-

Billing Amount

-

${(subscription.perSeatPrice * subscription.seats).toFixed(2)} per month

-
-
+ +
+ {/* Billing Email Card */} + + + + + + Subscription Plan + + + {subscription.status === "trialing" + ? "You are currently on a free trial" + : `You are currently on the ${subscription.plan} plan.`} + + + +
+
+ +
+

Seats

+

{subscription.seats} active users

+
+
+
+
+
+ +
+

{subscription.status === "trialing" ? "Trial End Date" : "Next Billing Date"}

+

{new Date(subscription.nextBillingDate * 1000).toLocaleDateString()}

+
+
+
+
+
+ +
+

Billing Amount

+

${(subscription.perSeatPrice * subscription.seats).toFixed(2)} per month

+
+
+
+
+ + + +
+
- - - - - -
-
- ) +
+ ) } - diff --git a/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx b/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx index 1814da35..c9433170 100644 --- a/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx +++ b/packages/web/src/app/[domain]/settings/components/memberInviteForm.tsx @@ -11,12 +11,13 @@ import { isServiceError } from "@/lib/utils"; import { useDomain } from "@/hooks/useDomain"; import { ErrorCode } from "@/lib/errorCodes"; import { useRouter } from "next/navigation"; +import { OrgRole } from "@sourcebot/db"; const formSchema = z.object({ email: z.string().min(2).max(40), }); -export const MemberInviteForm = ({ userId }: { userId: string }) => { +export const MemberInviteForm = ({ userId, currentUserRole }: { userId: string, currentUserRole: OrgRole }) => { const router = useRouter(); const { toast } = useToast(); const domain = useDomain(); @@ -44,25 +45,30 @@ export const MemberInviteForm = ({ userId }: { userId: string }) => { } } + const isOwner = currentUserRole === OrgRole.OWNER; return (

Invite a member

- ( - - Email - - - - - - )} - /> - +
+
+ ( + + Email + + + + + + )} + /> + +
+
diff --git a/packages/web/src/app/[domain]/settings/components/memberTable.tsx b/packages/web/src/app/[domain]/settings/components/memberTable.tsx index 9a21902d..d5a7f2ce 100644 --- a/packages/web/src/app/[domain]/settings/components/memberTable.tsx +++ b/packages/web/src/app/[domain]/settings/components/memberTable.tsx @@ -11,11 +11,12 @@ export interface MemberInfo { } interface MemberTableProps { + currentUserRole: string; currentUserId: string; initialMembers: MemberInfo[]; } -export const MemberTable = ({ currentUserId, initialMembers }: MemberTableProps) => { +export const MemberTable = ({ currentUserRole, currentUserId, initialMembers }: MemberTableProps) => { const memberRows: MemberColumnInfo[] = useMemo(() => { return initialMembers.map(member => { return { @@ -31,7 +32,7 @@ export const MemberTable = ({ currentUserId, initialMembers }: MemberTableProps)

Members

[] => { +export const MemberTableColumns = (currentUserRole: string, currentUserId: string): ColumnDef[] => { const { toast } = useToast(); const domain = useDomain(); const router = useRouter(); + + const isOwner = currentUserRole === "OWNER"; return [ { accessorKey: "name", cell: ({ row }) => { const member = row.original; - return
{member.name}
; + return
{member.name}
; } }, { accessorKey: "email", cell: ({ row }) => { const member = row.original; - return
{member.email}
; + return
{member.email}
; } }, { accessorKey: "role", cell: ({ row }) => { const member = row.original; - return
{member.role}
; + return
{member.role}
; + } + }, + { + id: "makeOwner", + cell: ({ row }) => { + const member = row.original; + if (!isOwner || member.id === currentUserId) return null; + + return ( + + + + + + + Make Owner + +
+
+

Are you sure you want to make this member the owner?

+
+

+ This action will make {member.email} the owner of your organization. +
+
+ You will be demoted to a regular member. +

+
+
+
+ + + + + + + + +
+
+ ); } }, { id: "remove", cell: ({ row }) => { const member = row.original; - if (member.id === currentUserId) { + if (!isOwner || member.id === currentUserId) { return null; } return ( diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 46d95df2..7e9d01cc 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,9 +1,7 @@ import { Metadata } from "next" - import { Separator } from "@/components/ui/separator" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" - export const metadata: Metadata = { title: "Settings", } diff --git a/packages/web/src/app/[domain]/settings/page.tsx b/packages/web/src/app/[domain]/settings/page.tsx index e06a0314..f1119a0c 100644 --- a/packages/web/src/app/[domain]/settings/page.tsx +++ b/packages/web/src/app/[domain]/settings/page.tsx @@ -5,6 +5,9 @@ import { MemberTable } from "./components/memberTable" import { MemberInviteForm } from "./components/memberInviteForm" import { InviteTable } from "./components/inviteTable" import { Separator } from "@/components/ui/separator" +import { getCurrentUserRole } from "@/actions" +import { isServiceError } from "@/lib/utils" +import { OrgRole } from "@sourcebot/db" interface SettingsPageProps { params: { @@ -73,11 +76,16 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP createdAt: invite.createdAt, })) + const currentUserRole = await getCurrentUserRole(domain) + if (isServiceError(currentUserRole)) { + return null + } + return { user, memberInfo, inviteInfo, - activeOrg, + userRole: currentUserRole, } } @@ -85,7 +93,7 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP if (!data) { return
Error: Unable to fetch data
} - const { user, memberInfo, inviteInfo } = data + const { user, memberInfo, inviteInfo, userRole } = data return (
@@ -95,8 +103,8 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP
- - + +
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 5ac33d6d..f8c92645 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -12,4 +12,5 @@ export enum ErrorCode { ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS', ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION', MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND', + MEMBER_NOT_OWNER = 'MEMBER_NOT_OWNER', } diff --git a/yarn.lock b/yarn.lock index a4ede7e3..ba2a74bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,13 @@ dependencies: "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-arrow@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab" + integrity sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-avatar@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz" @@ -1594,6 +1601,17 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-escape-keydown" "1.1.0" +"@radix-ui/react-dismissable-layer@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774" + integrity sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + "@radix-ui/react-dropdown-menu@^2.1.1": version "2.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz" @@ -1647,6 +1665,21 @@ "@radix-ui/react-primitive" "2.0.1" "@radix-ui/react-use-callback-ref" "1.1.0" +"@radix-ui/react-hover-card@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz#94fb87c047e1bb3bfd70439cf7ee48165ea4efa5" + integrity sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.5" + "@radix-ui/react-popper" "1.2.2" + "@radix-ui/react-portal" "1.1.4" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-icons@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz" @@ -1734,6 +1767,22 @@ "@radix-ui/react-use-size" "1.1.0" "@radix-ui/rect" "1.1.0" +"@radix-ui/react-popper@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.2.tgz#d2e1ee5a9b24419c5936a1b7f6f472b7b412b029" + integrity sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + "@radix-ui/react-portal@1.0.4": version "1.0.4" resolved "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz" @@ -1758,6 +1807,14 @@ "@radix-ui/react-primitive" "2.0.1" "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-portal@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8" + integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-presence@1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz" @@ -1805,6 +1862,13 @@ dependencies: "@radix-ui/react-slot" "1.1.1" +"@radix-ui/react-primitive@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef" + integrity sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w== + dependencies: + "@radix-ui/react-slot" "1.1.2" + "@radix-ui/react-roving-focus@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz" @@ -1879,6 +1943,13 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.1" +"@radix-ui/react-slot@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6" + integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-tabs@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz" @@ -6988,7 +7059,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==