Skip to content

switch magic link to invite code #222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1407,4 +1407,12 @@ 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);
}
13 changes: 10 additions & 3 deletions packages/web/src/app/[domain]/components/settingsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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";
Expand All @@ -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" />
Expand Down
18 changes: 14 additions & 4 deletions packages/web/src/app/login/components/magicLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -22,18 +23,27 @@ 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: {
email: "",
},
});

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);
});
}
Expand Down Expand Up @@ -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>
Expand Down
104 changes: 93 additions & 11 deletions packages/web/src/app/login/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -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.`}
</p>
<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>
)
}
}

43 changes: 43 additions & 0 deletions packages/web/src/app/login/verify/verificationFailed.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
13 changes: 9 additions & 4 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
}
});
71 changes: 71 additions & 0 deletions packages/web/src/components/ui/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -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 }
Loading