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", "fuse.js": "^7.0.0",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.435.0", "lucide-react": "^0.435.0",
"next": "14.2.21", "next": "14.2.21",
"next-auth": "^5.0.0-beta.25", "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 { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; 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 { getConnection } from "./data/connection";
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { cookies, headers } from "next/headers" import { cookies, headers } from "next/headers"
@ -1408,3 +1408,11 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
return parsedConfig; 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, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useMemo } from "react" import { useMemo, useState } from "react"
import { KeymapType } from "@/lib/types" import { KeymapType } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useKeymapType } from "@/hooks/useKeymapType" import { useKeymapType } from "@/hooks/useKeymapType"
@ -44,7 +44,7 @@ export const SettingsDropdown = ({
const { theme: _theme, setTheme } = useTheme(); const { theme: _theme, setTheme } = useTheme();
const [keymapType, setKeymapType] = useKeymapType(); const [keymapType, setKeymapType] = useKeymapType();
const { data: session } = useSession(); const { data: session, update } = useSession();
const theme = useMemo(() => { const theme = useMemo(() => {
return _theme ?? "light"; return _theme ?? "light";
@ -64,7 +64,14 @@ export const SettingsDropdown = ({
}, [theme]); }, [theme]);
return ( 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> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}> <Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />

View file

@ -10,6 +10,7 @@ import { signIn } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useRouter } from "next/navigation";
const magicLinkSchema = z.object({ const magicLinkSchema = z.object({
email: z.string().email(), email: z.string().email(),
@ -22,6 +23,8 @@ interface MagicLinkFormProps {
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({ const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
resolver: zodResolver(magicLinkSchema), resolver: zodResolver(magicLinkSchema),
defaultValues: { 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); setIsLoading(true);
captureEvent("wa_login_with_magic_link", {}); 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); setIsLoading(false);
}); });
} }
@ -66,7 +76,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""} {isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
Sign in with magic link Sign in with login code
</Button> </Button>
</form> </form>
</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() { 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 ( return (
<div className="flex flex-col items-center p-12 h-screen"> <div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-b from-background to-muted/30">
<SourcebotLogo <div className="w-full max-w-md">
className="mb-2 h-16" <div className="flex justify-center mb-6">
size="small" <SourcebotLogo className="h-16" size="large" />
/> </div>
<h1 className="text-2xl font-bold mb-2">Verify your email</h1> <Card className="w-full shadow-lg border-muted/40">
<p className="text-sm text-muted-foreground"> <CardHeader className="space-y-1">
{`We've sent a magic link to your email. Please check your inbox.`} <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> </p>
</div> </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, server: SMTP_CONNECTION_URL,
from: EMAIL_FROM, from: EMAIL_FROM,
maxAge: 60 * 10, 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 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({ const result = await transport.sendMail({
to: identifier, to: identifier,
from: provider.from, from: provider.from,
subject: 'Log in to Sourcebot', subject: 'Log in to Sourcebot',
html, 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); const failed = result.rejected.concat(result.pending).filter(Boolean);
@ -186,6 +190,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providers: getProviders(), providers: getProviders(),
pages: { pages: {
signIn: "/login", 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 { import {
Body, Body,
Container, Container,
Head,
Html,
Img, Img,
Link,
Preview, Preview,
Section, Section,
Tailwind, Tailwind,
Text, Text,
} from '@react-email/components'; } from '@react-email/components';
import { EmailFooter } from './emailFooter';
interface MagicLinkEmailProps { interface MagicLinkEmailProps {
magicLink: string,
baseUrl: string, baseUrl: string,
token: string,
} }
export const MagicLinkEmail = ({ export const MagicLinkEmail = ({
magicLink: url, baseUrl,
baseUrl: baseUrl, token,
}: MagicLinkEmailProps) => ( }: MagicLinkEmailProps) => (
<Html>
<Head />
<Preview>Use this code {token} to log in to Sourcebot</Preview>
<Tailwind> <Tailwind>
<Preview>Log in to Sourcebot</Preview> <Body className="bg-white font-sans m-0 p-0">
<Body className="bg-white my-auto mx-auto font-sans px-2"> <Container className="mx-auto max-w-[600px] p-6">
<Container className="border border-solid border-[#eaeaea] rounded my-[40px] mx-auto p-[20px] max-w-[465px]"> <Section className="mb-4">
<Section className="mt-[32px]">
<Img <Img
src={`${baseUrl}/sb_logo_light_large.png`} src={`${baseUrl}/sb_logo_light_large.png`}
height="60"
width="auto"
alt="Sourcebot Logo" alt="Sourcebot Logo"
className="my-0 mx-auto" width="auto"
height="40"
className="mx-0"
/> />
</Section> </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>
<Text className="text-black text-[14px] leading-[24px]"> </Section>
You can log in to your Sourcebot account by clicking the link below.
<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> </Text>
<Link </Section>
href={url}
className="text-blue-600 no-underline" <Section>
target="_blank" <Text className="text-sm text-gray-600 leading-6">
style={{ This code is only valid for the next 10 minutes. If you didn&apos;t try to log in,
display: 'block', you can safely ignore this email.
marginBottom: '16px',
}}
>
Click here to log in
</Link>
<Text className="text-black text-[14px] leading-[24px]">
If you didn&apos;t try to login, you can safely ignore this email.
</Text> </Text>
<EmailFooter /> </Section>
</Container> </Container>
</Body> </Body>
</Tailwind> </Tailwind>
) </Html>
);
MagicLinkEmail.PreviewProps = { MagicLinkEmail.PreviewProps = {
magicLink: 'https://example.com/login', token: '123456',
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
} as MagicLinkEmailProps; } as MagicLinkEmailProps;

View file

@ -222,6 +222,8 @@ export type PosthogEventMap = {
wa_mobile_unsupported_splash_screen_dismissed: {}, wa_mobile_unsupported_splash_screen_dismissed: {},
wa_mobile_unsupported_splash_screen_displayed: {}, wa_mobile_unsupported_splash_screen_displayed: {},
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
wa_login_verify_page_no_email: {},
//////////////////////////////////////////////////////////////////
wa_org_name_updated_success: {}, wa_org_name_updated_success: {},
wa_org_name_updated_fail: { wa_org_name_updated_fail: {
error: string, 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" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 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: internal-slot@^1.0.4, internal-slot@^1.0.7:
version "1.0.7" version "1.0.7"
resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz" 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-html4 "^2.0.0"
character-entities-legacy "^3.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" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==