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:
Michael Sukkarieh 2025-02-14 09:25:22 -08:00 committed by GitHub
parent 26cc70cc11
commit e2e5433d20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 574 additions and 99 deletions

View file

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

View file

@ -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 <T>(session: Session, domain: string, fn:
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 () => {
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<OrgRole | ServiceError> =>
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<string | ServiceError> =>
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<Stripe.Subscription |
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> => {
const orgs = await prisma.userToOrg.findMany({
where: {

View file

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

View file

@ -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 (
<Button className="w-full" onClick={redirectToCustomerPortal} disabled={isLoading}>
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
<Button
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>
)
}

View file

@ -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 <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
if (isServiceError(subscription)) {
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Billing</h3>
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
</div>
<Separator />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>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>
const currentUserRole = await getCurrentUserRole(domain)
if (isServiceError(currentUserRole)) {
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
const billingEmail = await getSubscriptionBillingEmail(domain);
if (isServiceError(billingEmail)) {
return <div>Failed to fetch billing email. Please contact us at team@sourcebot.dev if this issue persists.</div>
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Billing</h3>
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
</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>
<Separator />
<div className="grid gap-6">
{/* Billing Email Card */}
<ChangeBillingEmailCard currentUserRole={currentUserRole} />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<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 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 />
</CardFooter>
</Card>
</div>
</div>
)
</div>
)
}

View file

@ -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 (
<div className="space-y-2">
<h4 className="text-lg font-normal">Invite a member</h4>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleCreateInvite)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-5" type="submit">Submit</Button>
<div title={!isOwner ? "Only the owner of the org can invite new members" : undefined}>
<div className={!isOwner ? "opacity-50 pointer-events-none" : ""}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-5" type="submit">Submit</Button>
</div>
</div>
</form>
</Form>
</div>

View file

@ -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)
<div className="space-y-2">
<h4 className="text-lg font-normal">Members</h4>
<DataTable
columns={MemberTableColumns(currentUserId)}
columns={MemberTableColumns(currentUserRole, currentUserId)}
data={memberRows}
searchKey="name"
searchPlaceholder="Search members..."

View file

@ -11,7 +11,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { removeMember } from "@/actions"
import { removeMember, makeOwner } from "@/actions"
import { useToast } from "@/components/hooks/use-toast"
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
@ -24,37 +24,100 @@ export type MemberColumnInfo = {
role: string;
}
export const MemberTableColumns = (currentUserId: string): ColumnDef<MemberColumnInfo>[] => {
export const MemberTableColumns = (currentUserRole: string, currentUserId: string): ColumnDef<MemberColumnInfo>[] => {
const { toast } = useToast();
const domain = useDomain();
const router = useRouter();
const isOwner = currentUserRole === "OWNER";
return [
{
accessorKey: "name",
cell: ({ row }) => {
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",
cell: ({ row }) => {
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",
cell: ({ row }) => {
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",
cell: ({ row }) => {
const member = row.original;
if (member.id === currentUserId) {
if (!isOwner || member.id === currentUserId) {
return null;
}
return (

View file

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

View file

@ -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 <div>Error: Unable to fetch data</div>
}
const { user, memberInfo, inviteInfo } = data
const { user, memberInfo, inviteInfo, userRole } = data
return (
<div className="space-y-6">
@ -95,8 +103,8 @@ export default async function SettingsPage({ params: { domain } }: SettingsPageP
</div>
<Separator />
<div className="space-y-6">
<MemberTable currentUserId={user.id} initialMembers={memberInfo} />
<MemberInviteForm userId={user.id} />
<MemberTable currentUserRole={userRole} currentUserId={user.id} initialMembers={memberInfo} />
<MemberInviteForm userId={user.id} currentUserRole={userRole} />
<InviteTable initialInvites={inviteInfo} />
</div>
</div>

View file

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

View file

@ -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==