Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
18,297 changes: 18,297 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
"fuse.js": "^7.0.0",
"graphql": "^16.9.0",
"http-status-codes": "^2.3.0",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lucide-react": "^0.435.0",
"next": "14.2.21",
"next-auth": "^5.0.0-beta.25",
Expand All @@ -135,6 +137,7 @@
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/nodemailer": "^6.4.17",
"@types/psl": "^1.1.3",
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 @@ -1361,4 +1361,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);
}
10 changes: 7 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,11 @@ export const SettingsDropdown = ({
}, [theme]);

return (
<DropdownMenu>
<DropdownMenu onOpenChange={(isOpen) => {
if (isOpen) {
update();
}
}}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
<Settings className="h-4 w-4" />
Expand Down
17 changes: 16 additions & 1 deletion packages/web/src/app/login/components/magicLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import { signIn } from "next-auth/react";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useSearchParams, useRouter } from "next/navigation";
import Cookies from "js-cookie";
import { encryptValue } from "@/actions";

export const MAGIC_LINK_ONBOARDING_COOKIE_NAME = "magic_link_onboarding_params"
const MAGIC_LINK_ONBOARDING_COOKIE_EXPIRATION_DAYS = 7


const magicLinkSchema = z.object({
email: z.string().email(),
Expand All @@ -21,19 +28,27 @@ interface MagicLinkFormProps {

export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
const captureEvent = useCaptureEvent();
const currentSearchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);

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", {});

const { iv: encryptedIv, encryptedData: encryptedEmail } = await encryptValue(values.email);
Cookies.set(MAGIC_LINK_ONBOARDING_COOKIE_NAME, `${encryptedIv}:${encryptedEmail}`, { expires: MAGIC_LINK_ONBOARDING_COOKIE_EXPIRATION_DAYS});

signIn("nodemailer", { email: values.email, redirectTo: callbackUrl ?? "/" })
.finally(() => {
Cookies.remove(MAGIC_LINK_ONBOARDING_COOKIE_NAME);
setIsLoading(false);
});
}
Expand Down
109 changes: 98 additions & 11 deletions packages/web/src/app/login/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,104 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
"use client"

import type React from "react"

import { useCallback, useState } from "react"
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Loader2, ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import Cookies from "js-cookie";
import { MAGIC_LINK_ONBOARDING_COOKIE_NAME } from "../components/magicLinkForm";
import { decryptValue } from "@/actions"

export default function VerifyPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [value, setValue] = useState("")
const router = useRouter()

const handleSubmit = useCallback(async () => {
setIsSubmitting(true)

const magicLinkOnboardingParams = Cookies.get(MAGIC_LINK_ONBOARDING_COOKIE_NAME);
if (!magicLinkOnboardingParams) {
throw new Error("No magic link onboarding params found")
}

const [encryptedIv, encryptedEmail] = magicLinkOnboardingParams.split(":");
const email = await decryptValue(encryptedIv, encryptedEmail);

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 your email address
</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:[email protected]" className="text-primary hover:underline">
Contact support
</a>
</p>
</div>
</div>
</div>
)
}
}

10 changes: 7 additions & 3 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
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