mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
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:
parent
f652ca526e
commit
bbf8b9be86
21 changed files with 1521 additions and 286 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
30
packages/web/src/app/components/sourcebotLogo.tsx
Normal file
30
packages/web/src/app/components/sourcebotLogo.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
84
packages/web/src/app/login/components/credentialsForm.tsx
Normal file
84
packages/web/src/app/login/components/credentialsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
packages/web/src/app/login/components/magicLinkForm.tsx
Normal file
70
packages/web/src/app/login/components/magicLinkForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
packages/web/src/app/login/verify/page.tsx
Normal file
17
packages/web/src/app/login/verify/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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's create your organization</h1>
|
<h1 className="text-2xl font-bold">Let's create your organization</h1>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
authorize: async (credentials) => {
|
||||||
|
const body = verifyCredentialsRequestSchema.safeParse(credentials);
|
||||||
|
if (!body.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { email, password } = body.data;
|
||||||
|
|
||||||
if (!response.ok) {
|
// authorize runs in the edge runtime (where we cannot make DB calls / access environment variables),
|
||||||
return null;
|
// 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",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
73
packages/web/src/emails/magicLink.tsx
Normal file
73
packages/web/src/emails/magicLink.tsx
Normal 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'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>
|
||||||
|
blazingly fast code search.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
)
|
||||||
|
|
||||||
|
MagicLinkEmail.PreviewProps = {
|
||||||
|
magicLink: 'https://example.com/login',
|
||||||
|
baseUrl: 'http://localhost:3000',
|
||||||
|
} as MagicLinkEmailProps;
|
||||||
|
|
||||||
|
export default MagicLinkEmail;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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))',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue