diff --git a/packages/db/prisma/migrations/20250220202710_add_image_url_to_org/migration.sql b/packages/db/prisma/migrations/20250220202710_add_image_url_to_org/migration.sql new file mode 100644 index 00000000..7c69c03a --- /dev/null +++ b/packages/db/prisma/migrations/20250220202710_add_image_url_to_org/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 726ab0fc..24d1ceb4 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -59,22 +59,22 @@ model Repo { } model Connection { - id Int @id @default(autoincrement()) - name String - config Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - syncedAt DateTime? - repos RepoToConnection[] - syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) - syncStatusMetadata Json? + id Int @id @default(autoincrement()) + name String + config Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + syncedAt DateTime? + repos RepoToConnection[] + syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) + syncStatusMetadata Json? // The type of connection (e.g., github, gitlab, etc.) - connectionType String + connectionType String // The organization that owns this connection - org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) - orgId Int + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int } model RepoToConnection { @@ -121,6 +121,7 @@ model Org { repos Repo[] secrets Secret[] isOnboarded Boolean @default(false) + imageUrl String? stripeCustomerId String? stripeSubscriptionStatus StripeSubscriptionStatus? diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 8d0bfd07..50de4458 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -570,65 +570,115 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ ); -export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> => - withAuth(async () => { - try { - const res = await prisma.$transaction(async (tx) => { - const org = await tx.org.findUnique({ - where: { - id: invite.orgId, - } - }); +export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => + withAuth(async (session) => { + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + include: { + org: true, + } + }); - if (!org) { - return notFound(); - } - - // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. - const subscription = await _fetchSubscriptionForOrg(org.id, tx); - if (subscription) { - if (isServiceError(subscription)) { - return subscription; - } + if (!invite) { + return notFound(); + } - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) + 1 + const user = await getUser(session.user.id); + if (!user) { + return notFound(); + } - const stripe = getStripe(); - await stripe.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) + // Check if the user is the recipient of the invite + if (user.email !== invite.recipientEmail) { + return notFound(); + } + + const res = await prisma.$transaction(async (tx) => { + // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. + const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); + if (subscription) { + if (isServiceError(subscription)) { + return subscription; } - await tx.userToOrg.create({ - data: { - userId, - orgId: invite.orgId, - role: "MEMBER", - } - }); + const existingSeatCount = subscription.items.data[0].quantity; + const newSeatCount = (existingSeatCount || 1) + 1 - await tx.invite.delete({ - where: { - id: invite.id, + const stripe = getStripe(); + await stripe.subscriptionItems.update( + subscription.items.data[0].id, + { + quantity: newSeatCount, + proration_behavior: 'create_prorations', } - }); + ) + } + + await tx.userToOrg.create({ + data: { + userId: user.id, + orgId: invite.orgId, + role: "MEMBER", + } }); - if (isServiceError(res)) { - return res; + await tx.invite.delete({ + where: { + id: invite.id, + } + }); + }); + + if (isServiceError(res)) { + return res; + } + + return { + success: true, + } + }); + +export const getInviteInfo = async (inviteId: string) => + withAuth(async (session) => { + const user = await getUser(session.user.id); + if (!user) { + return notFound(); + } + + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + include: { + org: true, + host: true, } + }); - return { - success: true, + if (!invite) { + return notFound(); + } + + if (invite.recipientEmail !== user.email) { + return notFound(); + } + + return { + id: invite.id, + orgName: invite.org.name, + orgImageUrl: invite.org.imageUrl ?? undefined, + orgDomain: invite.org.domain, + host: { + name: invite.host.name ?? undefined, + email: invite.host.email!, + avatarUrl: invite.host.image ?? undefined, + }, + recipient: { + name: user.name ?? undefined, + email: user.email!, } - } catch (error) { - console.error("Failed to redeem invite:", error); - return unexpectedError("Failed to redeem invite"); } }); diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 801a1067..b35d09e9 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -49,7 +49,6 @@ export default async function BillingPage({

Billing

Manage your subscription and billing information

-
{/* Billing Email Card */} diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx index ad0b369c..9bb08c81 100644 --- a/packages/web/src/app/onboard/page.tsx +++ b/packages/web/src/app/onboard/page.tsx @@ -12,7 +12,7 @@ export default async function Onboarding() { } return ( -
+
{ - setIsLoading(true) - try { - const res = await redeemInvite(invite, userId) - if (isServiceError(res)) { - console.log("Failed to redeem invite: ", res) - toast({ - title: "Error", - description: "Failed to redeem invite. Please ensure the organization has an active subscription.", - variant: "destructive", - }) - } else { - router.push("/") - } - } catch (error) { - console.error("Error redeeming invite:", error) - toast({ - title: "Error", - description: "An unexpected error occurred. Please try again.", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - - return ( - - ) -} - diff --git a/packages/web/src/app/redeem/components/acceptInviteCard.tsx b/packages/web/src/app/redeem/components/acceptInviteCard.tsx new file mode 100644 index 00000000..d8f32e99 --- /dev/null +++ b/packages/web/src/app/redeem/components/acceptInviteCard.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import Link from "next/link"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import placeholderAvatar from "@/public/placeholder_avatar.png"; +import { ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useCallback, useState } from "react"; +import { redeemInvite } from "@/actions"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/hooks/use-toast"; +import { isServiceError } from "@/lib/utils"; + +interface AcceptInviteCardProps { + inviteId: string; + orgName: string; + orgDomain: string; + orgImageUrl?: string; + host: { + name?: string; + email: string; + avatarUrl?: string; + }; + recipient: { + name?: string; + email: string; + }; +} + +export const AcceptInviteCard = ({ inviteId, orgName, orgDomain, orgImageUrl, host, recipient }: AcceptInviteCardProps) => { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const onRedeemInvite = useCallback(() => { + setIsLoading(true); + redeemInvite(inviteId) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to redeem invite with error: ${response.message}`, + variant: "destructive", + }); + } else { + toast({ + description: `✅ You are now a member of the ${orgName} organization.`, + }); + router.push(`/${orgDomain}`); + } + }) + .finally(() => { + setIsLoading(false); + }); + }, [inviteId, orgDomain, orgName, router, toast]); + + return ( + + + + + Join {orgName} + + + +

+ Hello {recipient.name?.split(' ')[0] ?? recipient.email}, +

+

+ invited you to join the {orgName} organization. +

+
+ + + + + + + +
+ +
+
+ ) +} + +const InvitedByText = ({ email, name }: { email: string, name?: string }) => { + const emailElement = + {email} + ; + + if (name) { + const firstName = name.split(' ')[0]; + return {firstName} ({emailElement}); + } + + return emailElement; +} \ No newline at end of file diff --git a/packages/web/src/app/redeem/components/inviteNotFoundCard.tsx b/packages/web/src/app/redeem/components/inviteNotFoundCard.tsx new file mode 100644 index 00000000..52c9b80d --- /dev/null +++ b/packages/web/src/app/redeem/components/inviteNotFoundCard.tsx @@ -0,0 +1,31 @@ +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; +import placeholderAvatar from "@/public/placeholder_avatar.png"; +import { auth } from "@/auth"; +import { Card } from "@/components/ui/card"; + + +export const InviteNotFoundCard = async () => { + const session = await auth(); + + return ( + + +

Invite not found

+

+ The invite you are trying to redeem has already been used, expired, or does not exist. +

+
+ + + +

+ Logged in as {session?.user?.email} +

+
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx index e87932ac..34550222 100644 --- a/packages/web/src/app/redeem/page.tsx +++ b/packages/web/src/app/redeem/page.tsx @@ -1,95 +1,45 @@ -import { prisma } from "@/prisma"; import { notFound, redirect } from 'next/navigation'; import { auth } from "@/auth"; -import { getUser } from "@/data/user"; -import { AcceptInviteButton } from "./components/acceptInviteButton" -import { fetchSubscription } from "@/actions"; +import { getInviteInfo } from "@/actions"; import { isServiceError } from "@/lib/utils"; -import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { AcceptInviteCard } from './components/acceptInviteCard'; +import { LogoutEscapeHatch } from '../components/logoutEscapeHatch'; +import { InviteNotFoundCard } from './components/inviteNotFoundCard'; interface RedeemPageProps { - searchParams?: { + searchParams: { invite_id?: string; }; } -interface ErrorLayoutProps { - title: string; -} - -function ErrorLayout({ title }: ErrorLayoutProps) { - return ( -
-
- -
-
-

{title}

-
-
- ); -} - export default async function RedeemPage({ searchParams }: RedeemPageProps) { - const invite_id = searchParams?.invite_id; - - if (!invite_id) { - notFound(); - } - - const invite = await prisma.invite.findUnique({ - where: { id: invite_id }, - }); - - if (!invite) { - return ( - - ); + const inviteId = searchParams.invite_id; + if (!inviteId) { + return notFound(); } const session = await auth(); - let user = undefined; - if (session) { - user = await getUser(session.user.id); + if (!session) { + return redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${inviteId}`)}`); } + const inviteInfo = await getInviteInfo(inviteId); - // Auth case - if (user) { - if (user.email !== invite.recipientEmail) { - return ( - - ) - } else { - const org = await prisma.org.findUnique({ - where: { id: invite.orgId }, - }); - - if (!org) { - return ( - - ) - } - - return ( -
-
- -
-
-

You have been invited to org {org.name}

- -
-
- ); - } - } else { - redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${invite_id}`)}`); - } + return ( +
+ + {isServiceError(inviteInfo) ? ( + + ) : ( + + )} +
+ ); }