diff --git a/packages/web/package.json b/packages/web/package.json index 9e00cb49..43c9fdab 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index ac229058..00e149ee 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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" @@ -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); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index a1cc04a8..6f30e170 100644 --- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -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 ( - + // 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. + { + if (isOpen) { + update(); + } + }}> diff --git a/packages/web/src/app/login/verify/page.tsx b/packages/web/src/app/login/verify/page.tsx index e5cae835..af4c367d 100644 --- a/packages/web/src/app/login/verify/page.tsx +++ b/packages/web/src/app/login/verify/page.tsx @@ -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 + } + + 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) => { + if (e.key === 'Enter' && value.length === 6) { + handleSubmit() + } + } return ( -
- -

Verify your email

-

- {`We've sent a magic link to your email. Please check your inbox.`} -

+
+
+
+ +
+ + + Verify your email + + Enter the 6-digit code we sent to {email} + + + + +
{ + e.preventDefault() + if (value.length === 6) { + handleSubmit() + } + }} className="space-y-6"> +
+ + + + + + + + + + + + + +
+
+
+ + + + +
+
+

+ Having trouble?{" "} + + Contact support + +

+
+
) -} \ No newline at end of file +} + diff --git a/packages/web/src/app/login/verify/verificationFailed.tsx b/packages/web/src/app/login/verify/verificationFailed.tsx new file mode 100644 index 00000000..5fd46cae --- /dev/null +++ b/packages/web/src/app/login/verify/verificationFailed.tsx @@ -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 ( +
+
+
+ +
+ +
+
+ +
+

Login verification failed

+

+ Something went wrong when trying to verify your login. Please try again. +

+
+ + +
+ + +
+ ) +} diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 54ed7c5c..326b8fd6 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -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", } }); diff --git a/packages/web/src/components/ui/input-otp.tsx b/packages/web/src/components/ui/input-otp.tsx new file mode 100644 index 00000000..f66fcfa0 --- /dev/null +++ b/packages/web/src/components/ui/input-otp.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +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 ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/packages/web/src/emails/magicLinkEmail.tsx b/packages/web/src/emails/magicLinkEmail.tsx index 2eb32f57..778b60a3 100644 --- a/packages/web/src/emails/magicLinkEmail.tsx +++ b/packages/web/src/emails/magicLinkEmail.tsx @@ -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) => ( - - Log in to Sourcebot - - -
- Sourcebot Logo -
- - Hello, - - - You can log in to your Sourcebot account by clicking the link below. - - - Click here to log in - - - If you didn't try to login, you can safely ignore this email. - - -
- -
-) + + + Use this code {token} to log in to Sourcebot + + + +
+ Sourcebot Logo +
+ +
+ + Use the code below to log in to Sourcebot. + +
+ +
+ + {token} + +
+ +
+ + This code is only valid for the next 10 minutes. If you didn't try to log in, + you can safely ignore this email. + +
+
+ +
+ +); MagicLinkEmail.PreviewProps = { - magicLink: 'https://example.com/login', + token: '123456', baseUrl: 'http://localhost:3000', } as MagicLinkEmailProps; diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index e3dccca3..b71d460a 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index f39a0887..59933ada 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==