Redeem UX pass (#204)

This commit is contained in:
Brendan Kellam 2025-02-21 10:42:53 -08:00 committed by GitHub
parent fee0767981
commit 70e309b310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 282 additions and 193 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT;

View file

@ -59,22 +59,22 @@ model Repo {
} }
model Connection { model Connection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
config Json config Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
syncedAt DateTime? syncedAt DateTime?
repos RepoToConnection[] repos RepoToConnection[]
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
syncStatusMetadata Json? syncStatusMetadata Json?
// The type of connection (e.g., github, gitlab, etc.) // The type of connection (e.g., github, gitlab, etc.)
connectionType String connectionType String
// The organization that owns this connection // The organization that owns this connection
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int orgId Int
} }
model RepoToConnection { model RepoToConnection {
@ -121,6 +121,7 @@ model Org {
repos Repo[] repos Repo[]
secrets Secret[] secrets Secret[]
isOnboarded Boolean @default(false) isOnboarded Boolean @default(false)
imageUrl String?
stripeCustomerId String? stripeCustomerId String?
stripeSubscriptionStatus StripeSubscriptionStatus? stripeSubscriptionStatus StripeSubscriptionStatus?

View file

@ -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> => export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async () => { withAuth(async (session) => {
try { const invite = await prisma.invite.findUnique({
const res = await prisma.$transaction(async (tx) => { where: {
const org = await tx.org.findUnique({ id: inviteId,
where: { },
id: invite.orgId, include: {
} org: true,
}); }
});
if (!org) { if (!invite) {
return notFound(); 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;
}
const existingSeatCount = subscription.items.data[0].quantity; const user = await getUser(session.user.id);
const newSeatCount = (existingSeatCount || 1) + 1 if (!user) {
return notFound();
}
const stripe = getStripe(); // Check if the user is the recipient of the invite
await stripe.subscriptionItems.update( if (user.email !== invite.recipientEmail) {
subscription.items.data[0].id, return notFound();
{ }
quantity: newSeatCount,
proration_behavior: 'create_prorations', 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({ const existingSeatCount = subscription.items.data[0].quantity;
data: { const newSeatCount = (existingSeatCount || 1) + 1
userId,
orgId: invite.orgId,
role: "MEMBER",
}
});
await tx.invite.delete({ const stripe = getStripe();
where: { await stripe.subscriptionItems.update(
id: invite.id, 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)) { await tx.invite.delete({
return res; where: {
} id: invite.id,
}
});
});
return { if (isServiceError(res)) {
success: true, 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,
}
});
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");
} }
}); });

View file

@ -49,7 +49,6 @@ export default async function BillingPage({
<h3 className="text-lg font-medium">Billing</h3> <h3 className="text-lg font-medium">Billing</h3>
<p className="text-sm text-muted-foreground">Manage your subscription and billing information</p> <p className="text-sm text-muted-foreground">Manage your subscription and billing information</p>
</div> </div>
<Separator />
<div className="grid gap-6"> <div className="grid gap-6">
{/* Billing Email Card */} {/* Billing Email Card */}
<ChangeBillingEmailCard currentUserRole={currentUserRole} /> <ChangeBillingEmailCard currentUserRole={currentUserRole} />

View file

@ -12,7 +12,7 @@ export default async function Onboarding() {
} }
return ( return (
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative"> <div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary relative">
<OnboardHeader <OnboardHeader
title="Setup your organization" title="Setup your organization"
description="Create a organization for your team to search and share code across your repositories." description="Create a organization for your team to search and share code across your repositories."

View file

@ -1,53 +0,0 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { redeemInvite } from "../../../actions";
import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
import { Button } from "@/components/ui/button"
import { Invite } from "@sourcebot/db"
interface AcceptInviteButtonProps {
invite: Invite
userId: string
}
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const { toast } = useToast()
const handleAcceptInvite = async () => {
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 (
<Button onClick={handleAcceptInvite} disabled={isLoading}>
{isLoading ? "Accepting..." : "Accept Invite"}
</Button>
)
}

View file

@ -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 (
<Card className="p-12 max-w-lg">
<CardHeader className="text-center">
<SourcebotLogo
className="h-16 w-auto mx-auto mb-2"
size="large"
/>
<CardTitle className="font-medium text-2xl">
Join <strong>{orgName}</strong>
</CardTitle>
</CardHeader>
<CardContent className="mt-3">
<p>
Hello {recipient.name?.split(' ')[0] ?? recipient.email},
</p>
<p className="mt-5">
<InvitedByText email={host.email} name={host.name} /> invited you to join the <strong>{orgName}</strong> organization.
</p>
<div className="flex fex-row items-center justify-center gap-2 mt-12">
<Avatar className="w-14 h-14">
<AvatarImage src={host.avatarUrl ?? placeholderAvatar.src} />
</Avatar>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
<Avatar className="w-14 h-14">
<AvatarImage src={orgImageUrl ?? placeholderAvatar.src} />
</Avatar>
</div>
<Button
className="mt-12 mx-auto w-full"
disabled={isLoading}
onClick={onRedeemInvite}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Accept Invite
</Button>
</CardContent>
</Card>
)
}
const InvitedByText = ({ email, name }: { email: string, name?: string }) => {
const emailElement = <Link href={`mailto:${email}`} className="text-blue-500 hover:text-blue-600">
{email}
</Link>;
if (name) {
const firstName = name.split(' ')[0];
return <span><strong>{firstName}</strong> ({emailElement})</span>;
}
return emailElement;
}

View file

@ -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 (
<Card className="flex flex-col items-center justify-center max-w-md text-center p-12">
<SourcebotLogo
className="h-16 w-auto mx-auto mb-2"
size="large"
/>
<h2 className="text-2xl font-bold">Invite not found</h2>
<p className="mt-5">
The invite you are trying to redeem has already been used, expired, or does not exist.
</p>
<div className="flex flex-col items-center gap-2 mt-8">
<Avatar className="h-12 w-12">
<AvatarImage src={session?.user.image ?? placeholderAvatar.src} />
</Avatar>
<p className="text-sm text-muted-foreground">
Logged in as <strong>{session?.user?.email}</strong>
</p>
</div>
</Card>
);
}

View file

@ -1,95 +1,45 @@
import { prisma } from "@/prisma";
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getUser } from "@/data/user"; import { getInviteInfo } from "@/actions";
import { AcceptInviteButton } from "./components/acceptInviteButton"
import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils"; 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 { interface RedeemPageProps {
searchParams?: { searchParams: {
invite_id?: string; invite_id?: string;
}; };
} }
interface ErrorLayoutProps {
title: string;
}
function ErrorLayout({ title }: ErrorLayoutProps) {
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<div className="flex justify-center items-center">
<h1>{title}</h1>
</div>
</div>
);
}
export default async function RedeemPage({ searchParams }: RedeemPageProps) { export default async function RedeemPage({ searchParams }: RedeemPageProps) {
const invite_id = searchParams?.invite_id; const inviteId = searchParams.invite_id;
if (!inviteId) {
if (!invite_id) { return notFound();
notFound();
}
const invite = await prisma.invite.findUnique({
where: { id: invite_id },
});
if (!invite) {
return (
<ErrorLayout title="This invite has either expired or was revoked. Contact your organization owner." />
);
} }
const session = await auth(); const session = await auth();
let user = undefined; if (!session) {
if (session) { return redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${inviteId}`)}`);
user = await getUser(session.user.id);
} }
const inviteInfo = await getInviteInfo(inviteId);
// Auth case return (
if (user) { <div className="flex flex-col items-center min-h-screen py-24 bg-backgroundSecondary relative">
if (user.email !== invite.recipientEmail) { <LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
return ( {isServiceError(inviteInfo) ? (
<ErrorLayout title={`This invite doesn't belong to you. You're currently signed in with ${user.email}`} /> <InviteNotFoundCard />
) ) : (
} else { <AcceptInviteCard
const org = await prisma.org.findUnique({ inviteId={inviteId}
where: { id: invite.orgId }, orgName={inviteInfo.orgName}
}); orgDomain={inviteInfo.orgDomain}
host={inviteInfo.host}
if (!org) { recipient={inviteInfo.recipient}
return ( orgImageUrl={inviteInfo.orgImageUrl}
<ErrorLayout title="This organization wasn't found. Please contact your organization owner." /> />
) )}
} </div>
);
return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
<div className="max-h-44 w-auto mb-4">
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<div className="flex justify-between items-center w-full max-w-2xl">
<h1 className="text-2xl font-bold">You have been invited to org {org.name}</h1>
<AcceptInviteButton invite={invite} userId={user.id} />
</div>
</div>
);
}
} else {
redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${invite_id}`)}`);
}
} }