Skip to content

Commit e2e5433

Browse files
authored
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
1 parent 26cc70c commit e2e5433

File tree

12 files changed

+574
-99
lines changed

12 files changed

+574
-99
lines changed

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@radix-ui/react-avatar": "^1.1.2",
4545
"@radix-ui/react-dialog": "^1.1.4",
4646
"@radix-ui/react-dropdown-menu": "^2.1.1",
47+
"@radix-ui/react-hover-card": "^1.1.6",
4748
"@radix-ui/react-icons": "^1.3.0",
4849
"@radix-ui/react-label": "^2.1.0",
4950
"@radix-ui/react-navigation-menu": "^1.2.0",

packages/web/src/actions.ts

Lines changed: 186 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
1212
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
1313
import { encrypt } from "@sourcebot/crypto"
1414
import { getConnection } from "./data/connection";
15-
import { ConnectionSyncStatus, Prisma, Invite } from "@sourcebot/db";
15+
import { ConnectionSyncStatus, Prisma, Invite, OrgRole } from "@sourcebot/db";
1616
import { headers } from "next/headers"
1717
import { getStripe } from "@/lib/stripe"
1818
import { getUser } from "@/data/user";
@@ -58,6 +58,37 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
5858
return fn(org.id);
5959
}
6060

61+
export const withOwner = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
62+
const org = await prisma.org.findUnique({
63+
where: {
64+
domain,
65+
},
66+
});
67+
68+
if (!org) {
69+
return notFound();
70+
}
71+
72+
const userRole = await prisma.userToOrg.findUnique({
73+
where: {
74+
orgId_userId: {
75+
orgId: org.id,
76+
userId: session.user.id,
77+
},
78+
},
79+
});
80+
81+
if (!userRole || userRole.role !== OrgRole.OWNER) {
82+
return {
83+
statusCode: StatusCodes.FORBIDDEN,
84+
errorCode: ErrorCode.MEMBER_NOT_OWNER,
85+
message: "Only org owners can perform this action",
86+
} satisfies ServiceError;
87+
}
88+
89+
return fn(org.id);
90+
}
91+
6192
export const isAuthed = async () => {
6293
const session = await auth();
6394
return session != null;
@@ -282,9 +313,29 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr
282313
}
283314
}));
284315

285-
export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
316+
export const getCurrentUserRole = async (domain: string): Promise<OrgRole | ServiceError> =>
286317
withAuth((session) =>
287318
withOrgMembership(session, domain, async (orgId) => {
319+
const userRole = await prisma.userToOrg.findUnique({
320+
where: {
321+
orgId_userId: {
322+
orgId,
323+
userId: session.user.id,
324+
},
325+
},
326+
});
327+
328+
if (!userRole) {
329+
return notFound();
330+
}
331+
332+
return userRole.role;
333+
})
334+
);
335+
336+
export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
337+
withAuth((session) =>
338+
withOwner(session, domain, async (orgId) => {
288339
console.log("Creating invite for", email, userId, orgId);
289340

290341
if (email === session.user.email) {
@@ -377,6 +428,75 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
377428
}
378429
});
379430

431+
export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
432+
withAuth((session) =>
433+
withOwner(session, domain, async (orgId) => {
434+
const currentUserId = session.user.id;
435+
const currentUserRole = await prisma.userToOrg.findUnique({
436+
where: {
437+
orgId_userId: {
438+
userId: currentUserId,
439+
orgId,
440+
},
441+
},
442+
});
443+
444+
if (newOwnerId === currentUserId) {
445+
return {
446+
statusCode: StatusCodes.BAD_REQUEST,
447+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
448+
message: "You're already the owner of this org",
449+
} satisfies ServiceError;
450+
}
451+
452+
const newOwner = await prisma.userToOrg.findUnique({
453+
where: {
454+
orgId_userId: {
455+
userId: newOwnerId,
456+
orgId,
457+
},
458+
},
459+
});
460+
461+
if (!newOwner) {
462+
return {
463+
statusCode: StatusCodes.BAD_REQUEST,
464+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
465+
message: "The user you're trying to make the owner doesn't exist",
466+
} satisfies ServiceError;
467+
}
468+
469+
await prisma.$transaction([
470+
prisma.userToOrg.update({
471+
where: {
472+
orgId_userId: {
473+
userId: newOwnerId,
474+
orgId,
475+
},
476+
},
477+
data: {
478+
role: "OWNER",
479+
}
480+
}),
481+
prisma.userToOrg.update({
482+
where: {
483+
orgId_userId: {
484+
userId: currentUserId,
485+
orgId,
486+
},
487+
},
488+
data: {
489+
role: "MEMBER",
490+
}
491+
})
492+
]);
493+
494+
return {
495+
success: true,
496+
}
497+
})
498+
);
499+
380500
const parseConnectionConfig = (connectionType: string, config: string) => {
381501
let parsedConfig: ConnectionConfig;
382502
try {
@@ -530,7 +650,7 @@ export async function fetchStripeSession(sessionId: string) {
530650

531651
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
532652
withAuth((session) =>
533-
withOrgMembership(session, domain, async (orgId) => {
653+
withOwner(session, domain, async (orgId) => {
534654
const org = await prisma.org.findUnique({
535655
where: {
536656
id: orgId,
@@ -574,6 +694,69 @@ export const fetchSubscription = (domain: string): Promise<Stripe.Subscription |
574694
return subscriptions.data[0];
575695
});
576696

697+
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
698+
withAuth(async (session) =>
699+
withOrgMembership(session, domain, async (orgId) => {
700+
const org = await prisma.org.findUnique({
701+
where: {
702+
id: orgId,
703+
},
704+
});
705+
706+
if (!org || !org.stripeCustomerId) {
707+
return notFound();
708+
}
709+
710+
const stripe = getStripe();
711+
const customer = await stripe.customers.retrieve(org.stripeCustomerId);
712+
if (!('email' in customer) || customer.deleted) {
713+
return notFound();
714+
}
715+
return customer.email!;
716+
})
717+
);
718+
719+
export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> =>
720+
withAuth((session) =>
721+
withOrgMembership(session, domain, async (orgId) => {
722+
const userRole = await prisma.userToOrg.findUnique({
723+
where: {
724+
orgId_userId: {
725+
orgId,
726+
userId: session.user.id,
727+
}
728+
}
729+
});
730+
731+
if (!userRole || userRole.role !== "OWNER") {
732+
return {
733+
statusCode: StatusCodes.FORBIDDEN,
734+
errorCode: ErrorCode.MEMBER_NOT_OWNER,
735+
message: "Only org owners can change billing email",
736+
} satisfies ServiceError;
737+
}
738+
739+
const org = await prisma.org.findUnique({
740+
where: {
741+
id: orgId,
742+
},
743+
});
744+
745+
if (!org || !org.stripeCustomerId) {
746+
return notFound();
747+
}
748+
749+
const stripe = getStripe();
750+
await stripe.customers.update(org.stripeCustomerId, {
751+
email: newEmail,
752+
});
753+
754+
return {
755+
success: true,
756+
}
757+
})
758+
);
759+
577760
export const checkIfUserHasOrg = async (userId: string): Promise<boolean | ServiceError> => {
578761
const orgs = await prisma.userToOrg.findMany({
579762
where: {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use client"
2+
3+
import { Button } from "@/components/ui/button"
4+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
5+
import { Input } from "@/components/ui/input"
6+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
7+
import { changeSubscriptionBillingEmail, getSubscriptionBillingEmail } from "@/actions"
8+
import { isServiceError } from "@/lib/utils"
9+
import { useDomain } from "@/hooks/useDomain"
10+
import { OrgRole } from "@sourcebot/db"
11+
import { useEffect, useState } from "react"
12+
import { Mail } from "lucide-react"
13+
import { useForm } from "react-hook-form"
14+
import { zodResolver } from "@hookform/resolvers/zod"
15+
import * as z from "zod"
16+
import { useToast } from "@/components/hooks/use-toast";
17+
18+
const formSchema = z.object({
19+
email: z.string().email("Please enter a valid email address"),
20+
})
21+
22+
interface ChangeBillingEmailCardProps {
23+
currentUserRole: OrgRole
24+
}
25+
26+
export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCardProps) {
27+
const domain = useDomain()
28+
const [billingEmail, setBillingEmail] = useState<string>("")
29+
const [isLoading, setIsLoading] = useState(false)
30+
const { toast } = useToast()
31+
32+
const form = useForm<z.infer<typeof formSchema>>({
33+
resolver: zodResolver(formSchema),
34+
defaultValues: {
35+
email: "",
36+
},
37+
})
38+
39+
useEffect(() => {
40+
const fetchBillingEmail = async () => {
41+
const email = await getSubscriptionBillingEmail(domain)
42+
if (!isServiceError(email)) {
43+
setBillingEmail(email)
44+
}
45+
}
46+
fetchBillingEmail()
47+
}, [domain])
48+
49+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
50+
setIsLoading(true)
51+
const newEmail = values.email || billingEmail
52+
const result = await changeSubscriptionBillingEmail(domain, newEmail)
53+
if (!isServiceError(result)) {
54+
setBillingEmail(newEmail)
55+
form.reset({ email: "" })
56+
toast({
57+
description: "✅ Billing email updated successfully!",
58+
})
59+
} else {
60+
toast({
61+
description: "❌ Failed to update billing email. Please try again.",
62+
})
63+
}
64+
setIsLoading(false)
65+
}
66+
67+
return (
68+
<Card className="w-full">
69+
<CardHeader>
70+
<CardTitle className="flex items-center gap-2">
71+
<Mail className="h-5 w-5" />
72+
Billing Email
73+
</CardTitle>
74+
<CardDescription>The email address for your billing account</CardDescription>
75+
</CardHeader>
76+
<CardContent>
77+
<Form {...form}>
78+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
79+
<FormField
80+
control={form.control}
81+
name="email"
82+
render={({ field }) => (
83+
<FormItem>
84+
<FormLabel>Email address</FormLabel>
85+
<FormControl>
86+
<Input
87+
placeholder={billingEmail}
88+
{...field}
89+
disabled={currentUserRole !== OrgRole.OWNER}
90+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
91+
/>
92+
</FormControl>
93+
<FormMessage />
94+
</FormItem>
95+
)}
96+
/>
97+
<Button
98+
type="submit"
99+
className="w-full"
100+
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
101+
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
102+
>
103+
{isLoading ? "Updating..." : "Update Billing Email"}
104+
</Button>
105+
</form>
106+
</Form>
107+
</CardContent>
108+
</Card>
109+
)
110+
}
111+

packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { isServiceError } from "@/lib/utils"
66
import { Button } from "@/components/ui/button"
77
import { getCustomerPortalSessionLink } from "@/actions"
88
import { useDomain } from "@/hooks/useDomain";
9+
import { OrgRole } from "@sourcebot/db";
910

10-
export function ManageSubscriptionButton() {
11+
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
1112
const [isLoading, setIsLoading] = useState(false)
1213
const router = useRouter()
1314
const domain = useDomain();
@@ -28,9 +29,15 @@ export function ManageSubscriptionButton() {
2829
}
2930
}
3031

32+
const isOwner = currentUserRole === OrgRole.OWNER
3133
return (
32-
<Button className="w-full" onClick={redirectToCustomerPortal} disabled={isLoading}>
33-
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
34+
<Button
35+
className="w-full"
36+
onClick={redirectToCustomerPortal}
37+
disabled={isLoading || !isOwner}
38+
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
39+
>
40+
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
3441
</Button>
3542
)
3643
}

0 commit comments

Comments
 (0)