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

@ -121,6 +121,7 @@ model Org {
repos Repo[]
secrets Secret[]
isOnboarded Boolean @default(false)
imageUrl String?
stripeCustomerId String?
stripeSubscriptionStatus StripeSubscriptionStatus?

View file

@ -570,22 +570,34 @@ 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({
export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) => {
const invite = await prisma.invite.findUnique({
where: {
id: invite.orgId,
id: inviteId,
},
include: {
org: true,
}
});
if (!org) {
if (!invite) {
return notFound();
}
const user = await getUser(session.user.id);
if (!user) {
return notFound();
}
// 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(org.id, tx);
const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx);
if (subscription) {
if (isServiceError(subscription)) {
return subscription;
@ -606,7 +618,7 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
await tx.userToOrg.create({
data: {
userId,
userId: user.id,
orgId: invite.orgId,
role: "MEMBER",
}
@ -626,9 +638,47 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
return {
success: true,
}
} catch (error) {
console.error("Failed to redeem invite:", error);
return unexpectedError("Failed to redeem invite");
});
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!,
}
}
});

View file

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

View file

@ -12,7 +12,7 @@ export default async function Onboarding() {
}
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
title="Setup your organization"
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 { 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 (
<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) {
const invite_id = searchParams?.invite_id;
if (!invite_id) {
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 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}`)}`);
}
// Auth case
if (user) {
if (user.email !== invite.recipientEmail) {
return (
<ErrorLayout title={`This invite doesn't belong to you. You're currently signed in with ${user.email}`} />
)
} else {
const org = await prisma.org.findUnique({
where: { id: invite.orgId },
});
if (!org) {
return (
<ErrorLayout title="This organization wasn't found. Please contact your organization owner." />
)
}
const inviteInfo = await getInviteInfo(inviteId);
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 className="flex flex-col items-center min-h-screen py-24 bg-backgroundSecondary relative">
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
{isServiceError(inviteInfo) ? (
<InviteNotFoundCard />
) : (
<AcceptInviteCard
inviteId={inviteId}
orgName={inviteInfo.orgName}
orgDomain={inviteInfo.orgDomain}
host={inviteInfo.host}
recipient={inviteInfo.recipient}
orgImageUrl={inviteInfo.orgImageUrl}
/>
</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}`)}`);
}
}