Skip to content

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

Merged
merged 6 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all 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