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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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) => {
Comment thread
msukkari marked this conversation as resolved.
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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