mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 04:45:19 +00:00
Redeem UX pass (#204)
This commit is contained in:
parent
fee0767981
commit
70e309b310
9 changed files with 282 additions and 193 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Org" ADD COLUMN "imageUrl" TEXT;
|
||||
|
|
@ -121,6 +121,7 @@ model Org {
|
|||
repos Repo[]
|
||||
secrets Secret[]
|
||||
isOnboarded Boolean @default(false)
|
||||
imageUrl String?
|
||||
|
||||
stripeCustomerId String?
|
||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
109
packages/web/src/app/redeem/components/acceptInviteCard.tsx
Normal file
109
packages/web/src/app/redeem/components/acceptInviteCard.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue