Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
189 changes: 186 additions & 3 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

micro nit: since there is allot of overlap, we could maybe get away with single withOrgMembership with a optional param that specifies the minimum role. For example:

const withOrgMembership = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>, minimumRequiredRole?: OrgRole = OrgRole.MEMBER)

We did something similar in requireOrgMembershipAndRole.ts in monorepo

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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you use withOwner here?

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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
)
}

Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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>
)
}
Loading