Skip to content

Commit ff35056

Browse files
authored
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
1 parent 51186fe commit ff35056

File tree

11 files changed

+310
-68
lines changed

11 files changed

+310
-68
lines changed

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"fuse.js": "^7.0.0",
110110
"graphql": "^16.9.0",
111111
"http-status-codes": "^2.3.0",
112+
"input-otp": "^1.4.2",
112113
"lucide-react": "^0.435.0",
113114
"next": "14.2.21",
114115
"next-auth": "^5.0.0-beta.25",

packages/web/src/actions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
1212
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
1313
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
1414
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
15-
import { encrypt } from "@sourcebot/crypto"
15+
import { decrypt, encrypt } from "@sourcebot/crypto"
1616
import { getConnection } from "./data/connection";
1717
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
1818
import { cookies, headers } from "next/headers"
@@ -1407,4 +1407,12 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
14071407
}
14081408

14091409
return parsedConfig;
1410+
}
1411+
1412+
export const encryptValue = async (value: string) => {
1413+
return encrypt(value);
1414+
}
1415+
1416+
export const decryptValue = async (iv: string, encryptedValue: string) => {
1417+
return decrypt(iv, encryptedValue);
14101418
}

packages/web/src/app/[domain]/components/settingsDropdown.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
DropdownMenuTrigger,
2525
} from "@/components/ui/dropdown-menu"
2626
import { useTheme } from "next-themes"
27-
import { useMemo } from "react"
27+
import { useMemo, useState } from "react"
2828
import { KeymapType } from "@/lib/types"
2929
import { cn } from "@/lib/utils"
3030
import { useKeymapType } from "@/hooks/useKeymapType"
@@ -44,7 +44,7 @@ export const SettingsDropdown = ({
4444

4545
const { theme: _theme, setTheme } = useTheme();
4646
const [keymapType, setKeymapType] = useKeymapType();
47-
const { data: session } = useSession();
47+
const { data: session, update } = useSession();
4848

4949
const theme = useMemo(() => {
5050
return _theme ?? "light";
@@ -64,7 +64,14 @@ export const SettingsDropdown = ({
6464
}, [theme]);
6565

6666
return (
67-
<DropdownMenu>
67+
// 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
68+
// 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,
69+
// so this is a simple solution to the problem.
70+
<DropdownMenu onOpenChange={(isOpen) => {
71+
if (isOpen) {
72+
update();
73+
}
74+
}}>
6875
<DropdownMenuTrigger asChild>
6976
<Button variant="outline" size="icon" className={cn(menuButtonClassName)}>
7077
<Settings className="h-4 w-4" />

packages/web/src/app/login/components/magicLinkForm.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { signIn } from "next-auth/react";
1010
import { useState } from "react";
1111
import { Loader2 } from "lucide-react";
1212
import useCaptureEvent from "@/hooks/useCaptureEvent";
13+
import { useRouter } from "next/navigation";
1314

1415
const magicLinkSchema = z.object({
1516
email: z.string().email(),
@@ -22,18 +23,27 @@ interface MagicLinkFormProps {
2223
export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
2324
const captureEvent = useCaptureEvent();
2425
const [isLoading, setIsLoading] = useState(false);
26+
const router = useRouter();
27+
2528
const magicLinkForm = useForm<z.infer<typeof magicLinkSchema>>({
2629
resolver: zodResolver(magicLinkSchema),
2730
defaultValues: {
2831
email: "",
2932
},
3033
});
3134

32-
const onSignIn = (values: z.infer<typeof magicLinkSchema>) => {
35+
const onSignIn = async (values: z.infer<typeof magicLinkSchema>) => {
3336
setIsLoading(true);
3437
captureEvent("wa_login_with_magic_link", {});
35-
signIn("nodemailer", { email: values.email, redirectTo: callbackUrl ?? "/" })
36-
.finally(() => {
38+
39+
signIn("nodemailer", { email: values.email, redirect: false, redirectTo: callbackUrl ?? "/" })
40+
.then(() => {
41+
setIsLoading(false);
42+
43+
router.push("/login/verify?email=" + encodeURIComponent(values.email));
44+
})
45+
.catch((error) => {
46+
console.error("Error signing in", error);
3747
setIsLoading(false);
3848
});
3949
}
@@ -66,7 +76,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => {
6676
disabled={isLoading}
6777
>
6878
{isLoading ? <Loader2 className="animate-spin mr-2" /> : ""}
69-
Sign in with magic link
79+
Sign in with login code
7080
</Button>
7181
</form>
7282
</Form>
Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,99 @@
1-
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
1+
"use client"
2+
3+
import { InputOTPSeparator } from "@/components/ui/input-otp"
4+
import { InputOTPGroup } from "@/components/ui/input-otp"
5+
import { InputOTPSlot } from "@/components/ui/input-otp"
6+
import { InputOTP } from "@/components/ui/input-otp"
7+
import { Card, CardHeader, CardDescription, CardTitle, CardContent, CardFooter } from "@/components/ui/card"
8+
import { Button } from "@/components/ui/button"
9+
import { ArrowLeft } from "lucide-react"
10+
import { useRouter, useSearchParams } from "next/navigation"
11+
import { useCallback, useState } from "react"
12+
import VerificationFailed from "./verificationFailed"
13+
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
14+
import useCaptureEvent from "@/hooks/useCaptureEvent"
215

316
export default function VerifyPage() {
17+
const [value, setValue] = useState("")
18+
const searchParams = useSearchParams()
19+
const email = searchParams.get("email")
20+
const router = useRouter()
21+
const captureEvent = useCaptureEvent();
22+
23+
if (!email) {
24+
captureEvent("wa_login_verify_page_no_email", {})
25+
return <VerificationFailed />
26+
}
27+
28+
const handleSubmit = useCallback(async () => {
29+
const url = new URL("/api/auth/callback/nodemailer", window.location.origin)
30+
url.searchParams.set("token", value)
31+
url.searchParams.set("email", email)
32+
router.push(url.toString())
33+
}, [value])
34+
35+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
36+
if (e.key === 'Enter' && value.length === 6) {
37+
handleSubmit()
38+
}
39+
}
440

541
return (
6-
<div className="flex flex-col items-center p-12 h-screen">
7-
<SourcebotLogo
8-
className="mb-2 h-16"
9-
size="small"
10-
/>
11-
<h1 className="text-2xl font-bold mb-2">Verify your email</h1>
12-
<p className="text-sm text-muted-foreground">
13-
{`We've sent a magic link to your email. Please check your inbox.`}
14-
</p>
42+
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-b from-background to-muted/30">
43+
<div className="w-full max-w-md">
44+
<div className="flex justify-center mb-6">
45+
<SourcebotLogo className="h-16" size="large" />
46+
</div>
47+
<Card className="w-full shadow-lg border-muted/40">
48+
<CardHeader className="space-y-1">
49+
<CardTitle className="text-2xl font-bold text-center">Verify your email</CardTitle>
50+
<CardDescription className="text-center">
51+
Enter the 6-digit code we sent to <span className="font-semibold text-primary">{email}</span>
52+
</CardDescription>
53+
</CardHeader>
54+
55+
<CardContent>
56+
<form onSubmit={(e) => {
57+
e.preventDefault()
58+
if (value.length === 6) {
59+
handleSubmit()
60+
}
61+
}} className="space-y-6">
62+
<div className="flex justify-center py-4">
63+
<InputOTP maxLength={6} value={value} onChange={setValue} onKeyDown={handleKeyDown} className="gap-2">
64+
<InputOTPGroup>
65+
<InputOTPSlot index={0} className="rounded-md border-input" />
66+
<InputOTPSlot index={1} className="rounded-md border-input" />
67+
<InputOTPSlot index={2} className="rounded-md border-input" />
68+
</InputOTPGroup>
69+
<InputOTPSeparator />
70+
<InputOTPGroup>
71+
<InputOTPSlot index={3} className="rounded-md border-input" />
72+
<InputOTPSlot index={4} className="rounded-md border-input" />
73+
<InputOTPSlot index={5} className="rounded-md border-input" />
74+
</InputOTPGroup>
75+
</InputOTP>
76+
</div>
77+
</form>
78+
</CardContent>
79+
80+
<CardFooter className="flex flex-col space-y-4 pt-0">
81+
<Button variant="ghost" className="w-full text-sm" size="sm" onClick={() => window.history.back()}>
82+
<ArrowLeft className="mr-2 h-4 w-4" />
83+
Back to login
84+
</Button>
85+
</CardFooter>
86+
</Card>
87+
<div className="mt-8 text-center text-sm text-muted-foreground">
88+
<p>
89+
Having trouble?{" "}
90+
<a href="mailto:team@sourcebot.dev" className="text-primary hover:underline">
91+
Contact support
92+
</a>
93+
</p>
94+
</div>
95+
</div>
1596
</div>
1697
)
17-
}
98+
}
99+
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client"
2+
3+
import { Button } from "@/components/ui/button"
4+
import { AlertCircle } from "lucide-react"
5+
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
6+
import { useRouter } from "next/navigation"
7+
8+
export default function VerificationFailed() {
9+
const router = useRouter()
10+
11+
return (
12+
<div className="flex min-h-screen flex-col items-center justify-center bg-[#111318] text-white">
13+
<div className="w-full max-w-md rounded-lg bg-[#1A1D24] p-8 shadow-lg">
14+
<div className="mb-6 flex justify-center">
15+
<SourcebotLogo />
16+
</div>
17+
18+
<div className="mb-6 text-center">
19+
<div className="mb-4 flex justify-center">
20+
<AlertCircle className="h-10 w-10 text-red-500" />
21+
</div>
22+
<p className="mb-2 text-center text-lg font-medium">Login verification failed</p>
23+
<p className="text-center text-sm text-gray-400">
24+
Something went wrong when trying to verify your login. Please try again.
25+
</p>
26+
</div>
27+
28+
<Button onClick={() => router.push("/login")} className="w-full bg-purple-600 hover:bg-purple-700">
29+
Return to login
30+
</Button>
31+
</div>
32+
33+
<div className="mt-8 flex gap-6 text-sm text-gray-500">
34+
<a href="https://www.sourcebot.dev" className="hover:text-gray-300">
35+
About
36+
</a>
37+
<a href="mailto:team@sourcebot.dev" className="hover:text-gray-300">
38+
Contact Us
39+
</a>
40+
</div>
41+
</div>
42+
)
43+
}

packages/web/src/auth.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,19 @@ export const getProviders = () => {
6363
server: SMTP_CONNECTION_URL,
6464
from: EMAIL_FROM,
6565
maxAge: 60 * 10,
66-
sendVerificationRequest: async ({ identifier, url, provider }) => {
66+
generateVerificationToken: async () => {
67+
const token = String(Math.floor(100000 + Math.random() * 900000));
68+
return token;
69+
},
70+
sendVerificationRequest: async ({ identifier, provider, token }) => {
6771
const transport = createTransport(provider.server);
68-
const html = await render(MagicLinkEmail({ magicLink: url, baseUrl: AUTH_URL }));
72+
const html = await render(MagicLinkEmail({ baseUrl: AUTH_URL, token: token }));
6973
const result = await transport.sendMail({
7074
to: identifier,
7175
from: provider.from,
7276
subject: 'Log in to Sourcebot',
7377
html,
74-
text: `Log in to Sourcebot by clicking here: ${url}`
78+
text: `Log in to Sourcebot using this code: ${token}`
7579
});
7680

7781
const failed = result.rejected.concat(result.pending).filter(Boolean);
@@ -186,6 +190,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
186190
providers: getProviders(),
187191
pages: {
188192
signIn: "/login",
189-
verifyRequest: "/login/verify",
193+
// We set redirect to false in signInOptions so we can pass the email is as a param
194+
// verifyRequest: "/login/verify",
190195
}
191196
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { OTPInput, OTPInputContext } from "input-otp"
5+
import { Dot } from "lucide-react"
6+
7+
import { cn } from "@/lib/utils"
8+
9+
const InputOTP = React.forwardRef<
10+
React.ElementRef<typeof OTPInput>,
11+
React.ComponentPropsWithoutRef<typeof OTPInput>
12+
>(({ className, containerClassName, ...props }, ref) => (
13+
<OTPInput
14+
ref={ref}
15+
containerClassName={cn(
16+
"flex items-center gap-2 has-[:disabled]:opacity-50",
17+
containerClassName
18+
)}
19+
className={cn("disabled:cursor-not-allowed", className)}
20+
{...props}
21+
/>
22+
))
23+
InputOTP.displayName = "InputOTP"
24+
25+
const InputOTPGroup = React.forwardRef<
26+
React.ElementRef<"div">,
27+
React.ComponentPropsWithoutRef<"div">
28+
>(({ className, ...props }, ref) => (
29+
<div ref={ref} className={cn("flex items-center", className)} {...props} />
30+
))
31+
InputOTPGroup.displayName = "InputOTPGroup"
32+
33+
const InputOTPSlot = React.forwardRef<
34+
React.ElementRef<"div">,
35+
React.ComponentPropsWithoutRef<"div"> & { index: number }
36+
>(({ index, className, ...props }, ref) => {
37+
const inputOTPContext = React.useContext(OTPInputContext)
38+
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
39+
40+
return (
41+
<div
42+
ref={ref}
43+
className={cn(
44+
"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",
45+
isActive && "z-10 ring-2 ring-ring ring-offset-background",
46+
className
47+
)}
48+
{...props}
49+
>
50+
{char}
51+
{hasFakeCaret && (
52+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
53+
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
54+
</div>
55+
)}
56+
</div>
57+
)
58+
})
59+
InputOTPSlot.displayName = "InputOTPSlot"
60+
61+
const InputOTPSeparator = React.forwardRef<
62+
React.ElementRef<"div">,
63+
React.ComponentPropsWithoutRef<"div">
64+
>(({ ...props }, ref) => (
65+
<div ref={ref} role="separator" {...props}>
66+
<Dot />
67+
</div>
68+
))
69+
InputOTPSeparator.displayName = "InputOTPSeparator"
70+
71+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

0 commit comments

Comments
 (0)