From 193e6a0b5d5198b4def7b614f9354ec3ae1ea0b8 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 13 Feb 2025 17:40:26 -0800 Subject: [PATCH 1/5] add make owner logic, and owner perms for removal, invite, and manage subscription --- packages/web/package.json | 1 + packages/web/src/actions.ts | 99 ++++++++++++- .../components/payWall/paywallCard.tsx | 3 + .../billing/manageSubscriptionButton.tsx | 13 +- .../app/[domain]/settings/billing/page.tsx | 131 +++++++++--------- .../settings/components/memberInviteForm.tsx | 36 +++-- .../settings/components/memberTable.tsx | 5 +- .../components/memberTableColumns.tsx | 75 +++++++++- .../web/src/app/[domain]/settings/layout.tsx | 7 +- .../web/src/app/[domain]/settings/page.tsx | 16 ++- packages/web/src/components/ui/hover-card.tsx | 29 ++++ 11 files changed, 318 insertions(+), 97 deletions(-) create mode 100644 packages/web/src/components/ui/hover-card.tsx diff --git a/packages/web/package.json b/packages/web/package.json index a4f614f67..09ee7c7a7 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 c70d4b556..7eb95f084 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"; @@ -282,6 +282,26 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr } })); +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) => withOrgMembership(session, domain, async (orgId) => { @@ -377,6 +397,83 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su } }); +export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => + withAuth((session) => + withOrgMembership(session, domain, async (orgId) => { + const currentUserId = session.user.id; + const currentUserRole = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + userId: currentUserId, + orgId, + }, + }, + }); + + if (!currentUserRole || currentUserRole.role !== "OWNER") { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "You are not the owner of this org", + } satisfies ServiceError; + } + + 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 { diff --git a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx index 3e1f31dfc..5e2e0cb3f 100644 --- a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx +++ b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx @@ -5,6 +5,9 @@ import { CheckoutButton } from "./checkoutButton" import Image from "next/image"; import logoDark from "@/public/sb_logo_dark_large.png"; import logoLight from "@/public/sb_logo_light_large.png"; +import { getCurrentUserRole } from "@/actions" +import { isServiceError } from "@/lib/utils" +import { OrgRole } from "@sourcebot/db" const teamFeatures = [ "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported", diff --git a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx index dbb16bf63..47c263ba3 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 bbcfd9938..028c41dfb 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -4,80 +4,85 @@ 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 } from "@/actions" import { isServiceError } from "@/lib/utils" + 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.
+ } + + return ( +
+
+

Billing

+

Manage your subscription and billing information

-
-
- -
-

Billing Amount

-

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

-
-
+ +
+ + + 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 1814da351..c94331708 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 9a21902d1..d5a7f2ced 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: "remove", cell: ({ row }) => { const member = row.original; - if (member.id === currentUserId) { + if (!isOwner || member.id === currentUserId) { return null; } return ( @@ -131,6 +133,67 @@ export const MemberTableColumns = (currentUserId: string): ColumnDef ); } + }, + { + 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. +

+
+
+
+ + + + + + + + +
+
+ ); + } } ] } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 46d95df2b..db5e821c0 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,14 +1,15 @@ import { Metadata } from "next" - +import { auth } from "@/auth" +import { getUser } from "@/data/user" +import { prisma } from "@/prisma" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" - export const metadata: Metadata = { title: "Settings", } -export default function SettingsLayout({ +export default async function SettingsLayout({ children, params: { domain }, }: Readonly<{ diff --git a/packages/web/src/app/[domain]/settings/page.tsx b/packages/web/src/app/[domain]/settings/page.tsx index e06a0314c..f1119a0cc 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/components/ui/hover-card.tsx b/packages/web/src/components/ui/hover-card.tsx new file mode 100644 index 000000000..e54d91cf8 --- /dev/null +++ b/packages/web/src/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } From 7fe34f82130861edd4934142229e601cfe25f274 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 13 Feb 2025 18:10:37 -0800 Subject: [PATCH 2/5] add change billing email card to billing settings --- packages/web/src/actions.ts | 63 ++++++++++ .../billing/changeBillingEmailButton.tsx | 112 ++++++++++++++++++ .../app/[domain]/settings/billing/page.tsx | 17 ++- .../components/memberTableColumns.tsx | 102 ++++++++-------- packages/web/src/lib/errorCodes.ts | 1 + yarn.lock | 80 ++++++++++++- 6 files changed, 321 insertions(+), 54 deletions(-) create mode 100644 packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 7eb95f084..8662a074d 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -671,6 +671,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/changeBillingEmailButton.tsx b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx new file mode 100644 index 000000000..1045661c7 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx @@ -0,0 +1,112 @@ +"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 { Label } from "@/components/ui/label" +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/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 028c41dfb..3b9d53100 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -4,8 +4,10 @@ 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, getCurrentUserRole } from "@/actions" +import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" +import { ChangeBillingEmailCard } from "./changeBillingEmailButton" +import { CreditCard } from "lucide-react" export const metadata: Metadata = { title: "Billing | Settings", @@ -32,6 +34,11 @@ export default async function BillingPage({ 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 (
@@ -40,9 +47,14 @@ export default async function BillingPage({
+ {/* Billing Email Card */} + - Subscription Plan + + + Subscription Plan + {subscription.status === "trialing" ? "You are currently on a free trial" @@ -82,6 +94,7 @@ export default async function BillingPage({ +
) diff --git a/packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx b/packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx index 66a70bc28..a85aa69a2 100644 --- a/packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx +++ b/packages/web/src/app/[domain]/settings/components/memberTableColumns.tsx @@ -53,51 +53,31 @@ export const MemberTableColumns = (currentUserRole: string, currentUserId: strin } }, { - id: "remove", + id: "makeOwner", cell: ({ row }) => { const member = row.original; - if (!isOwner || member.id === currentUserId) { - return null; - } + if (!isOwner || member.id === currentUserId) return null; + return ( - - Remove Member + Make Owner
-

Are you sure you want to remove this member?

+

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

- This action will remove {member.email} from your organization. + This action will make {member.email} the owner of your organization.

- Your subscription's seat count will be automatically adjusted. + You will be demoted to a regular member.

@@ -108,24 +88,23 @@ export const MemberTableColumns = (currentUserRole: string, currentUserId: strin @@ -135,31 +114,51 @@ export const MemberTableColumns = (currentUserRole: string, currentUserId: strin } }, { - id: "makeOwner", + id: "remove", cell: ({ row }) => { const member = row.original; - if (!isOwner || member.id === currentUserId) return null; - + if (!isOwner || member.id === currentUserId) { + return null; + } return ( - - Make Owner + Remove Member
-

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

+

Are you sure you want to remove this member?

- This action will make {member.email} the owner of your organization. + This action will remove {member.email} from your organization.

- You will be demoted to a regular member. + Your subscription's seat count will be automatically adjusted.

@@ -170,23 +169,24 @@ export const MemberTableColumns = (currentUserRole: string, currentUserId: strin diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 5ac33d6d6..f8c92645b 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 a4ede7e3e..ba2a74bb3 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== From b1ab282650e40e0874488e05735bc8e75f6a34ba Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 13 Feb 2025 18:16:31 -0800 Subject: [PATCH 3/5] enforce owner role in action level --- packages/web/src/actions.ts | 45 ++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 8662a074d..8fad44bc3 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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; @@ -304,7 +335,7 @@ export const getCurrentUserRole = async (domain: string): Promise => withAuth((session) => - withOrgMembership(session, domain, async (orgId) => { + withOwner(session, domain, async (orgId) => { console.log("Creating invite for", email, userId, orgId); if (email === session.user.email) { @@ -399,7 +430,7 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => withAuth((session) => - withOrgMembership(session, domain, async (orgId) => { + withOwner(session, domain, async (orgId) => { const currentUserId = session.user.id; const currentUserRole = await prisma.userToOrg.findUnique({ where: { @@ -410,14 +441,6 @@ export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ s }, }); - if (!currentUserRole || currentUserRole.role !== "OWNER") { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "You are not the owner of this org", - } satisfies ServiceError; - } - if (newOwnerId === currentUserId) { return { statusCode: StatusCodes.BAD_REQUEST, @@ -627,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, From ec31ed6005dc815f7e056a7f14c54c2cf9d0fdba Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 13 Feb 2025 18:19:28 -0800 Subject: [PATCH 4/5] remove unused hover card component --- packages/web/src/components/ui/hover-card.tsx | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 packages/web/src/components/ui/hover-card.tsx diff --git a/packages/web/src/components/ui/hover-card.tsx b/packages/web/src/components/ui/hover-card.tsx deleted file mode 100644 index e54d91cf8..000000000 --- a/packages/web/src/components/ui/hover-card.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" - -import * as React from "react" -import * as HoverCardPrimitive from "@radix-ui/react-hover-card" - -import { cn } from "@/lib/utils" - -const HoverCard = HoverCardPrimitive.Root - -const HoverCardTrigger = HoverCardPrimitive.Trigger - -const HoverCardContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - -)) -HoverCardContent.displayName = HoverCardPrimitive.Content.displayName - -export { HoverCard, HoverCardTrigger, HoverCardContent } From 1bf60d18f6ec181054bffd7c2b32d87757a61251 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 13 Feb 2025 18:22:13 -0800 Subject: [PATCH 5/5] cleanup --- .../web/src/app/[domain]/components/payWall/paywallCard.tsx | 3 --- ...angeBillingEmailButton.tsx => changeBillingEmailCard.tsx} | 1 - packages/web/src/app/[domain]/settings/billing/page.tsx | 2 +- packages/web/src/app/[domain]/settings/layout.tsx | 5 +---- 4 files changed, 2 insertions(+), 9 deletions(-) rename packages/web/src/app/[domain]/settings/billing/{changeBillingEmailButton.tsx => changeBillingEmailCard.tsx} (98%) diff --git a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx index 5e2e0cb3f..3e1f31dfc 100644 --- a/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx +++ b/packages/web/src/app/[domain]/components/payWall/paywallCard.tsx @@ -5,9 +5,6 @@ import { CheckoutButton } from "./checkoutButton" import Image from "next/image"; import logoDark from "@/public/sb_logo_dark_large.png"; import logoLight from "@/public/sb_logo_light_large.png"; -import { getCurrentUserRole } from "@/actions" -import { isServiceError } from "@/lib/utils" -import { OrgRole } from "@sourcebot/db" const teamFeatures = [ "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported", diff --git a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx similarity index 98% rename from packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx rename to packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx index 1045661c7..b644d896b 100644 --- a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailButton.tsx +++ b/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx @@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" import { changeSubscriptionBillingEmail, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 3b9d53100..0cc245336 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -6,7 +6,7 @@ import { Separator } from "@/components/ui/separator" import { ManageSubscriptionButton } from "./manageSubscriptionButton" import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { isServiceError } from "@/lib/utils" -import { ChangeBillingEmailCard } from "./changeBillingEmailButton" +import { ChangeBillingEmailCard } from "./changeBillingEmailCard" import { CreditCard } from "lucide-react" export const metadata: Metadata = { diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index db5e821c0..7e9d01ccb 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,7 +1,4 @@ import { Metadata } from "next" -import { auth } from "@/auth" -import { getUser } from "@/data/user" -import { prisma } from "@/prisma" import { Separator } from "@/components/ui/separator" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" @@ -9,7 +6,7 @@ export const metadata: Metadata = { title: "Settings", } -export default async function SettingsLayout({ +export default function SettingsLayout({ children, params: { domain }, }: Readonly<{