Magic links (#199)

* wip on magic link support

* Switch to nodemailer / resend for transactional mail

* Further cleanup

* Add stylized email using react-email

* fix
This commit is contained in:
Brendan Kellam 2025-02-18 11:34:07 -08:00 committed by GitHub
parent f652ca526e
commit bbf8b9be86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1521 additions and 286 deletions

View file

@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "vitest" "test": "vitest",
"dev:emails": "email dev --dir ./src/emails"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
@ -56,6 +57,8 @@
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@react-email/components": "^0.0.33",
"@react-email/render": "^1.0.5",
"@replit/codemirror-lang-csharp": "^6.2.0", "@replit/codemirror-lang-csharp": "^6.2.0",
"@replit/codemirror-lang-nix": "^6.0.1", "@replit/codemirror-lang-nix": "^6.0.1",
"@replit/codemirror-lang-solidity": "^6.0.2", "@replit/codemirror-lang-solidity": "^6.0.2",
@ -107,6 +110,7 @@
"next": "14.2.21", "next": "14.2.21",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nodemailer": "^6.10.0",
"posthog-js": "^1.161.5", "posthog-js": "^1.161.5",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"psl": "^1.15.0", "psl": "^1.15.0",
@ -128,6 +132,7 @@
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/psl": "^1.1.3", "@types/psl": "^1.1.3",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
@ -140,6 +145,7 @@
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8", "postcss": "^8",
"react-email": "3.0.3",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5", "typescript": "^5",

View file

@ -2,15 +2,14 @@ import { Button } from "@/components/ui/button";
import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { NavigationMenu as NavigationMenuBase, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import Link from "next/link"; import Link from "next/link";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_small.png";
import logoLight from "@/public/sb_logo_light_small.png";
import { SettingsDropdown } from "./settingsDropdown"; import { SettingsDropdown } from "./settingsDropdown";
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OrgSelector } from "./orgSelector"; import { OrgSelector } from "./orgSelector";
import { getSubscriptionData } from "@/actions"; import { getSubscriptionData } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -31,17 +30,9 @@ export const NavigationMenu = async ({
href={`/${domain}`} href={`/${domain}`}
className="mr-3 cursor-pointer" className="mr-3 cursor-pointer"
> >
<Image <SourcebotLogo
src={logoDark} className="h-11"
className="h-11 w-auto hidden dark:block" size="small"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-11 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/> />
</Link> </Link>

View file

@ -2,9 +2,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Check } from "lucide-react" import { Check } from "lucide-react"
import { EnterpriseContactUsButton } from "./enterpriseContactUsButton" import { EnterpriseContactUsButton } from "./enterpriseContactUsButton"
import { CheckoutButton } from "./checkoutButton" import { CheckoutButton } from "./checkoutButton"
import Image from "next/image"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
const teamFeatures = [ const teamFeatures = [
"Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported", "Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported",
@ -24,17 +22,9 @@ export async function PaywallCard({ domain }: { domain: string }) {
return ( return (
<div className="max-w-4xl mx-auto px-4 py-8"> <div className="max-w-4xl mx-auto px-4 py-8">
<div className="max-h-44 w-auto mb-4 flex justify-center"> <div className="max-h-44 w-auto mb-4 flex justify-center">
<Image <SourcebotLogo
src={logoDark} className="h-18 md:h-40"
className="h-18 md:h-40 w-auto hidden dark:block" size="large"
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>
<h2 className="text-3xl font-bold text-center mb-8 text-primary"> <h2 className="text-3xl font-bold text-center mb-8 text-primary">

View file

@ -11,7 +11,7 @@ export default function Layout({
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} /> <NavigationMenu domain={domain} />
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative"> <main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div> <div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main> </main>
</div> </div>

View file

@ -1,9 +1,6 @@
import { listRepositories } from "@/lib/server/searchService"; import { listRepositories } from "@/lib/server/searchService";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import Image from "next/image";
import { Suspense } from "react"; import { Suspense } from "react";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import { NavigationMenu } from "./components/navigationMenu"; import { NavigationMenu } from "./components/navigationMenu";
import { RepositoryCarousel } from "./components/repositoryCarousel"; import { RepositoryCarousel } from "./components/repositoryCarousel";
import { SearchBar } from "./components/searchBar"; import { SearchBar } from "./components/searchBar";
@ -14,6 +11,7 @@ import Link from "next/link";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound"; import { PageNotFound } from "./components/pageNotFound";
import { Footer } from "./components/footer"; import { Footer } from "./components/footer";
import { SourcebotLogo } from "../components/sourcebotLogo";
export default async function Home({ params: { domain } }: { params: { domain: string } }) { export default async function Home({ params: { domain } }: { params: { domain: string } }) {
@ -30,17 +28,8 @@ export default async function Home({ params: { domain } }: { params: { domain: s
<UpgradeToast /> <UpgradeToast />
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5"> <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"> <div className="max-h-44 w-auto">
<Image <SourcebotLogo
src={logoDark} className="h-18 md:h-40 w-auto"
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>
<SearchBar <SearchBar

View file

@ -0,0 +1,30 @@
import logoDarkLarge from "@/public/sb_logo_dark_large.png";
import logoLightLarge from "@/public/sb_logo_light_large.png";
import logoDarkSmall from "@/public/sb_logo_dark_small.png";
import logoLightSmall from "@/public/sb_logo_light_small.png";
import Image from "next/image";
import { cn } from "@/lib/utils";
interface SourcebotLogoProps {
className?: string;
size?: "small" | "large";
}
export const SourcebotLogo = ({ className, size = "large" }: SourcebotLogoProps) => {
return (
<>
<Image
src={size === "large" ? logoDarkLarge : logoDarkSmall}
className={cn("h-16 w-auto hidden dark:block", className)}
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={size === "large" ? logoLightLarge : logoLightSmall}
className={cn("h-16 w-auto block dark:hidden", className)}
alt={"Sourcebot logo"}
priority={true}
/>
</>
)
}

View file

@ -5,6 +5,7 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--background-secondary: 0, 0%, 98%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
@ -42,6 +43,7 @@
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--background-secondary: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;

View file

@ -0,0 +1,84 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { signIn } from "next-auth/react";
import { verifyCredentialsRequestSchema } from "@/lib/schemas";
import { useState } from "react";
import { Loader2 } from "lucide-react";
interface CredentialsFormProps {
callbackUrl?: string;
}
export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
resolver: zodResolver(verifyCredentialsRequestSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = (values: z.infer<typeof verifyCredentialsRequestSchema>) => {
setIsLoading(true);
signIn("credentials", {
email: values.email,
password: values.password,
redirectTo: callbackUrl ?? "/"
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-8">
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
variant="outline"
disabled={isLoading}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with credentials
</Button>
</form>
</Form>
);
}

View file

@ -1,42 +1,28 @@
'use client'; 'use client';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import githubLogo from "@/public/github.svg";
import googleLogo from "@/public/google.svg"; import googleLogo from "@/public/google.svg";
import Image from "next/image"; import Image from "next/image";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useCallback, useMemo } from "react"; import { Fragment, useCallback, useMemo } from "react";
import { verifyCredentialsRequestSchema } from "@/lib/schemas"; import { Card } from "@/components/ui/card";
import { cn, getCodeHostIcon } from "@/lib/utils";
import { MagicLinkForm } from "./magicLinkForm";
import { CredentialsForm } from "./credentialsForm";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
interface LoginFormProps { interface LoginFormProps {
callbackUrl?: string; callbackUrl?: string;
error?: string; error?: string;
enabledMethods: {
github: boolean;
google: boolean;
magicLink: boolean;
credentials: boolean;
}
} }
export const LoginForm = ({ callbackUrl, error }: LoginFormProps) => { export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps) => {
const form = useForm<z.infer<typeof verifyCredentialsRequestSchema>>({
resolver: zodResolver(verifyCredentialsRequestSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSignInWithEmailPassword = (values: z.infer<typeof verifyCredentialsRequestSchema>) => {
signIn("credentials", {
email: values.email,
password: values.password,
redirectTo: callbackUrl ?? "/"
});
}
const onSignInWithOauth = useCallback((provider: string) => { const onSignInWithOauth = useCallback((provider: string) => {
signIn(provider, { redirectTo: callbackUrl ?? "/" }); signIn(provider, { redirectTo: callbackUrl ?? "/" });
}, [callbackUrl]); }, [callbackUrl]);
@ -56,81 +42,55 @@ export const LoginForm = ({ callbackUrl, error }: LoginFormProps) => {
}, [error]); }, [error]);
return ( return (
<div className="flex flex-col items-center border p-16 rounded-lg gap-6 w-[500px]"> <div className="flex flex-col items-center justify-center">
{error && ( <div className="mb-6 flex flex-col items-center">
<div className="text-sm text-destructive text-center text-wrap border p-2 rounded-md border-destructive"> <SourcebotLogo
{errorMessage} className="h-16"
</div>
)}
<div>
<Image
src={logoDark}
className="h-16 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/> />
<Image <h2 className="text-lg font-bold">Sign in to your account</h2>
src={logoLight} </div>
className="h-16 w-auto block dark:hidden" <Card className="flex flex-col items-center border p-12 rounded-lg gap-6 w-[500px] bg-background">
alt={"Sourcebot logo"} {error && (
priority={true} <div className="text-sm text-destructive text-center text-wrap border p-2 rounded-md border-destructive">
{errorMessage}
</div>
)}
<DividerSet
elements={[
...(enabledMethods.github || enabledMethods.google ? [
<>
{enabledMethods.github && (
<ProviderButton
key="github"
name="GitHub"
logo={getCodeHostIcon("github")!}
onClick={() => {
onSignInWithOauth("github")
}}
/>
)}
{enabledMethods.google && (
<ProviderButton
key="google"
name="Google"
logo={{ src: googleLogo }}
onClick={() => {
onSignInWithOauth("google")
}}
/>
)}
</>
] : []),
...(enabledMethods.magicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} />
] : []),
...(enabledMethods.credentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} />
] : [])
]}
/> />
</div> </Card>
<ProviderButton </div>
name="GitHub"
logo={githubLogo}
onClick={() => {
onSignInWithOauth("github")
}}
/>
<ProviderButton
name="Google"
logo={googleLogo}
onClick={() => {
onSignInWithOauth("google")
}}
/>
<div className="flex items-center w-full gap-4">
<div className="h-[1px] flex-1 bg-border" />
<span className="text-muted-foreground text-sm">or</span>
<div className="h-[1px] flex-1 bg-border" />
</div>
<div className="flex flex-col w-60">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignInWithEmailPassword)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-8">
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
</Form>
</div>
</div >
) )
} }
@ -138,15 +98,42 @@ const ProviderButton = ({
name, name,
logo, logo,
onClick, onClick,
className,
}: { }: {
name: string; name: string;
logo: string; logo: { src: string, className?: string };
onClick: () => void; onClick: () => void;
className?: string;
}) => { }) => {
return ( return (
<Button onClick={onClick}> <Button
{logo && <Image src={logo} alt={name} className="w-5 h-5 invert dark:invert-0 mr-2" />} onClick={onClick}
className={cn("w-full", className)}
variant="outline"
>
{logo && <Image src={logo.src} alt={name} className={cn("w-5 h-5 mr-2", logo.className)} />}
Sign in with {name} Sign in with {name}
</Button> </Button>
) )
}
const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
return elements.map((child, index) => {
return (
<Fragment key={index}>
{child}
{index < elements.length - 1 && <Divider key={`divider-${index}`} />}
</Fragment>
)
})
}
const Divider = ({ className }: { className?: string }) => {
return (
<div className={cn("flex items-center w-full gap-4", className)}>
<div className="h-[1px] flex-1 bg-border" />
<span className="text-muted-foreground text-sm">or</span>
<div className="h-[1px] flex-1 bg-border" />
</div>
)
} }

View file

@ -0,0 +1,70 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
const magicLinkSchema = z.object({
email: z.string().email(),
});
interface MagicLinkFormProps {
callbackUrl?: string;
}
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
resolver: zodResolver(magicLinkSchema),
defaultValues: {
email: "",
},
});
const onSignIn = (values: z.infer<typeof magicLinkSchema>) => {
setIsLoading(true);
signIn("nodemailer", { email: values.email, redirectTo: callbackUrl ?? "/" })
.finally(() => {
setIsLoading(false);
});
}
return (
<Form
{...magicLinkForm}
>
<form
onSubmit={magicLinkForm.handleSubmit(onSignIn)}
className="w-full"
>
<FormField
control={magicLinkForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
variant="outline"
disabled={isLoading}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with magic link
</Button>
</form>
</Form>
)
}

View file

@ -1,5 +1,7 @@
import { auth } from "@/auth";
import { LoginForm } from "./components/loginForm"; import { LoginForm } from "./components/loginForm";
import { redirect } from "next/navigation";
import { getProviders } from "@/auth";
interface LoginProps { interface LoginProps {
searchParams: { searchParams: {
callbackUrl?: string; callbackUrl?: string;
@ -8,9 +10,34 @@ interface LoginProps {
} }
export default async function Login({ searchParams }: LoginProps) { export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
return redirect("/");
}
const providers = getProviders();
const providerMap = providers
.map((provider) => {
if (typeof provider === "function") {
const providerData = provider()
return { id: providerData.id, name: providerData.name }
} else {
return { id: provider.id, name: provider.name }
}
});
return ( return (
<div className="flex flex-col justify-center items-center h-screen"> <div className="flex flex-col justify-center items-center h-screen bg-backgroundSecondary">
<LoginForm callbackUrl={searchParams.callbackUrl} error={searchParams.error} /> <LoginForm
callbackUrl={searchParams.callbackUrl}
error={searchParams.error}
enabledMethods={{
github: providerMap.some(provider => provider.id === "github"),
google: providerMap.some(provider => provider.id === "google"),
magicLink: providerMap.some(provider => provider.id === "nodemailer"),
credentials: providerMap.some(provider => provider.id === "credentials"),
}}
/>
</div> </div>
) )
} }

View file

@ -0,0 +1,17 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
export default function VerifyPage() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<SourcebotLogo
className="mb-2 h-16"
size="small"
/>
<h1 className="text-2xl font-bold mb-2">Verify your email</h1>
<p className="text-sm text-muted-foreground">
{`We've sent a magic link to your email. Please check your inbox.`}
</p>
</div>
)
}

View file

@ -8,10 +8,8 @@ import { isServiceError } from "@/lib/utils"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
const onboardingFormSchema = z.object({ const onboardingFormSchema = z.object({
name: z.string() name: z.string()
@ -64,17 +62,8 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-center"> <div className="flex justify-center">
<Image <SourcebotLogo
src={logoDark} className="h-16"
className="h-16 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-16 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
/> />
</div> </div>
<h1 className="text-2xl font-bold">Let&apos;s create your organization</h1> <h1 className="text-2xl font-bold">Let&apos;s create your organization</h1>

View file

@ -9,9 +9,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import Image from "next/image";
import { setupInitialStripeCustomer } from "../../../actions" import { setupInitialStripeCustomer } from "../../../actions"
import { import {
@ -23,6 +20,7 @@ import { useState } from "react";
import { OnboardingFormValues } from "./orgCreateForm"; import { OnboardingFormValues } from "./orgCreateForm";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/environment.client"; import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/environment.client";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
const stripePromise = loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) const stripePromise = loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
@ -50,17 +48,8 @@ export function TrialCard({ orgCreateInfo }: { orgCreateInfo: OnboardingFormValu
<Card className="w-full max-w-md mx-auto"> <Card className="w-full max-w-md mx-auto">
<CardHeader> <CardHeader>
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<Image <SourcebotLogo
src={logoDark || "/placeholder.svg"} className="h-16"
className="h-16 w-auto hidden dark:block"
alt="Sourcebot logo"
priority={true}
/>
<Image
src={logoLight || "/placeholder.svg"}
className="h-16 w-auto block dark:hidden"
alt="Sourcebot logo"
priority={true}
/> />
</div> </div>
<CardTitle className="text-center text-2xl font-bold">7 day free trial</CardTitle> <CardTitle className="text-center text-2xl font-bold">7 day free trial</CardTitle>

View file

@ -3,11 +3,9 @@ import { notFound, redirect } from 'next/navigation';
import { auth } from "@/auth"; import { auth } from "@/auth";
import { getUser } from "@/data/user"; import { getUser } from "@/data/user";
import { AcceptInviteButton } from "./components/acceptInviteButton" 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";
import { fetchSubscription } from "@/actions"; import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
interface RedeemPageProps { interface RedeemPageProps {
searchParams?: { searchParams?: {
@ -23,17 +21,9 @@ function ErrorLayout({ title }: ErrorLayoutProps) {
return ( return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5"> <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"> <div className="max-h-44 w-auto mb-4">
<Image <SourcebotLogo
src={logoDark} className="h-18 md:h-40"
className="h-18 md:h-40 w-auto hidden dark:block" size="large"
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>
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
@ -97,17 +87,9 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
return ( return (
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5"> <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"> <div className="max-h-44 w-auto mb-4">
<Image <SourcebotLogo
src={logoDark} className="h-18 md:h-40"
className="h-18 md:h-40 w-auto hidden dark:block" size="large"
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>
<div className="flex justify-between items-center w-full max-w-2xl"> <div className="flex justify-between items-center w-full max-w-2xl">

View file

@ -3,13 +3,27 @@ import NextAuth, { DefaultSession } from "next-auth"
import GitHub from "next-auth/providers/github" import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google" import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials" import Credentials from "next-auth/providers/credentials"
import EmailProvider from "next-auth/providers/nodemailer";
import { PrismaAdapter } from "@auth/prisma-adapter" import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET, AUTH_URL } from "./lib/environment"; import {
AUTH_GITHUB_CLIENT_ID,
AUTH_GITHUB_CLIENT_SECRET,
AUTH_GOOGLE_CLIENT_ID,
AUTH_GOOGLE_CLIENT_SECRET,
AUTH_SECRET,
AUTH_URL,
AUTH_CREDENTIALS_LOGIN_ENABLED,
EMAIL_FROM,
SMTP_CONNECTION_URL
} from "./lib/environment";
import { User } from '@sourcebot/db'; import { User } from '@sourcebot/db';
import 'next-auth/jwt'; import 'next-auth/jwt';
import type { Provider } from "next-auth/providers"; import type { Provider } from "next-auth/providers";
import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas'; import { verifyCredentialsRequestSchema, verifyCredentialsResponseSchema } from './lib/schemas';
import { createTransport } from 'nodemailer';
import { render } from '@react-email/render';
import MagicLinkEmail from './emails/magicLink';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@ -27,62 +41,85 @@ declare module 'next-auth/jwt' {
} }
} }
const providers: Provider[] = [ export const getProviders = () => {
GitHub({ const providers: Provider[] = [];
clientId: AUTH_GITHUB_CLIENT_ID,
clientSecret: AUTH_GITHUB_CLIENT_SECRET, if (AUTH_GITHUB_CLIENT_ID && AUTH_GITHUB_CLIENT_SECRET) {
}), providers.push(GitHub({
Google({ clientId: AUTH_GITHUB_CLIENT_ID,
clientId: AUTH_GOOGLE_CLIENT_ID, clientSecret: AUTH_GITHUB_CLIENT_SECRET,
clientSecret: AUTH_GOOGLE_CLIENT_SECRET, }));
}), }
Credentials({
credentials: { if (AUTH_GOOGLE_CLIENT_ID && AUTH_GOOGLE_CLIENT_SECRET) {
email: {}, providers.push(Google({
password: {} clientId: AUTH_GOOGLE_CLIENT_ID,
}, clientSecret: AUTH_GOOGLE_CLIENT_SECRET,
type: "credentials", }));
authorize: async (credentials) => { }
const body = verifyCredentialsRequestSchema.safeParse(credentials);
if (!body.success) { if (SMTP_CONNECTION_URL && EMAIL_FROM) {
return null; providers.push(EmailProvider({
server: SMTP_CONNECTION_URL,
from: EMAIL_FROM,
maxAge: 60 * 10,
sendVerificationRequest: async ({ identifier, url, provider }) => {
const transport = createTransport(provider.server);
const html = await render(MagicLinkEmail({ magicLink: url, baseUrl: 'https://sourcebot.app' }));
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: 'Log in to Sourcebot',
html,
text: `Log in to Sourcebot by clicking here: ${url}`
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
} }
const { email, password } = body.data; }));
}
// authorize runs in the edge runtime (where we cannot make DB calls / access environment variables), if (AUTH_CREDENTIALS_LOGIN_ENABLED) {
// so we need to make a request to the server to verify the credentials. providers.push(Credentials({
const response = await fetch(new URL('/api/auth/verifyCredentials', AUTH_URL), { credentials: {
method: 'POST', email: {},
body: JSON.stringify({ email, password }), password: {}
}); },
type: "credentials",
if (!response.ok) { authorize: async (credentials) => {
return null; const body = verifyCredentialsRequestSchema.safeParse(credentials);
if (!body.success) {
return null;
}
const { email, password } = body.data;
// authorize runs in the edge runtime (where we cannot make DB calls / access environment variables),
// so we need to make a request to the server to verify the credentials.
const response = await fetch(new URL('/api/auth/verifyCredentials', AUTH_URL), {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
return null;
}
const user = verifyCredentialsResponseSchema.parse(await response.json());
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
} }
}));
}
const user = verifyCredentialsResponseSchema.parse(await response.json()); return providers;
return { }
id: user.id,
email: user.email,
name: user.name,
image: user.image,
}
}
})
];
// @see: https://authjs.dev/guides/pages/signin
export const providerMap = providers
.map((provider) => {
if (typeof provider === "function") {
const providerData = provider()
return { id: providerData.id, name: providerData.name }
} else {
return { id: provider.id, name: provider.name }
}
})
.filter((provider) => provider.id !== "credentials");
const useSecureCookies = AUTH_URL?.startsWith("https://") ?? false; const useSecureCookies = AUTH_URL?.startsWith("https://") ?? false;
const hostName = AUTH_URL ? new URL(AUTH_URL).hostname : "localhost"; const hostName = AUTH_URL ? new URL(AUTH_URL).hostname : "localhost";
@ -146,8 +183,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
} }
} }
}, },
providers: providers, providers: getProviders(),
pages: { pages: {
signIn: "/login" signIn: "/login",
verifyRequest: "/login/verify",
} }
}); });

View file

@ -0,0 +1,73 @@
import {
Body,
Container,
Hr,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
interface MagicLinkEmailProps {
magicLink: string,
baseUrl: string,
}
export const MagicLinkEmail = ({
magicLink: url,
baseUrl: baseUrl,
}: MagicLinkEmailProps) => (
<Tailwind>
<Preview>Log in to Sourcebot</Preview>
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src={`${baseUrl}/sb_logo_light_large.png`}
height="60"
width="auto"
alt="Sourcebot Logo"
className="my-0 mx-auto"
/>
</Section>
<Text className="text-black text-[14px] leading-[24px]">
Hey there,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
You can log in to your Sourcebot account by clicking the link below.
</Text>
<Link
href={url}
className="text-blue-600 no-underline"
target="_blank"
style={{
display: 'block',
marginBottom: '16px',
}}
>
Click here to log in
</Link>
<Text className="text-black text-[14px] leading-[24px]">
If you didn&apos;t try to login, you can safely ignore this email.
</Text>
<Hr className="border border-solid border-[#eaeaea] my-[10px] mx-0 w-full" />
<Text className="text-[#666666] text-[12px] leading-[24px]">
<Link href="https://sourcebot.dev" className="underline text-[#666666]" target="_blank">
Sourcebot.dev,
</Link>
&nbsp;blazingly fast code search.
</Text>
</Container>
</Body>
</Tailwind>
)
MagicLinkEmail.PreviewProps = {
magicLink: 'https://example.com/login',
baseUrl: 'http://localhost:3000',
} as MagicLinkEmailProps;
export default MagicLinkEmail;

View file

@ -1,6 +1,6 @@
import 'server-only'; import 'server-only';
import { getEnv, getEnvNumber } from "./utils"; import { getEnv, getEnvBoolean, getEnvNumber } from "./utils";
export const ZOEKT_WEBSERVER_URL = getEnv(process.env.ZOEKT_WEBSERVER_URL, "http://localhost:6070")!; export const ZOEKT_WEBSERVER_URL = getEnv(process.env.ZOEKT_WEBSERVER_URL, "http://localhost:6070")!;
export const SHARD_MAX_MATCH_COUNT = getEnvNumber(process.env.SHARD_MAX_MATCH_COUNT, 10000); export const SHARD_MAX_MATCH_COUNT = getEnvNumber(process.env.SHARD_MAX_MATCH_COUNT, 10000);
@ -13,9 +13,13 @@ export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_S
export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID); export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET); export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
export const AUTH_URL = getEnv(process.env.AUTH_URL)!; export const AUTH_URL = getEnv(process.env.AUTH_URL)!;
export const AUTH_CREDENTIALS_LOGIN_ENABLED = getEnvBoolean(process.env.AUTH_CREDENTIALS_LOGIN_ENABLED, true);
export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY); export const STRIPE_SECRET_KEY = getEnv(process.env.STRIPE_SECRET_KEY);
export const STRIPE_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID); export const STRIPE_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID);
export const STRIPE_WEBHOOK_SECRET = getEnv(process.env.STRIPE_WEBHOOK_SECRET); export const STRIPE_WEBHOOK_SECRET = getEnv(process.env.STRIPE_WEBHOOK_SECRET);
export const CONFIG_MAX_REPOS_NO_TOKEN = getEnvNumber(process.env.CONFIG_MAX_REPOS_NO_TOKEN, 500); export const CONFIG_MAX_REPOS_NO_TOKEN = getEnvNumber(process.env.CONFIG_MAX_REPOS_NO_TOKEN, 500);
export const SMTP_CONNECTION_URL = getEnv(process.env.SMTP_CONNECTION_URL);
export const EMAIL_FROM = getEnv(process.env.EMAIL_FROM);

View file

@ -1,7 +1,6 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "./auth"
export default auth((request) => { export async function middleware(request: NextRequest) {
const host = request.headers.get("host")!; const host = request.headers.get("host")!;
const searchParams = request.nextUrl.searchParams.toString(); const searchParams = request.nextUrl.searchParams.toString();
@ -13,15 +12,12 @@ export default auth((request) => {
host === process.env.NEXT_PUBLIC_ROOT_DOMAIN || host === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
host === 'localhost:3000' host === 'localhost:3000'
) { ) {
if (request.nextUrl.pathname === "/login" && request.auth) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next(); return NextResponse.next();
} }
const subdomain = host.split(".")[0]; const subdomain = host.split(".")[0];
return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url)); return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url));
}); };
export const config = { export const config = {

View file

@ -23,6 +23,7 @@ const config = {
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
backgroundSecondary: 'hsl(var(--background-secondary))',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',

1010
yarn.lock

File diff suppressed because it is too large Load diff