Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 364
enforce owner perms#191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
enforce owner perms #191
Changes from all commits
193e6a07fe34f8b1ab282ec31ed61bf60d197ab3dcFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") { | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you use | ||
| 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: { | ||
| 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) | ||
| } | ||
msukkari marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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> | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
withOrgMembershipwith a optional param that specifies the minimum role. For example:We did something similar in
requireOrgMembershipAndRole.tsin monorepo