switch magic link to invite code (#222)

* wip magic link codes

* pipe email to email provider properly

* remove magic link data cookie after sign in

* clean up unused imports

* dont remove cookie before we use it

* rm package-lock.json

* revert yarn files to v3 state

* switch email passing from cookie to search param

* add comment for settings dropdown auth update
This commit is contained in:
Michael Sukkarieh 2025-03-01 16:15:35 -08:00 committed by GitHub
parent 51186fe87d
commit ff350566b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 310 additions and 68 deletions

View file

@ -109,6 +109,7 @@
"fuse.js": "^7.0.0",
"graphql": "^16.9.0",
"http-status-codes": "^2.3.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.435.0",
"next": "14.2.21",
"next-auth": "^5.0.0-beta.25",

View file

@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { decrypt, encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { cookies, headers } from "next/headers"
@ -1408,3 +1408,11 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
return parsedConfig;
}
export const encryptValue = async (value: string) => {
return encrypt(value);
}
export const decryptValue = async (iv: string, encryptedValue: string) => {
return decrypt(iv, encryptedValue);
}

View file

@ -24,7 +24,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "next-themes"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { KeymapType } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useKeymapType } from "@/hooks/useKeymapType"
@ -44,7 +44,7 @@ export const SettingsDropdown = ({
const { theme: _theme, setTheme } = useTheme();
const [keymapType, setKeymapType] = useKeymapType();
const { data: session } = useSession();
const { data: session, update } = useSession();
const theme = useMemo(() => {
return _theme ?? "light";
@ -64,7 +64,14 @@ export const SettingsDropdown = ({
}, [theme]);
return (
<DropdownMenu>
// Was hitting a bug with invite code login where the first time the user signs in, the settingsDropdown doesn't have a valid session. To fix this
// we can simply update the session everytime the settingsDropdown is opened. This isn't a super frequent operation and updating the session is low cost,
// so this is a simple solution to the problem.
<DropdownMenu onOpenChange={(isOpen) => {
if (isOpen) {
update();
}
}}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
<Settings className="h-4 w-4" />

View file

@ -10,6 +10,7 @@ import { signIn } from "next-auth/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useRouter } from "next/navigation";
const magicLinkSchema = z.object({
email: z.string().email(),
@ -22,6 +23,8 @@ interface MagicLinkFormProps {
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
resolver: zodResolver(magicLinkSchema),
defaultValues: {
@ -29,11 +32,18 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
},
});
const onSignIn = (values: z.infer<typeof magicLinkSchema>) => {
const onSignIn = async (values: z.infer<typeof magicLinkSchema>) => {
setIsLoading(true);
captureEvent("wa_login_with_magic_link", {});
signIn("nodemailer", { email: values.email, redirectTo: callbackUrl ?? "/" })
.finally(() => {
signIn("nodemailer", { email: values.email, redirect: false, redirectTo: callbackUrl ?? "/" })
.then(() => {
setIsLoading(false);
router.push("/login/verify?email=" + encodeURIComponent(values.email));
})
.catch((error) => {
console.error("Error signing in", error);
setIsLoading(false);
});
}
@ -66,7 +76,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
disabled={isLoading}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with magic link
Sign in with login code
</Button>
</form>
</Form>

View file

@ -1,17 +1,99 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
"use client"
import { InputOTPSeparator } from "@/components/ui/input-otp"
import { InputOTPGroup } from "@/components/ui/input-otp"
import { InputOTPSlot } from "@/components/ui/input-otp"
import { InputOTP } from "@/components/ui/input-otp"
import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useState } from "react"
import VerificationFailed from "./verificationFailed"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import useCaptureEvent from "@/hooks/useCaptureEvent"
export default function VerifyPage() {
const [value, setValue] = useState("")
const searchParams = useSearchParams()
const email = searchParams.get("email")
const router = useRouter()
const captureEvent = useCaptureEvent();
if (!email) {
captureEvent("wa_login_verify_page_no_email", {})
return <VerificationFailed />
}
const handleSubmit = useCallback(async () => {
const url = new URL("/api/auth/callback/nodemailer", window.location.origin)
url.searchParams.set("token", value)
url.searchParams.set("email", email)
router.push(url.toString())
}, [value])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && value.length === 6) {
handleSubmit()
}
}
return (
<div className="flex flex-col items-center p-12 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.`}
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-b from-background to-muted/30">
<div className="w-full max-w-md">
<div className="flex justify-center mb-6">
<SourcebotLogo className="h-16" size="large" />
</div>
<Card className="w-full shadow-lg border-muted/40">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Verify your email</CardTitle>
<CardDescription className="text-center">
Enter the 6-digit code we sent to <span className="font-semibold text-primary">{email}</span>
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={(e) => {
e.preventDefault()
if (value.length === 6) {
handleSubmit()
}
}} className="space-y-6">
<div className="flex justify-center py-4">
<InputOTP maxLength={6} value={value} onChange={setValue} onKeyDown={handleKeyDown} className="gap-2">
<InputOTPGroup>
<InputOTPSlot index={0} className="rounded-md border-input" />
<InputOTPSlot index={1} className="rounded-md border-input" />
<InputOTPSlot index={2} className="rounded-md border-input" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} className="rounded-md border-input" />
<InputOTPSlot index={4} className="rounded-md border-input" />
<InputOTPSlot index={5} className="rounded-md border-input" />
</InputOTPGroup>
</InputOTP>
</div>
</form>
</CardContent>
<CardFooter className="flex flex-col space-y-4 pt-0">
<Button variant="ghost" className="w-full text-sm" size="sm" onClick={() => window.history.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to login
</Button>
</CardFooter>
</Card>
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
Having trouble?{" "}
<a href="mailto:team@sourcebot.dev" className="text-primary hover:underline">
Contact support
</a>
</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,43 @@
"use client"
import { Button } from "@/components/ui/button"
import { AlertCircle } from "lucide-react"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { useRouter } from "next/navigation"
export default function VerificationFailed() {
const router = useRouter()
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#111318] text-white">
<div className="w-full max-w-md rounded-lg bg-[#1A1D24] p-8 shadow-lg">
<div className="mb-6 flex justify-center">
<SourcebotLogo />
</div>
<div className="mb-6 text-center">
<div className="mb-4 flex justify-center">
<AlertCircle className="h-10 w-10 text-red-500" />
</div>
<p className="mb-2 text-center text-lg font-medium">Login verification failed</p>
<p className="text-center text-sm text-gray-400">
Something went wrong when trying to verify your login. Please try again.
</p>
</div>
<Button onClick={() => router.push("/login")} className="w-full bg-purple-600 hover:bg-purple-700">
Return to login
</Button>
</div>
<div className="mt-8 flex gap-6 text-sm text-gray-500">
<a href="https://www.sourcebot.dev" className="hover:text-gray-300">
About
</a>
<a href="mailto:team@sourcebot.dev" className="hover:text-gray-300">
Contact Us
</a>
</div>
</div>
)
}

View file

@ -63,15 +63,19 @@ export const getProviders = () => {
server: SMTP_CONNECTION_URL,
from: EMAIL_FROM,
maxAge: 60 * 10,
sendVerificationRequest: async ({ identifier, url, provider }) => {
generateVerificationToken: async () => {
const token = String(Math.floor(100000 + Math.random() * 900000));
return token;
},
sendVerificationRequest: async ({ identifier, provider, token }) => {
const transport = createTransport(provider.server);
const html = await render(MagicLinkEmail({ magicLink: url, baseUrl: AUTH_URL }));
const html = await render(MagicLinkEmail({ baseUrl: AUTH_URL, token: token }));
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}`
text: `Log in to Sourcebot using this code: ${token}`
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
@ -186,6 +190,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providers: getProviders(),
pages: {
signIn: "/login",
verifyRequest: "/login/verify",
// We set redirect to false in signInOptions so we can pass the email is as a param
// verifyRequest: "/login/verify",
}
});

View file

@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View file

@ -1,65 +1,66 @@
import {
Body,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { EmailFooter } from './emailFooter';
interface MagicLinkEmailProps {
magicLink: string,
baseUrl: string,
token: string,
}
export const MagicLinkEmail = ({
magicLink: url,
baseUrl: baseUrl,
baseUrl,
token,
}: MagicLinkEmailProps) => (
<Html>
<Head />
<Preview>Use this code {token} to log in to Sourcebot</Preview>
<Tailwind>
<Preview>Log in to Sourcebot</Preview>
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Body className="bg-white font-sans m-0 p-0">
<Container className="mx-auto max-w-[600px] p-6">
<Section className="mb-4">
<Img
src={`${baseUrl}/sb_logo_light_large.png`}
height="60"
width="auto"
alt="Sourcebot Logo"
className="my-0 mx-auto"
width="auto"
height="40"
className="mx-0"
/>
</Section>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
<Section className="mb-4">
<Text className="text-base text-black">
Use the code below to log in to Sourcebot.
</Text>
<Text className="text-black text-[14px] leading-[24px]">
You can log in to your Sourcebot account by clicking the link below.
</Section>
<Section className="bg-[#f4f7fa] py-4 px-2 rounded mb-4 text-center">
<Text className="text-xl font-bold text-black tracking-[0.5em]">
{token}
</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.
</Section>
<Section>
<Text className="text-sm text-gray-600 leading-6">
This code is only valid for the next 10 minutes. If you didn&apos;t try to log in,
you can safely ignore this email.
</Text>
<EmailFooter />
</Section>
</Container>
</Body>
</Tailwind>
)
</Html>
);
MagicLinkEmail.PreviewProps = {
magicLink: 'https://example.com/login',
token: '123456',
baseUrl: 'http://localhost:3000',
} as MagicLinkEmailProps;

View file

@ -222,6 +222,8 @@ export type PosthogEventMap = {
wa_mobile_unsupported_splash_screen_dismissed: {},
wa_mobile_unsupported_splash_screen_displayed: {},
//////////////////////////////////////////////////////////////////
wa_login_verify_page_no_email: {},
//////////////////////////////////////////////////////////////////
wa_org_name_updated_success: {},
wa_org_name_updated_fail: {
error: string,

View file

@ -6022,6 +6022,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
input-otp@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07"
integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
internal-slot@^1.0.4, internal-slot@^1.0.7:
version "1.0.7"
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz"
@ -8623,7 +8628,14 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==