properly block access to pages if user isn't in an org

This commit is contained in:
msukkari 2025-02-12 09:29:00 -08:00
parent e7f8f51c05
commit 3ad6c2de48
9 changed files with 174 additions and 27 deletions

View file

@ -409,7 +409,7 @@ export async function fetchStripeClientSecret(name: string, domain: string) {
],
mode: 'subscription',
subscription_data: {
trial_period_days: 14
trial_period_days: 7,
},
customer_email: user.email!,
payment_method_collection: 'if_required',

View file

@ -0,0 +1,14 @@
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
export function Footer() {
return (
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
</footer>
)
}

View file

@ -0,0 +1,43 @@
"use client"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { Building2 } from "lucide-react"
export function NoOrganizationCard() {
const router = useRouter()
const handleOnboard = () => {
router.push("/onboard")
}
return (
<div className="flex justify-center items-center p-4">
<Card className="w-[400px] animate-fade-in-up">
<CardHeader className="space-y-1">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-muted-foreground" />
<CardTitle>No Organization</CardTitle>
</div>
<CardDescription className="text-base">You&apos;re not part of any organization yet.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
Please complete the onboarding process to create an organization. Alternatively, ask your teammate to invite
you to their organization.
</p>
<p className="text-sm text-muted-foreground">
Something seem wrong? Contact us at team@sourcebot.dev
</p>
</CardContent>
<CardFooter>
<Button onClick={handleOnboard} className="w-full bg-[#6366F1] hover:bg-[#5558DD]">
Complete Onboarding
</Button>
</CardFooter>
</Card>
</div>
)
}

View file

@ -6,17 +6,46 @@ import { PHProvider } from "./posthogProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
import { getCurrentUserOrg } from "@/auth";
import { isServiceError } from "@/lib/utils";
import { NavigationMenu } from "./components/navigationMenu";
import { NoOrganizationCard } from "./components/noOrganizationCard";
import { Footer } from "./components/footer";
import { headers } from "next/headers";
export const metadata: Metadata = {
title: "Sourcebot",
description: "Sourcebot",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const orgId = await getCurrentUserOrg();
const byPassOrgCheck = (await headers()).get("x-bypass-org-check")! == "true";
if (isServiceError(orgId) && !byPassOrgCheck) {
return (
<html
lang="en"
// @see : https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
suppressHydrationWarning
>
<body>
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<SessionProvider>
<NavigationMenu />
<NoOrganizationCard />
<Footer />
</SessionProvider>
</div>
)
</body>
</html>
)
}
return (
<html
lang="en"

View file

@ -12,6 +12,7 @@ import { SymbolIcon } from "@radix-ui/react-icons";
import { UpgradeToast } from "./components/upgradeToast";
import Link from "next/link";
import { getCurrentUserOrg } from "../auth"
import { Footer } from "./components/footer";
export default async function Home() {
@ -108,13 +109,7 @@ export default async function Home() {
</div>
)}
<footer className="w-full mt-auto py-4 flex flex-row justify-center items-center gap-4">
<Link href="https://sourcebot.dev" className="text-gray-400 text-sm hover:underline">About</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="https://github.com/sourcebot-dev/sourcebot/issues/new" className="text-gray-400 text-sm hover:underline">Support</Link>
<Separator orientation="vertical" className="h-4" />
<Link href="mailto:team@sourcebot.dev" className="text-gray-400 text-sm hover:underline">Contact Us</Link>
</footer>
<Footer />
</div>
)
}

View file

@ -4,13 +4,16 @@ import { NavigationMenu } from "../components/navigationMenu";
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { AcceptInviteButton } from "./components/acceptInviteButton"
import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
interface RedeemPageProps {
searchParams?: {
invite_id?: string;
};
}
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
const invite_id = searchParams?.invite_id;
@ -24,11 +27,24 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
if (!invite) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
</div>
<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">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This invite has either expired or was revoked. Contact your organization owner.</h1>
</div>
</div>
);
}
@ -44,12 +60,25 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
if (user) {
if (user.email !== invite.recipientEmail) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>Sorry this invite does not belong to you.</h1>
<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">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This invite doesn't belong to you. You're currenly signed in with ${user.email}</h1>
</div>
</div>
)
} else {
const orgName = await prisma.org.findUnique({
@ -59,18 +88,30 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
if (!orgName) {
return (
<div>
<NavigationMenu />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<h1>Organization not found. Please contact the invite sender.</h1>
<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">
<Image
src={logoDark}
className="h-18 md:h-40 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-18 md:h-40 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/>
</div>
<div className="flex justify-center items-center">
<h1>This organization wasn't found. Please contact your organization owner.</h1>
</div>
</div>
)
}
return (
<div>
<NavigationMenu />
<div className="flex justify-between items-center h-screen px-6">
<h1 className="text-2xl font-bold">You have been invited to org {orgName.name}</h1>
<AcceptInviteButton invite={invite} userId={user.id} />

View file

@ -1,4 +1,3 @@
import { Header } from "../components/header";
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { prisma } from "@/prisma";

View file

@ -111,3 +111,13 @@ export const getCurrentUserOrg = async () => {
return orgId;
}
export const doesUserHaveOrg = async (userId: string) => {
const orgs = await prisma.userToOrg.findMany({
where: {
userId,
},
});
return orgs.length > 0;
}

View file

@ -23,10 +23,22 @@ const apiMiddleware = (req: NextAuthRequest) => {
}
const defaultMiddleware = (req: NextAuthRequest) => {
// We're not able to check if the user doesn't belong to any orgs in the middleware, since we cannot call prisma. As a result, we do this check
// in the root layout. However, there are certain endpoints (ex. login, redeem, onboard) that we want the user to be able to hit even if they don't
// belong to an org. It seems like the easiest way to do this is to check for these paths here and pass in a flag to the root layout using the headers
// https://github.com/vercel/next.js/discussions/43657#discussioncomment-5981981
const bypassOrgCheck = req.nextUrl.pathname === "/login" || req.nextUrl.pathname === "/redeem" || req.nextUrl.pathname.includes("onboard");
const requestheaders = new Headers(req.headers);
requestheaders.set("x-bypass-org-check", bypassOrgCheck.toString());
// if we're trying to redeem an invite while not authed we continue to the redeem page so
// that we can pipe the invite_id to the login page
if (!req.auth && req.nextUrl.pathname === "/redeem") {
return NextResponse.next();
return NextResponse.next({
request: {
headers: requestheaders,
}
});
}
if (!req.auth && req.nextUrl.pathname !== "/login") {
@ -37,7 +49,11 @@ const defaultMiddleware = (req: NextAuthRequest) => {
return NextResponse.redirect(newUrl);
}
return NextResponse.next();
return NextResponse.next({
request: {
headers: requestheaders,
}
});
}
export default auth(async (req) => {