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",
"start": "next start",
"lint": "next lint",
"test": "vitest"
"test": "vitest",
"dev:emails": "email dev --dir ./src/emails"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
@ -56,6 +57,8 @@
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0",
"@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-nix": "^6.0.1",
"@replit/codemirror-lang-solidity": "^6.0.2",
@ -107,6 +110,7 @@
"next": "14.2.21",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.3.0",
"nodemailer": "^6.10.0",
"posthog-js": "^1.161.5",
"pretty-bytes": "^6.1.1",
"psl": "^1.15.0",
@ -128,6 +132,7 @@
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/psl": "^1.1.3",
"@types/react": "^18",
"@types/react-dom": "^18",
@ -140,6 +145,7 @@
"jsdom": "^25.0.1",
"npm-run-all": "^4.1.5",
"postcss": "^8",
"react-email": "3.0.3",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.2",
"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 Link from "next/link";
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 { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
import { redirect } from "next/navigation";
import { OrgSelector } from "./orgSelector";
import { getSubscriptionData } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -31,17 +30,9 @@ export const NavigationMenu = async ({
href={`/${domain}`}
className="mr-3 cursor-pointer"
>
<Image
src={logoDark}
className="h-11 w-auto hidden dark:block"
alt={"Sourcebot logo"}
priority={true}
/>
<Image
src={logoLight}
className="h-11 w-auto block dark:hidden"
alt={"Sourcebot logo"}
priority={true}
<SourcebotLogo
className="h-11"
size="small"
/>
</Link>

View file

@ -2,9 +2,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Check } from "lucide-react"
import { EnterpriseContactUsButton } from "./enterpriseContactUsButton"
import { CheckoutButton } from "./checkoutButton"
import Image from "next/image";
import logoDark from "@/public/sb_logo_dark_large.png";
import logoLight from "@/public/sb_logo_light_large.png";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
const teamFeatures = [
"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 (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="max-h-44 w-auto mb-4 flex justify-center">
<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}
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<h2 className="text-3xl font-bold text-center mb-8 text-primary">

View file

@ -11,7 +11,7 @@ export default function Layout({
return (
<div className="min-h-screen flex flex-col">
<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>
</main>
</div>

View file

@ -1,9 +1,6 @@
import { listRepositories } from "@/lib/server/searchService";
import { isServiceError } from "@/lib/utils";
import Image from "next/image";
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 { RepositoryCarousel } from "./components/repositoryCarousel";
import { SearchBar } from "./components/searchBar";
@ -14,6 +11,7 @@ import Link from "next/link";
import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound";
import { Footer } from "./components/footer";
import { SourcebotLogo } from "../components/sourcebotLogo";
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 />
<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">
<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}
<SourcebotLogo
className="h-18 md:h-40 w-auto"
/>
</div>
<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 {
:root {
--background: 0 0% 100%;
--background-secondary: 0, 0%, 98%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
@ -42,6 +43,7 @@
.dark {
--background: 222.2 84% 4.9%;
--background-secondary: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--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';
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 Image from "next/image";
import { signIn } from "next-auth/react";
import { useCallback, useMemo } from "react";
import { verifyCredentialsRequestSchema } from "@/lib/schemas";
import { Fragment, useCallback, useMemo } from "react";
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 {
callbackUrl?: string;
error?: string;
enabledMethods: {
github: boolean;
google: boolean;
magicLink: boolean;
credentials: boolean;
}
}
export const LoginForm = ({ callbackUrl, error }: 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 ?? "/"
});
}
export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps) => {
const onSignInWithOauth = useCallback((provider: string) => {
signIn(provider, { redirectTo: callbackUrl ?? "/" });
}, [callbackUrl]);
@ -56,80 +42,54 @@ export const LoginForm = ({ callbackUrl, error }: LoginFormProps) => {
}, [error]);
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">
<div className="mb-6 flex flex-col items-center">
<SourcebotLogo
className="h-16"
/>
<h2 className="text-lg font-bold">Sign in to your account</h2>
</div>
<Card className="flex flex-col items-center border p-12 rounded-lg gap-6 w-[500px] bg-background">
{error && (
<div className="text-sm text-destructive text-center text-wrap border p-2 rounded-md border-destructive">
{errorMessage}
</div>
)}
<div>
<Image
src={logoDark}
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>
<DividerSet
elements={[
...(enabledMethods.github || enabledMethods.google ? [
<>
{enabledMethods.github && (
<ProviderButton
key="github"
name="GitHub"
logo={githubLogo}
logo={getCodeHostIcon("github")!}
onClick={() => {
onSignInWithOauth("github")
}}
/>
)}
{enabledMethods.google && (
<ProviderButton
key="google"
name="Google"
logo={googleLogo}
logo={{ src: 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>
)}
</>
] : []),
...(enabledMethods.magicLink ? [
<MagicLinkForm key="magic-link" callbackUrl={callbackUrl} />
] : []),
...(enabledMethods.credentials ? [
<CredentialsForm key="credentials" callbackUrl={callbackUrl} />
] : [])
]}
/>
<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>
</Card>
</div>
)
}
@ -138,15 +98,42 @@ const ProviderButton = ({
name,
logo,
onClick,
className,
}: {
name: string;
logo: string;
logo: { src: string, className?: string };
onClick: () => void;
className?: string;
}) => {
return (
<Button onClick={onClick}>
{logo && <Image src={logo} alt={name} className="w-5 h-5 invert dark:invert-0 mr-2" />}
<Button
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}
</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 { redirect } from "next/navigation";
import { getProviders } from "@/auth";
interface LoginProps {
searchParams: {
callbackUrl?: string;
@ -8,9 +10,34 @@ interface 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 (
<div className="flex flex-col justify-center items-center h-screen">
<LoginForm callbackUrl={searchParams.callbackUrl} error={searchParams.error} />
<div className="flex flex-col justify-center items-center h-screen bg-backgroundSecondary">
<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>
)
}

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 { z } from "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 { SourcebotLogo } from "@/app/components/sourcebotLogo"
const onboardingFormSchema = z.object({
name: z.string()
@ -64,17 +62,8 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
return (
<div className="space-y-6">
<div className="flex justify-center">
<Image
src={logoDark}
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}
<SourcebotLogo
className="h-16"
/>
</div>
<h1 className="text-2xl font-bold">Let&apos;s create your organization</h1>

View file

@ -9,9 +9,6 @@ import {
} from "@/components/ui/card";
import { Check } from "lucide-react";
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 {
@ -23,6 +20,7 @@ import { useState } from "react";
import { OnboardingFormValues } from "./orgCreateForm";
import { isServiceError } from "@/lib/utils";
import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/environment.client";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
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">
<CardHeader>
<div className="flex justify-center mb-4">
<Image
src={logoDark || "/placeholder.svg"}
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}
<SourcebotLogo
className="h-16"
/>
</div>
<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 { 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";
import { fetchSubscription } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
interface RedeemPageProps {
searchParams?: {
@ -23,17 +21,9 @@ 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">
<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}
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<div className="flex justify-center items-center">
@ -97,17 +87,9 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
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">
<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}
<SourcebotLogo
className="h-18 md:h-40"
size="large"
/>
</div>
<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 Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
import EmailProvider from "next-auth/providers/nodemailer";
import { PrismaAdapter } from "@auth/prisma-adapter"
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 'next-auth/jwt';
import type { Provider } from "next-auth/providers";
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';
@ -27,16 +41,49 @@ declare module 'next-auth/jwt' {
}
}
const providers: Provider[] = [
GitHub({
export const getProviders = () => {
const providers: Provider[] = [];
if (AUTH_GITHUB_CLIENT_ID && AUTH_GITHUB_CLIENT_SECRET) {
providers.push(GitHub({
clientId: AUTH_GITHUB_CLIENT_ID,
clientSecret: AUTH_GITHUB_CLIENT_SECRET,
}),
Google({
}));
}
if (AUTH_GOOGLE_CLIENT_ID && AUTH_GOOGLE_CLIENT_SECRET) {
providers.push(Google({
clientId: AUTH_GOOGLE_CLIENT_ID,
clientSecret: AUTH_GOOGLE_CLIENT_SECRET,
}),
Credentials({
}));
}
if (SMTP_CONNECTION_URL && EMAIL_FROM) {
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`);
}
}
}));
}
if (AUTH_CREDENTIALS_LOGIN_ENABLED) {
providers.push(Credentials({
credentials: {
email: {},
password: {}
@ -68,21 +115,11 @@ const providers: Provider[] = [
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");
return providers;
}
const useSecureCookies = AUTH_URL?.startsWith("https://") ?? false;
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: {
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 { 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 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_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
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_PRODUCT_ID = getEnv(process.env.STRIPE_PRODUCT_ID);
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 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 { auth } from "./auth"
import { NextRequest, NextResponse } from "next/server";
export default auth((request) => {
export async function middleware(request: NextRequest) {
const host = request.headers.get("host")!;
const searchParams = request.nextUrl.searchParams.toString();
@ -13,15 +12,12 @@ export default auth((request) => {
host === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
host === 'localhost:3000'
) {
if (request.nextUrl.pathname === "/login" && request.auth) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
const subdomain = host.split(".")[0];
return NextResponse.rewrite(new URL(`/${subdomain}${path}`, request.url));
});
};
export const config = {

View file

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

1010
yarn.lock

File diff suppressed because it is too large Load diff