mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
enforce owner perms (#191)
* add make owner logic, and owner perms for removal, invite, and manage subscription * add change billing email card to billing settings * enforce owner role in action level * remove unused hover card component * cleanup
This commit is contained in:
parent
26cc70cc11
commit
e2e5433d20
12 changed files with 574 additions and 99 deletions
|
|
@ -44,6 +44,7 @@
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@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-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection } from "./data/connection";
|
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 { headers } from "next/headers"
|
||||||
import { getStripe } from "@/lib/stripe"
|
import { getStripe } from "@/lib/stripe"
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
|
|
@ -58,6 +58,37 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
|
||||||
return fn(org.id);
|
return fn(org.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const withOwner = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
|
||||||
|
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 () => {
|
export const isAuthed = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
return session != null;
|
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<OrgRole | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async (orgId) => {
|
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);
|
console.log("Creating invite for", email, userId, orgId);
|
||||||
|
|
||||||
if (email === session.user.email) {
|
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) => {
|
const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
let parsedConfig: ConnectionConfig;
|
let parsedConfig: ConnectionConfig;
|
||||||
try {
|
try {
|
||||||
|
|
@ -530,7 +650,7 @@ export async function fetchStripeSession(sessionId: string) {
|
||||||
|
|
||||||
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async (orgId) => {
|
withOwner(session, domain, async (orgId) => {
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: orgId,
|
id: orgId,
|
||||||
|
|
@ -574,6 +694,69 @@ export const fetchSubscription = (domain: string): Promise<Stripe.Subscription |
|
||||||
return subscriptions.data[0];
|
return subscriptions.data[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
|
||||||
|
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<boolean | ServiceError> => {
|
export const checkIfUserHasOrg = async (userId: string): Promise<boolean | ServiceError> => {
|
||||||
const orgs = await prisma.userToOrg.findMany({
|
const orgs = await prisma.userToOrg.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
|
||||||
|
|
@ -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<string>("")
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
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<typeof formSchema>) => {
|
||||||
|
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 (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Billing Email
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>The email address for your billing account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={billingEmail}
|
||||||
|
{...field}
|
||||||
|
disabled={currentUserRole !== OrgRole.OWNER}
|
||||||
|
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
|
||||||
|
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||||
|
>
|
||||||
|
{isLoading ? "Updating..." : "Update Billing Email"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -6,8 +6,9 @@ import { isServiceError } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { getCustomerPortalSessionLink } from "@/actions"
|
import { getCustomerPortalSessionLink } from "@/actions"
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
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 [isLoading, setIsLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -28,9 +29,15 @@ export function ManageSubscriptionButton() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOwner = currentUserRole === OrgRole.OWNER
|
||||||
return (
|
return (
|
||||||
<Button className="w-full" onClick={redirectToCustomerPortal} disabled={isLoading}>
|
<Button
|
||||||
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
className="w-full"
|
||||||
|
onClick={redirectToCustomerPortal}
|
||||||
|
disabled={isLoading || !isOwner}
|
||||||
|
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -4,80 +4,98 @@ import { CalendarIcon, DollarSign, Users } from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { ManageSubscriptionButton } from "./manageSubscriptionButton"
|
import { ManageSubscriptionButton } from "./manageSubscriptionButton"
|
||||||
import { getSubscriptionData } from "@/actions"
|
import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions"
|
||||||
import { isServiceError } from "@/lib/utils"
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
|
||||||
|
import { CreditCard } from "lucide-react"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Billing | Settings",
|
title: "Billing | Settings",
|
||||||
description: "Manage your subscription and billing information",
|
description: "Manage your subscription and billing information",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BillingPageProps {
|
interface BillingPageProps {
|
||||||
params: {
|
params: {
|
||||||
domain: string
|
domain: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BillingPage({
|
export default async function BillingPage({
|
||||||
params: { domain },
|
params: { domain },
|
||||||
}: BillingPageProps) {
|
}: BillingPageProps) {
|
||||||
const subscription = await getSubscriptionData(domain)
|
const subscription = await getSubscriptionData(domain)
|
||||||
|
|
||||||
if (isServiceError(subscription)) {
|
if (isServiceError(subscription)) {
|
||||||
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const currentUserRole = await getCurrentUserRole(domain)
|
||||||
<div className="space-y-6">
|
if (isServiceError(currentUserRole)) {
|
||||||
<div>
|
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
}
|
||||||
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
|
|
||||||
</div>
|
const billingEmail = await getSubscriptionBillingEmail(domain);
|
||||||
<Separator />
|
if (isServiceError(billingEmail)) {
|
||||||
<div className="grid gap-6">
|
return <div>Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||||
<Card>
|
}
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Subscription Plan</CardTitle>
|
return (
|
||||||
<CardDescription>
|
<div className="space-y-6">
|
||||||
{subscription.status === "trialing"
|
<div>
|
||||||
? "You are currently on a free trial"
|
<h3 className="text-lg font-medium">Billing</h3>
|
||||||
: `You are currently on the ${subscription.plan} plan.`}
|
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Users className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">Seats</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{subscription.seats} active users</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<Separator />
|
||||||
<div className="flex items-center space-x-4">
|
<div className="grid gap-6">
|
||||||
<CalendarIcon className="h-5 w-5 text-muted-foreground" />
|
{/* Billing Email Card */}
|
||||||
<div className="space-y-1">
|
<ChangeBillingEmailCard currentUserRole={currentUserRole} />
|
||||||
<p className="text-sm font-medium leading-none">{subscription.status === "trialing" ? "Trial End Date" : "Next Billing Date"}</p>
|
<Card>
|
||||||
<p className="text-sm text-muted-foreground">{new Date(subscription.nextBillingDate * 1000).toLocaleDateString()}</p>
|
<CardHeader>
|
||||||
</div>
|
<CardTitle className="flex items-center gap-2">
|
||||||
</div>
|
<CreditCard className="h-5 w-5" />
|
||||||
|
Subscription Plan
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{subscription.status === "trialing"
|
||||||
|
? "You are currently on a free trial"
|
||||||
|
: `You are currently on the ${subscription.plan} plan.`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Users className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">Seats</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{subscription.seats} active users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<CalendarIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{subscription.status === "trialing" ? "Trial End Date" : "Next Billing Date"}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{new Date(subscription.nextBillingDate * 1000).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<DollarSign className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">Billing Amount</p>
|
||||||
|
<p className="text-sm text-muted-foreground">${(subscription.perSeatPrice * subscription.seats).toFixed(2)} per month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col space-y-2 w-full">
|
||||||
|
<ManageSubscriptionButton currentUserRole={currentUserRole} />
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
)
|
||||||
<DollarSign className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">Billing Amount</p>
|
|
||||||
<p className="text-sm text-muted-foreground">${(subscription.perSeatPrice * subscription.seats).toFixed(2)} per month</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col space-y-2 w-full">
|
|
||||||
<ManageSubscriptionButton />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().min(2).max(40),
|
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 router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -44,25 +45,30 @@ export const MemberInviteForm = ({ userId }: { userId: string }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOwner = currentUserRole === OrgRole.OWNER;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-lg font-normal">Invite a member</h4>
|
<h4 className="text-lg font-normal">Invite a member</h4>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleCreateInvite)}>
|
<form onSubmit={form.handleSubmit(handleCreateInvite)}>
|
||||||
<FormField
|
<div title={!isOwner ? "Only the owner of the org can invite new members" : undefined}>
|
||||||
control={form.control}
|
<div className={!isOwner ? "opacity-50 pointer-events-none" : ""}>
|
||||||
name="email"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="email"
|
||||||
<FormLabel>Email</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input {...field} />
|
<FormLabel>Email</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input {...field} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<Button className="mt-5" type="submit">Submit</Button>
|
)}
|
||||||
|
/>
|
||||||
|
<Button className="mt-5" type="submit">Submit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ export interface MemberInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberTableProps {
|
interface MemberTableProps {
|
||||||
|
currentUserRole: string;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
initialMembers: MemberInfo[];
|
initialMembers: MemberInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberTable = ({ currentUserId, initialMembers }: MemberTableProps) => {
|
export const MemberTable = ({ currentUserRole, currentUserId, initialMembers }: MemberTableProps) => {
|
||||||
const memberRows: MemberColumnInfo[] = useMemo(() => {
|
const memberRows: MemberColumnInfo[] = useMemo(() => {
|
||||||
return initialMembers.map(member => {
|
return initialMembers.map(member => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -31,7 +32,7 @@ export const MemberTable = ({ currentUserId, initialMembers }: MemberTableProps)
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-lg font-normal">Members</h4>
|
<h4 className="text-lg font-normal">Members</h4>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={MemberTableColumns(currentUserId)}
|
columns={MemberTableColumns(currentUserRole, currentUserId)}
|
||||||
data={memberRows}
|
data={memberRows}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchPlaceholder="Search members..."
|
searchPlaceholder="Search members..."
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { removeMember } from "@/actions"
|
import { removeMember, makeOwner } from "@/actions"
|
||||||
import { useToast } from "@/components/hooks/use-toast"
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
@ -24,37 +24,100 @@ export type MemberColumnInfo = {
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberTableColumns = (currentUserId: string): ColumnDef<MemberColumnInfo>[] => {
|
export const MemberTableColumns = (currentUserRole: string, currentUserId: string): ColumnDef<MemberColumnInfo>[] => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isOwner = currentUserRole === "OWNER";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const member = row.original;
|
const member = row.original;
|
||||||
return <div>{member.name}</div>;
|
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.name}</div>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const member = row.original;
|
const member = row.original;
|
||||||
return <div>{member.email}</div>;
|
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.email}</div>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "role",
|
accessorKey: "role",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const member = row.original;
|
const member = row.original;
|
||||||
return <div>{member.role}</div>;
|
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.role}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "makeOwner",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const member = row.original;
|
||||||
|
if (!isOwner || member.id === currentUserId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Make Owner
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg font-semibold">Make Owner</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="font-medium">Are you sure you want to make this member the owner?</p>
|
||||||
|
<div className="rounded-lg bg-muted p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This action will make <span className="font-semibold text-foreground">{member.email}</span> the owner of your organization.
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
You will be demoted to a regular member.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={async () => {
|
||||||
|
const response = await makeOwner(member.id, domain);
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to switch ownership. ${response.message}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Switched ownership successfully.`
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "remove",
|
id: "remove",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const member = row.original;
|
const member = row.original;
|
||||||
if (member.id === currentUserId) {
|
if (!isOwner || member.id === currentUserId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { SidebarNav } from "./components/sidebar-nav"
|
import { SidebarNav } from "./components/sidebar-nav"
|
||||||
import { NavigationMenu } from "../components/navigationMenu"
|
import { NavigationMenu } from "../components/navigationMenu"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { MemberTable } from "./components/memberTable"
|
||||||
import { MemberInviteForm } from "./components/memberInviteForm"
|
import { MemberInviteForm } from "./components/memberInviteForm"
|
||||||
import { InviteTable } from "./components/inviteTable"
|
import { InviteTable } from "./components/inviteTable"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { getCurrentUserRole } from "@/actions"
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
import { OrgRole } from "@sourcebot/db"
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -73,11 +76,16 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP
|
||||||
createdAt: invite.createdAt,
|
createdAt: invite.createdAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const currentUserRole = await getCurrentUserRole(domain)
|
||||||
|
if (isServiceError(currentUserRole)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
memberInfo,
|
memberInfo,
|
||||||
inviteInfo,
|
inviteInfo,
|
||||||
activeOrg,
|
userRole: currentUserRole,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +93,7 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <div>Error: Unable to fetch data</div>
|
return <div>Error: Unable to fetch data</div>
|
||||||
}
|
}
|
||||||
const { user, memberInfo, inviteInfo } = data
|
const { user, memberInfo, inviteInfo, userRole } = data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -95,8 +103,8 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MemberTable currentUserId={user.id} initialMembers={memberInfo} />
|
<MemberTable currentUserRole={userRole} currentUserId={user.id} initialMembers={memberInfo} />
|
||||||
<MemberInviteForm userId={user.id} />
|
<MemberInviteForm userId={user.id} currentUserRole={userRole} />
|
||||||
<InviteTable initialInvites={inviteInfo} />
|
<InviteTable initialInvites={inviteInfo} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ export enum ErrorCode {
|
||||||
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
|
ORG_DOMAIN_ALREADY_EXISTS = 'ORG_DOMAIN_ALREADY_EXISTS',
|
||||||
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
|
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
|
||||||
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
|
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
|
||||||
|
MEMBER_NOT_OWNER = 'MEMBER_NOT_OWNER',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
yarn.lock
80
yarn.lock
|
|
@ -1419,6 +1419,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-primitive" "2.0.0"
|
"@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":
|
"@radix-ui/react-avatar@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz"
|
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-callback-ref" "1.1.0"
|
||||||
"@radix-ui/react-use-escape-keydown" "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":
|
"@radix-ui/react-dropdown-menu@^2.1.1":
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz"
|
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-primitive" "2.0.1"
|
||||||
"@radix-ui/react-use-callback-ref" "1.1.0"
|
"@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":
|
"@radix-ui/react-icons@^1.3.0":
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz"
|
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/react-use-size" "1.1.0"
|
||||||
"@radix-ui/rect" "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":
|
"@radix-ui/react-portal@1.0.4":
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz"
|
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-primitive" "2.0.1"
|
||||||
"@radix-ui/react-use-layout-effect" "1.1.0"
|
"@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":
|
"@radix-ui/react-presence@1.0.1":
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz"
|
resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz"
|
||||||
|
|
@ -1805,6 +1862,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-slot" "1.1.1"
|
"@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":
|
"@radix-ui/react-roving-focus@1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz"
|
||||||
|
|
@ -1879,6 +1943,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@radix-ui/react-compose-refs" "1.1.1"
|
"@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":
|
"@radix-ui/react-tabs@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz"
|
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-html4 "^2.0.0"
|
||||||
character-entities-legacy "^3.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"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue