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;
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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 { 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}`)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue