mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 {
|
||||
: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%;
|
||||
|
|
|
|||
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';
|
||||
|
||||
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,81 +42,55 @@ export const LoginForm = ({ callbackUrl, error }: LoginFormProps) => {
|
|||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center border p-16 rounded-lg gap-6 w-[500px]">
|
||||
{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}
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<SourcebotLogo
|
||||
className="h-16"
|
||||
/>
|
||||
<Image
|
||||
src={logoLight}
|
||||
className="h-16 w-auto block dark:hidden"
|
||||
alt={"Sourcebot logo"}
|
||||
priority={true}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<ProviderButton
|
||||
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 >
|
||||
</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>
|
||||
)
|
||||
}
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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 { 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's create your organization</h1>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,62 +41,85 @@ declare module 'next-auth/jwt' {
|
|||
}
|
||||
}
|
||||
|
||||
const providers: Provider[] = [
|
||||
GitHub({
|
||||
clientId: AUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: AUTH_GITHUB_CLIENT_SECRET,
|
||||
}),
|
||||
Google({
|
||||
clientId: AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: AUTH_GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: {},
|
||||
password: {}
|
||||
},
|
||||
type: "credentials",
|
||||
authorize: async (credentials) => {
|
||||
const body = verifyCredentialsRequestSchema.safeParse(credentials);
|
||||
if (!body.success) {
|
||||
return null;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
if (AUTH_GOOGLE_CLIENT_ID && AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
providers.push(Google({
|
||||
clientId: AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: AUTH_GOOGLE_CLIENT_SECRET,
|
||||
}));
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
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;
|
||||
if (AUTH_CREDENTIALS_LOGIN_ENABLED) {
|
||||
providers.push(Credentials({
|
||||
credentials: {
|
||||
email: {},
|
||||
password: {}
|
||||
},
|
||||
type: "credentials",
|
||||
authorize: async (credentials) => {
|
||||
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 {
|
||||
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");
|
||||
|
||||
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",
|
||||
}
|
||||
});
|
||||
|
|
|
|||
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 { 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);
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
Loading…
Reference in a new issue