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 (
-
- );
-}
-
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) ? (
+
+ ) : (
+
+ )}
+
+ );
}