Merge pull request #1008 from khoj-ai/features/new-sign-in-page
Some checks are pending
dockerize / Publish Khoj Docker Images (push) Waiting to run
dockerize / manifest (push) Blocked by required conditions
build and deploy github pages for documentation / deploy (push) Waiting to run
pre-commit / Setup Application and Lint (push) Waiting to run
pypi / Publish Python Package to PyPI (push) Waiting to run
test / Run Tests (push) Waiting to run

- Make it easier to quickly create the account without losing track of where you are
- Show some capabilities before you sign on
This commit is contained in:
sabaimran 2024-12-16 17:54:43 -08:00 committed by GitHub
commit 6f3218f487
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1332 additions and 109 deletions

View file

@ -49,7 +49,7 @@ jobs:
- name: 📂 Copy Generated Files
run: |
mkdir -p src/khoj/interface/compiled
cp -r /opt/hostedtoolcache/Python/3.11.10/x64/lib/python3.11/site-packages/khoj/interface/compiled/* src/khoj/interface/compiled/
cp -r /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/khoj/interface/compiled/* src/khoj/interface/compiled/
- name: ⚙️ Build Python Package
run: |

View file

@ -5,7 +5,8 @@ import { ContentSecurityPolicy } from "../common/layoutHelper";
export const metadata: Metadata = {
title: "Khoj AI - Agents",
description: "Find a specialized agent that can help you address more specific needs.",
description:
"Find or create agents with custom knowledge, tools and personalities to help address your specific needs.",
icons: {
icon: "/static/assets/icons/khoj_lantern.ico",
apple: "/static/assets/icons/khoj_lantern_256x256.png",
@ -13,10 +14,16 @@ export const metadata: Metadata = {
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI - Agents",
description: "Your Second Brain.",
description:
"Find or create agents with custom knowledge, tools and personalities to help address your specific needs.",
url: "https://app.khoj.dev/agents",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,

View file

@ -143,6 +143,7 @@ function CreateAgentCard(props: CreateAgentCardProps) {
<LoginPrompt
loginRedirectMessage="Sign in to start chatting with a specialized agent"
onOpenChange={setShowLoginPrompt}
isMobileWidth={props.isMobileWidth}
/>
)}
<AgentModificationForm
@ -317,6 +318,7 @@ export default function Agents() {
<LoginPrompt
loginRedirectMessage="Sign in to start chatting with a specialized agent"
onOpenChange={setShowLoginPrompt}
isMobileWidth={isMobileWidth}
/>
)}
<Alert className="bg-secondary border-none my-4">

View file

@ -6,7 +6,8 @@ import { ContentSecurityPolicy } from "../common/layoutHelper";
export const metadata: Metadata = {
title: "Khoj AI - Automations",
description: "Use Autoomations with Khoj to simplify the process of running repetitive tasks.",
description:
"Use Khoj Automations to get tailored research and event based notifications directly in your inbox.",
icons: {
icon: "/static/assets/icons/khoj_lantern.ico",
apple: "/static/assets/icons/khoj_lantern_256x256.png",
@ -14,10 +15,16 @@ export const metadata: Metadata = {
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI - Automations",
description: "Your Second Brain.",
description:
"Use Khoj Automations to get tailored research and event based notifications directly in your inbox.",
url: "https://app.khoj.dev/automations",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,

View file

@ -1064,6 +1064,7 @@ export default function Automations() {
<LoginPrompt
loginRedirectMessage={"Create an account to make your own automation"}
onOpenChange={setShowLoginPrompt}
isMobileWidth={isMobileWidth}
/>
)}
<Alert className="bg-secondary border-none my-4">

View file

@ -6,7 +6,7 @@ import { ContentSecurityPolicy } from "../common/layoutHelper";
export const metadata: Metadata = {
title: "Khoj AI - Chat",
description:
"Ask anything. Khoj will use the internet and your docs to answer, paint and even automate stuff for you.",
"Ask anything. Research answers from across the internet and your documents, draft messages, summarize documents, generate paintings and chat with personal agents.",
icons: {
icon: "/static/assets/icons/khoj_lantern.ico",
apple: "/static/assets/icons/khoj_lantern_256x256.png",
@ -14,10 +14,16 @@ export const metadata: Metadata = {
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI - Chat",
description: "Your Second Brain.",
description:
"Ask anything. Research answers from across the internet and your documents, draft messages, summarize documents, generate paintings and chat with personal agents.",
url: "https://app.khoj.dev/chat",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,

View file

@ -4,11 +4,12 @@ export function ContentSecurityPolicy() {
httpEquiv="Content-Security-Policy"
content="default-src 'self' https://assets.khoj.dev;
media-src * blob:;
script-src 'self' https://assets.khoj.dev https://app.chatwoot.com 'unsafe-inline' 'unsafe-eval';
connect-src 'self' blob: https://ipapi.co/json ws://localhost:42110;
style-src 'self' https://assets.khoj.dev 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https://*.khoj.dev https://*.googleusercontent.com https://*.google.com/ https://*.gstatic.com;
script-src 'self' https://assets.khoj.dev https://app.chatwoot.com https://accounts.google.com 'unsafe-inline' 'unsafe-eval';
connect-src 'self' blob: https://ipapi.co/json ws://localhost:42110 https://accounts.google.com;
style-src 'self' https://assets.khoj.dev 'unsafe-inline' https://fonts.googleapis.com https://accounts.google.com;
img-src 'self' data: blob: https://*.khoj.dev https://accounts.google.com https://*.googleusercontent.com https://*.google.com/ https://*.gstatic.com;
font-src 'self' https://assets.khoj.dev https://fonts.gstatic.com;
frame-src 'self' https://accounts.google.com;
child-src 'self' https://app.chatwoot.com;
object-src 'none';"
></meta>

View file

@ -328,6 +328,7 @@ export function AgentCard(props: AgentCardProps) {
<LoginPrompt
loginRedirectMessage={`Sign in to start chatting with ${props.data.name}`}
onOpenChange={setShowLoginPrompt}
isMobileWidth={props.isMobileWidth}
/>
)}
<CardHeader>

View file

@ -408,6 +408,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
{showLoginPrompt && loginRedirectMessage && (
<LoginPrompt
onOpenChange={setShowLoginPrompt}
isMobileWidth={props.isMobileWidth}
loginRedirectMessage={loginRedirectMessage}
/>
)}
@ -628,7 +629,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
<Button
variant={"ghost"}
className="!bg-none p-0 m-2 h-auto text-3xl rounded-full text-gray-300 hover:text-gray-500"
disabled={props.sendDisabled}
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={handleFileButtonClick}
>
<Paperclip className="w-8 h-8" />
@ -668,7 +669,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
onClick={() => {
setRecording(!recording);
}}
disabled={props.sendDisabled}
disabled={props.sendDisabled || !props.isLoggedIn}
>
<Stop weight="fill" className="w-6 h-6" />
</Button>
@ -698,6 +699,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
<Button
variant="default"
className={`${!message || recording || "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={() => {
setMessage("Listening...");
setRecording(!recording);
@ -717,6 +719,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
)}
<Button
className={`${(!message || recording) && "hidden"} ${props.agentColor ? convertToBGClass(props.agentColor) : "bg-orange-300 hover:bg-orange-500"} rounded-full p-1 m-2 h-auto text-3xl transition transform md:hover:-translate-y-1`}
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={onSendMessage}
>
<ArrowUp className="w-6 h-6" weight="bold" />
@ -729,6 +732,7 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
<Button
variant="ghost"
className="float-right justify-center gap-1 flex items-center p-1.5 mr-2 h-fit"
disabled={props.sendDisabled || !props.isLoggedIn}
onClick={() => {
setUseResearchMode(!useResearchMode);
chatInputRef?.current?.focus();

View file

@ -0,0 +1,20 @@
// GoogleSignIn.tsx
"use client";
import Script from "next/script";
interface GoogleSignInProps {
onLoad?: () => void;
}
export function GoogleSignIn({ onLoad }: GoogleSignInProps) {
return (
<Script
id="google-signin"
src="https://accounts.google.com/gsi/client"
async
defer
onLoad={onLoad}
/>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader } from "@/components/ui/card";
import { KhojLogo } from "../logo/khojLogo";
import { Drawer, DrawerContent } from "@/components/ui/drawer";
export interface LoginPopupProps {
isMobileWidth?: boolean;
setShowLoginPrompt: (show: boolean) => void;
}
export default function LoginPopup(props: LoginPopupProps) {
if (props.isMobileWidth) {
return (
<Drawer open={true} onClose={() => props.setShowLoginPrompt(false)}>
<DrawerContent>
<PopUpContent {...props} />
</DrawerContent>
</Drawer>
);
}
return (
<div className="fixed inset-x-0 bottom-8 z-30 flex items-center justify-center">
<PopUpContent {...props} />
</div>
);
}
function PopUpContent(props: LoginPopupProps) {
return (
<Card className="rounded-lg p-6 flex flex-col items-center justify-between gap-8 md:w-fit border-none md:flex-row md:z-30 md:shadow-lg">
{!props.isMobileWidth && <KhojLogo className="w-12 h-auto" />}
<div className="flex flex-col items-start justify-center gap-8 md:gap-2">
<CardHeader className="p-0 text-xl font-bold">Welcome to Khoj!</CardHeader>
<CardDescription>
Sign in to get started with Khoj, your AI-powered knowledge assistant.
</CardDescription>
</div>
<Button
variant={"default"}
className="p-6 text-lg"
onClick={() => props.setShowLoginPrompt(true)}
>
Get started for free
</Button>
</Card>
);
}

View file

@ -0,0 +1,114 @@
.gsiMaterialButton {
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-appearance: none;
background-image: none;
/* border: 1px solid #747775; */
-webkit-border-radius: 20px;
border-radius: 20px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #1f1f1f;
cursor: pointer;
font-family: "Roboto", arial, sans-serif;
font-size: 14px;
height: 40px;
letter-spacing: 0.25px;
outline: none;
overflow: hidden;
padding: 0;
position: relative;
text-align: center;
-webkit-transition:
background-color 0.218s,
border-color 0.218s,
box-shadow 0.218s;
transition:
background-color 0.218s,
border-color 0.218s,
box-shadow 0.218s;
vertical-align: middle;
white-space: nowrap;
width: 40px;
max-width: 400px;
min-width: min-content;
}
.gsiMaterialButton .gsiMaterialButtonIcon {
height: 100%;
margin-right: 12px;
min-width: 20px;
width: 100%;
margin: 0;
padding: 9px;
}
.gsiMaterialButton .gsiMaterialButtonContentWrapper {
-webkit-align-items: center;
align-items: center;
display: flex;
-webkit-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: nowrap;
flex-wrap: nowrap;
height: 100%;
justify-content: space-between;
position: relative;
width: 100%;
}
.gsiMaterialButton .gsiMaterialButtonContents {
-webkit-flex-grow: 1;
flex-grow: 1;
font-family: "Roboto", arial, sans-serif;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.gsiMaterialButton .gsiMaterialButtonState {
-webkit-transition: opacity 0.218s;
transition: opacity 0.218s;
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
}
.gsiMaterialButton:disabled {
cursor: default;
background-color: #ffffff61;
border-color: #1f1f1f1f;
}
.gsiMaterialButton:disabled .gsiMaterialButtonContents {
opacity: 38%;
}
.gsiMaterialButton:disabled .gsiMaterialButtonIcon {
opacity: 38%;
}
.gsiMaterialButton:not(:disabled):active .gsiMaterialButton-State,
.gsiMaterialButton:not(:disabled):focus .gsiMaterialButtonState {
background-color: #303030;
opacity: 12%;
}
.gsiMaterialButton:not(:disabled):hover {
-webkit-box-shadow:
0 1px 2px 0 rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
box-shadow:
0 1px 2px 0 rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.gsiMaterialButton:not(:disabled):hover .gsiMaterialButtonState {
background-color: #303030;
opacity: 8%;
}

View file

@ -1,47 +1,540 @@
"use client";
import styles from "./loginPrompt.module.css";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import Autoplay from "embla-carousel-autoplay";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
ArrowLeft,
ArrowsClockwise,
LineVertical,
PaperPlaneTilt,
PencilSimple,
Spinner,
} from "@phosphor-icons/react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { GoogleSignIn } from "./GoogleSignIn";
import { Drawer, DrawerContent } from "@/components/ui/drawer";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
export interface LoginPromptProps {
loginRedirectMessage: string;
onOpenChange: (open: boolean) => void;
isMobileWidth?: boolean;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const ALLOWED_OTP_ATTEMPTS = 5;
interface Provider {
client_id: string;
redirect_uri: string;
}
interface CredentialsData {
[provider: string]: Provider;
}
export default function LoginPrompt(props: LoginPromptProps) {
const { data, error, isLoading } = useSWR<CredentialsData>("/auth/oauth/metadata", fetcher);
const [useEmailSignIn, setUseEmailSignIn] = useState(false);
const [email, setEmail] = useState("");
const [checkEmail, setCheckEmail] = useState(false);
const [recheckEmail, setRecheckEmail] = useState(false);
useEffect(() => {
const google = (window as any).google;
if (!google) return;
// Initialize Google Sign In after script loads
google.accounts.id.initialize({
client_id: data?.google?.client_id,
callback: handleGoogleSignIn,
auto_select: false,
login_uri: data?.google?.redirect_uri,
});
// Render the button
google.accounts.id.renderButton(document.getElementById("g_id_signin")!, {
theme: "outline",
size: "large",
width: "100%",
});
}, [data]);
const handleGoogleSignIn = () => {
if (!data?.google?.client_id || !data?.google?.redirect_uri) return;
// Create full redirect URL using current origin
const fullRedirectUri = `${window.location.origin}${data.google.redirect_uri}`;
const params = new URLSearchParams({
client_id: data.google.client_id,
redirect_uri: fullRedirectUri,
response_type: "code",
scope: "email profile openid",
state: window.location.pathname,
access_type: "offline",
prompt: "consent select_account",
include_granted_scopes: "true",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
};
const handleGoogleScriptLoad = () => {
const google = (window as any).google;
if (!data?.google?.client_id || !data?.google?.redirect_uri) return;
// Initialize Google Sign In after script loads
google.accounts.id.initialize({
client_id: data?.google?.client_id,
callback: handleGoogleSignIn,
auto_select: false,
login_uri: data?.google?.redirect_uri,
});
// Render the button
google.accounts.id.renderButton(document.getElementById("g_id_signin")!, {
theme: "outline",
size: "large",
width: "100%",
});
};
function handleMagicLinkSignIn() {
fetch("/auth/magic", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email }),
})
.then((res) => {
if (res.ok) {
setCheckEmail(true);
if (checkEmail) {
setRecheckEmail(true);
}
return res.json();
} else {
throw new Error("Failed to send magic link");
}
})
.then((data) => {
console.log(data);
})
.catch((err) => {
console.error(err);
});
}
if (props.isMobileWidth) {
return (
<AlertDialog open={true} onOpenChange={props.onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign in to Khoj to continue</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{props.loginRedirectMessage}. By logging in, you agree to our{" "}
<Link href="https://khoj.dev/terms-of-service">Terms of Service.</Link>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Dismiss</AlertDialogCancel>
<AlertDialogAction
className="bg-slate-400 hover:bg-slate-500"
onClick={() => {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
}}
>
<Link href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
{" "}
{/* Redirect to login page */}
Login
</Link>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Drawer open={true} onOpenChange={props.onOpenChange}>
<DrawerContent className={`flex flex-col gap-4 w-full mb-4`}>
<div>
{useEmailSignIn ? (
<EmailSignInContext
email={email}
setEmail={setEmail}
checkEmail={checkEmail}
setCheckEmail={setCheckEmail}
setUseEmailSignIn={setUseEmailSignIn}
recheckEmail={recheckEmail}
setRecheckEmail={setRecheckEmail}
handleMagicLinkSignIn={handleMagicLinkSignIn}
/>
) : (
<MainSignInContext
handleGoogleScriptLoad={handleGoogleScriptLoad}
handleGoogleSignIn={handleGoogleSignIn}
isLoading={isLoading}
data={data}
setUseEmailSignIn={setUseEmailSignIn}
isMobileWidth={props.isMobileWidth}
/>
)}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={true} onOpenChange={props.onOpenChange}>
<DialogContent
className={`flex flex-col gap-4 ${!useEmailSignIn ? "p-0 pb-4 m-0 max-w-xl" : "w-fit"}`}
>
<div>
{useEmailSignIn ? (
<EmailSignInContext
email={email}
setEmail={setEmail}
checkEmail={checkEmail}
setCheckEmail={setCheckEmail}
setUseEmailSignIn={setUseEmailSignIn}
recheckEmail={recheckEmail}
setRecheckEmail={setRecheckEmail}
handleMagicLinkSignIn={handleMagicLinkSignIn}
/>
) : (
<MainSignInContext
handleGoogleScriptLoad={handleGoogleScriptLoad}
handleGoogleSignIn={handleGoogleSignIn}
isLoading={isLoading}
data={data}
setUseEmailSignIn={setUseEmailSignIn}
isMobileWidth={props.isMobileWidth ?? false}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmailSignInContext({
email,
setEmail,
checkEmail,
setCheckEmail,
setUseEmailSignIn,
recheckEmail,
handleMagicLinkSignIn,
}: {
email: string;
setEmail: (email: string) => void;
checkEmail: boolean;
setCheckEmail: (checkEmail: boolean) => void;
setUseEmailSignIn: (useEmailSignIn: boolean) => void;
recheckEmail: boolean;
setRecheckEmail: (recheckEmail: boolean) => void;
handleMagicLinkSignIn: () => void;
}) {
const [otp, setOTP] = useState("");
const [otpError, setOTPError] = useState("");
const [numFailures, setNumFailures] = useState(0);
function checkOTPAndRedirect() {
const verifyUrl = `/auth/magic?code=${otp}&email=${email}`;
if (numFailures >= ALLOWED_OTP_ATTEMPTS) {
setOTPError("Too many failed attempts. Please try again tomorrow.");
return;
}
fetch(verifyUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => {
if (res.ok) {
// Check if the response is a redirect
if (res.redirected) {
window.location.href = res.url;
}
} else if (res.status === 401) {
setOTPError("Invalid OTP.");
setNumFailures(numFailures + 1);
if (numFailures + 1 >= ALLOWED_OTP_ATTEMPTS) {
setOTPError("Too many failed attempts. Please try again tomorrow.");
}
} else if (res.status === 429) {
setOTPError("Too many failed attempts. Please try again tomorrow.");
setNumFailures(ALLOWED_OTP_ATTEMPTS);
} else if (res.status === 403) {
setOTPError("OTP expired. Please request a new one.");
} else {
throw new Error("Failed to verify OTP");
}
})
.catch((err) => {
console.error(err);
});
}
return (
<div className="flex flex-col gap-4 p-4">
<Button
variant="ghost"
className="w-fit p-1 m-0 flex gap-2 items-center justify-center text-sm absolute top-5 left-5 h-fit rounded-full border border-gray-200"
onClick={() => {
setUseEmailSignIn(false);
}}
>
<ArrowLeft className="h-6 w-6" />
</Button>
<div>
<div className="text-center font-bold text-xl">Get started with Khoj</div>
</div>
<div className="text-center text-sm text-muted-foreground">
{checkEmail
? recheckEmail
? `A new link has been sent to ${email}.`
: `A one time sign in code has been sent to ${email}.`
: "You will receive a sign-in code on the email address you provide below"}
</div>
{!checkEmail && (
<>
<Input
placeholder="Email"
className="p-6 w-[300px] mx-auto rounded-lg"
disabled={checkEmail}
value={email}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleMagicLinkSignIn();
}
}}
onChange={(e) => setEmail(e.target.value)}
/>
<Button
variant="default"
className="p-6 w-[300px] mx-auto flex gap-2 items-center justify-center rounded-lg"
onClick={handleMagicLinkSignIn}
disabled={checkEmail}
>
<PaperPlaneTilt className="h-6 w-6 mr-2 font-bold" />
{checkEmail ? "Check your email" : "Send sign in code"}
</Button>
</>
)}
{checkEmail && (
<div className="flex items-center justify-center gap-4 text-muted-foreground flex-col">
<InputOTP
autoFocus={true}
maxLength={6}
value={otp || ""}
onChange={setOTP}
disabled={numFailures >= ALLOWED_OTP_ATTEMPTS}
onComplete={() =>
setTimeout(() => {
checkOTPAndRedirect();
}, 1000)
}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{otpError && (
<div className="text-red-500 text-sm">
{otpError} {ALLOWED_OTP_ATTEMPTS - numFailures} remaining attempts.
</div>
)}
</div>
)}
{checkEmail && (
<div className="flex items-center justify-center gap-4 text-muted-foreground flex-col md:flex-row">
<Button
variant="ghost"
className="p-0 text-orange-500"
disabled={recheckEmail}
onClick={() => {
handleMagicLinkSignIn();
}}
>
<ArrowsClockwise className="h-6 w-6 mr-2 text-gray-500" />
Resend email
</Button>
<LineVertical className="h-6 w-6 hidden md:block opacity-50" />
<Button
variant="ghost"
className="p-0 text-orange-500"
disabled={recheckEmail}
onClick={() => {
setEmail("");
setCheckEmail(false);
}}
>
<PencilSimple className="h-6 w-6 mr-2 text-gray-500" />
Use a different email
</Button>
</div>
)}
</div>
);
}
function MainSignInContext({
handleGoogleScriptLoad,
handleGoogleSignIn,
isLoading,
data,
setUseEmailSignIn,
isMobileWidth,
}: {
handleGoogleScriptLoad: () => void;
handleGoogleSignIn: () => void;
isLoading: boolean;
data: CredentialsData | undefined;
setUseEmailSignIn: (useEmailSignIn: boolean) => void;
isMobileWidth: boolean;
}) {
const plugin = useRef(Autoplay({ delay: 4000, stopOnInteraction: true }));
const [showArrows, setShowArrows] = useState(false);
const tips = [
{
src: "https://assets.khoj.dev/sign_in_demos/research_mode.gif",
alt: "Research tip",
description: "Simplify Deep Work",
},
{
src: "https://assets.khoj.dev/sign_in_demos/custom_agents.gif",
alt: "Personalize tip",
description: "Personalize your AI",
},
{
src: "https://assets.khoj.dev/sign_in_demos/docment_questions.gif",
alt: "Document tip",
description: "Ask Anything",
},
];
return (
<div className="flex flex-col gap-4 p-4 md:p-0">
{!isMobileWidth && (
<Carousel
plugins={[plugin.current]}
className="w-full"
onMouseEnter={() => {
plugin.current.stop();
setShowArrows(true);
}}
onMouseLeave={() => {
plugin.current.play();
setShowArrows(false);
}}
>
<CarouselContent>
{tips.map((tip, index) => (
<CarouselItem key={index}>
<div className="relative p-0">
<Card>
<CardContent className="flex flex-col items-center justify-center rounded-b-none rounded-t-lg p-0">
<img
src={tip.src}
alt={tip.alt}
className="w-full h-auto rounded-b-none rounded-t-lg"
/>
<div className="absolute bottom-0 flex items-center justify-center text-white bg-gradient-to-t from-black to-transparent text-center w-full p-4 py-6">
{tip.description}
</div>
</CardContent>
</Card>
</div>
</CarouselItem>
))}
</CarouselContent>
{showArrows && (
<>
<CarouselPrevious className="absolute left-0" />
<CarouselNext className="absolute right-0" />
</>
)}
</Carousel>
)}
<div className="flex flex-col gap-4 text-center p-2">
<div className="text-center font-bold text-xl">
Sign in to unlock your second brain
</div>
</div>
<div className="flex flex-col gap-8 pb-4 text-center align-middle items-center">
<GoogleSignIn onLoad={handleGoogleScriptLoad} />
{/* <div id="g_id_signin" /> */}
<Button
variant="outline"
className="w-[300px] p-8 flex gap-2 items-center justify-center rounded-lg font-bold"
onClick={handleGoogleSignIn}
disabled={
isLoading ||
!data?.google ||
!data?.google.client_id ||
!data?.google.redirect_uri
}
>
{isLoading ? (
<Spinner className="h-6 w-6" />
) : (
<button className={`${styles.gsiMaterialButton}`}>
<div className={styles.gsiMaterialButtonState}></div>
<div className={styles.gsiMaterialButtonContentWrapper}>
<div
className={`${styles.gsiMaterialButtonIcon} flex items-center justify-center`}
>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
></path>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
></path>
<path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
></path>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
></path>
<path fill="none" d="M0 0h48v48H0z"></path>
</svg>
</div>
</div>
</button>
)}
Continue with Google
</Button>
<Button
variant="outline"
className="w-[300px] p-8 flex gap-2 items-center justify-center rounded-lg font-bold"
onClick={() => {
setUseEmailSignIn(true);
}}
>
<PaperPlaneTilt className="h-6 w-6" />
Continue with Email
</Button>
</div>
<div className="text-center text-muted-foreground text-sm mb-[20px]">
By logging in, you agree to our{" "}
<Link href="https://khoj.dev/terms-of-service">Terms of Service</Link>. See{" "}
<Link href="https://khoj.dev/privacy-policy">Privacy Policy</Link>.
</div>
</div>
);
}

View file

@ -25,6 +25,8 @@ import {
import { Moon, Sun, UserCircle, Question, GearFine, ArrowRight, Code } from "@phosphor-icons/react";
import { KhojAgentLogo, KhojAutomationLogo, KhojSearchLogo } from "../logo/khojLogo";
import { useIsMobileWidth } from "@/app/common/utils";
import LoginPrompt from "../loginPrompt/loginPrompt";
import { Button } from "@/components/ui/button";
function SubscriptionBadge({ is_active }: { is_active: boolean }) {
return (
@ -51,6 +53,7 @@ export default function NavMenu() {
const [darkMode, setDarkMode] = useState(false);
const [initialLoadDone, setInitialLoadDone] = useState(false);
const isMobileWidth = useIsMobileWidth();
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
useEffect(() => {
if (localStorage.getItem("theme") === "dark") {
@ -87,6 +90,13 @@ export default function NavMenu() {
return (
<div className={styles.titleBar}>
{showLoginPrompt && (
<LoginPrompt
onOpenChange={setShowLoginPrompt}
isMobileWidth={isMobileWidth}
loginRedirectMessage={"Login to your second brain"}
/>
)}
{isMobileWidth ? (
<DropdownMenu>
<DropdownMenuTrigger>
@ -197,12 +207,16 @@ export default function NavMenu() {
</DropdownMenuItem>
) : (
<DropdownMenuItem>
<Link href="/login" className="no-underline w-full">
<div className="flex flex-rows">
<Button
variant={"ghost"}
onClick={() => setShowLoginPrompt(true)}
className="no-underline w-full text-left p-0 content-start justify-start items-start h-fit"
>
<div className="flex flex-rows text-left content-start justify-start items-start p-0">
<ArrowRight className="w-6 h-6" />
<p className="ml-3 font-semibold">Login</p>
</div>
</Link>
</Button>
</DropdownMenuItem>
)}
</>
@ -323,12 +337,16 @@ export default function NavMenu() {
</MenubarItem>
) : (
<MenubarItem>
<Link href="/login" className="no-underline w-full">
<div className="flex flex-rows">
<Button
variant={"ghost"}
onClick={() => setShowLoginPrompt(true)}
className="no-underline w-full text-left p-0 content-start justify-start items-start h-fit"
>
<div className="flex flex-rows text-left content-start justify-start items-start p-0">
<ArrowRight className="w-6 h-6" />
<p className="ml-3 font-semibold">Login</p>
</div>
</Link>
</Button>
</MenubarItem>
)}
</>

View file

@ -105,6 +105,7 @@ import { ScrollAreaScrollbar } from "@radix-ui/react-scroll-area";
import { KhojLogoType } from "@/app/components/logo/khojLogo";
import NavMenu from "@/app/components/navMenu/navMenu";
import { getIconFromIconName } from "@/app/common/iconUtils";
import LoginPrompt from "../loginPrompt/loginPrompt";
// Define a fetcher function
const fetcher = (url: string) =>
@ -908,6 +909,7 @@ export default function SidePanel(props: SidePanelProps) {
const [organizedData, setOrganizedData] = useState<GroupedChatHistory | null>(null);
const [subsetOrganizedData, setSubsetOrganizedData] = useState<GroupedChatHistory | null>(null);
const [enabled, setEnabled] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const authenticatedData = useAuthenticatedData();
const { data: chatSessions } = useChatSessionsFetchRequest(
@ -955,6 +957,13 @@ export default function SidePanel(props: SidePanelProps) {
<div
className={`${styles.panel} ${enabled ? styles.expanded : styles.collapsed} ${props.isMobileWidth ? "mt-0" : "mt-1"}`}
>
{showLoginPrompt && (
<LoginPrompt
loginRedirectMessage="Sign in to start chatting"
onOpenChange={setShowLoginPrompt}
isMobileWidth={props.isMobileWidth}
/>
)}
<div className={`flex justify-between flex-row`}>
{props.isMobileWidth ? (
<Drawer
@ -990,17 +999,15 @@ export default function SidePanel(props: SidePanelProps) {
</div>
) : (
<div className={`${styles.panelWrapper}`}>
<Link
href={`/login?next=${encodeURIComponent(window.location.pathname)}`}
className="text-center"
>
{" "}
{/* Redirect to login page */}
<Button variant="default">
<Button
variant="default"
onClick={() => setShowLoginPrompt(true)}
>
<UserCirclePlus className="h-4 w-4 mr-1" />
Sign Up
</Button>
</Link>
</div>
)}
<DrawerFooter>
@ -1068,15 +1075,11 @@ export default function SidePanel(props: SidePanelProps) {
<StackPlus className="h-4 w-4 mr-1" />
New Conversation
</Button>
</Link>
<Link href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
{" "}
{/* Redirect to login page */}
<Button variant="default">
</Link>{" "}
<Button variant="default" onClick={() => setShowLoginPrompt(true)}>
<UserCirclePlus className="h-4 w-4 mr-1" />
Sign Up
</Button>
</Link>
</div>
)}
</div>

View file

@ -4,20 +4,29 @@ import "./globals.css";
import { ContentSecurityPolicy } from "./common/layoutHelper";
export const metadata: Metadata = {
title: "Khoj AI - Home",
description: "Your Second Brain.",
title: "Khoj AI - Ask Anything",
description:
"Khoj is a personal research assistant. It helps you understand better and create faster.",
icons: {
icon: "/static/assets/icons/khoj_lantern.ico",
apple: "/static/assets/icons/khoj_lantern_256x256.png",
},
manifest: "/static/khoj.webmanifest",
keywords:
"research assistant, productivity, AI, Khoj, open source, model agnostic, research, productivity tool, personal assistant, personal research assistant, personal productivity assistant",
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI",
description: "Your Second Brain.",
description:
"Khoj is a personal research assistant. It helps you understand better and create faster.",
url: "https://app.khoj.dev",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,

View file

@ -34,6 +34,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { AgentCard } from "@/app/components/agentCard/agentCard";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import LoginPopup from "./components/loginPrompt/loginPopup";
interface ChatBodyDataProps {
chatOptionsData: ChatOptions | null;
@ -217,9 +218,16 @@ function ChatBodyData(props: ChatBodyDataProps) {
{showLoginPrompt && (
<LoginPrompt
onOpenChange={setShowLoginPrompt}
isMobileWidth={props.isMobileWidth}
loginRedirectMessage={"Login to your second brain"}
/>
)}
{!props.isLoggedIn && (
<LoginPopup
isMobileWidth={props.isMobileWidth}
setShowLoginPrompt={setShowLoginPrompt}
/>
)}
<div className={`w-full text-center justify-end content-end`}>
<div className="items-center">
<h1 className="text-2xl md:text-3xl text-center w-fit pb-6 px-4 mx-auto">

View file

@ -15,10 +15,15 @@ export const metadata: Metadata = {
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI - Settings",
description: "Your Second Brain.",
description: "Setup, configure, and personalize Khoj, your AI research assistant.",
url: "https://app.khoj.dev/settings",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,

View file

@ -4,8 +4,33 @@ import "../../globals.css";
import { ContentSecurityPolicy } from "@/app/common/layoutHelper";
export const metadata: Metadata = {
title: "Khoj AI - Chat",
description: "Use this page to view a chat with Khoj AI.",
title: "Khoj AI - Ask Anything",
description:
"Ask anything. Research answers from across the internet and your documents, draft messages, summarize documents, generate paintings and chat with personal agents.",
icons: {
icon: "/static/assets/icons/khoj_lantern.ico",
apple: "/static/assets/icons/khoj_lantern_256x256.png",
},
openGraph: {
siteName: "Khoj AI",
title: "Khoj AI - Ask Anything",
description:
"Ask anything. Research answers from across the internet and your documents, draft messages, summarize documents, generate paintings and chat with personal agents.",
url: "https://app.khoj.dev/chat",
type: "website",
images: [
{
url: "https://assets.khoj.dev/khoj_hero.png",
width: 940,
height: 525,
},
{
url: "https://assets.khoj.dev/khoj_lantern_256x256.png",
width: 256,
height: 256,
},
],
},
};
export default function RootLayout({

View file

@ -0,0 +1,242 @@
"use client";
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View file

@ -11,7 +11,7 @@
"cicollectstatic": "bash -c 'pushd ../../../ && python3 src/khoj/manage.py collectstatic --noinput && popd'",
"export": "yarn build && cp -r out/ ../../khoj/interface/built && yarn collectstatic",
"ciexport": "yarn build && cp -r out/ ../../khoj/interface/built && yarn cicollectstatic",
"pypiciexport": "yarn build && cp -r out/ /opt/hostedtoolcache/Python/3.11.10/x64/lib/python3.11/site-packages/khoj/interface/compiled && yarn cicollectstatic",
"pypiciexport": "yarn build && cp -r out/ /opt/hostedtoolcache/Python/3.11.11/x64/lib/python3.11/site-packages/khoj/interface/compiled && yarn cicollectstatic",
"watch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn export'",
"windowswatch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn windowsexport'",
"windowscollectstatic": "cd ..\\..\\.. && .\\.venv\\Scripts\\Activate.bat && py .\\src\\khoj\\manage.py collectstatic --noinput && .\\.venv\\Scripts\\deactivate.bat && cd ..",
@ -45,6 +45,8 @@
"cmdk": "^1.0.0",
"cronstrue": "^2.50.0",
"dompurify": "^3.1.6",
"embla-carousel-autoplay": "^8.5.1",
"embla-carousel-react": "^8.5.1",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"input-otp": "^1.2.4",

View file

@ -1897,6 +1897,29 @@ electron-to-chromium@^1.5.41:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz#69444d592fbbe628d129866c2355691ea93eda3e"
integrity sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==
embla-carousel-autoplay@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.5.1.tgz#d0213ab6d7beeafcfcb8f7b1fa023a4d3882f0a2"
integrity sha512-FnZklFpePfp8wbj177UwVaGFehgs+ASVcJvYLWTtHuYKURynCc3IdDn2qrn0E5Qpa3g9yeGwCS4p8QkrZmO8xg==
embla-carousel-react@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.5.1.tgz#e06ff28cb53698d453ffad89423c23d725e9b010"
integrity sha512-z9Y0K84BJvhChXgqn2CFYbfEi6AwEr+FFVVKm/MqbTQ2zIzO1VQri6w67LcfpVF0AjbhwVMywDZqY4alYkjW5w==
dependencies:
embla-carousel "8.5.1"
embla-carousel-reactive-utils "8.5.1"
embla-carousel-reactive-utils@8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.1.tgz#3059ab2f72f04988a96694f700a772a72bb75ffb"
integrity sha512-n7VSoGIiiDIc4MfXF3ZRTO59KDp820QDuyBDGlt5/65+lumPHxX2JLz0EZ23hZ4eg4vZGUXwMkYv02fw2JVo/A==
embla-carousel@8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.5.1.tgz#8d83217e831666f6df573b0d3727ff0ae9208002"
integrity sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==
emoji-regex@^10.3.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4"
@ -4174,6 +4197,7 @@ string-argv@~0.3.2:
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==

View file

@ -238,7 +238,9 @@ async def aget_or_create_user_by_email(email: str) -> tuple[KhojUser, bool]:
await user.asave()
if user:
user.email_verification_code = secrets.token_urlsafe(18)
# Generate a secure 6-digit numeric code
user.email_verification_code = f"{secrets.randbelow(1000000):06}"
user.email_verification_code_expiry = datetime.now(tz=timezone.utc) + timedelta(minutes=5)
await user.asave()
user_subscription = await Subscription.objects.filter(user=user).afirst()
@ -267,16 +269,19 @@ async def astart_trial_subscription(user: KhojUser) -> Subscription:
return subscription
async def aget_user_validated_by_email_verification_code(code: str) -> KhojUser:
user = await KhojUser.objects.filter(email_verification_code=code).afirst()
async def aget_user_validated_by_email_verification_code(code: str, email: str) -> tuple[Optional[KhojUser], bool]:
user = await KhojUser.objects.filter(email_verification_code=code, email=email).afirst()
if not user:
return None
return None, False
if user.email_verification_code_expiry < datetime.now(tz=timezone.utc):
return user, True
user.email_verification_code = None
user.verified_email = True
await user.asave()
return user
return user, False
async def create_user_by_google_token(token: dict) -> KhojUser:
@ -432,10 +437,14 @@ def is_user_subscribed(user: KhojUser) -> bool:
return subscribed
async def get_user_by_email(email: str) -> KhojUser:
async def aget_user_by_email(email: str) -> KhojUser:
return await KhojUser.objects.filter(email=email).afirst()
def get_user_by_email(email: str) -> KhojUser:
return KhojUser.objects.filter(email=email).first()
async def aget_user_by_uuid(uuid: str) -> KhojUser:
return await KhojUser.objects.filter(uuid=uuid).afirst()

View file

@ -0,0 +1,17 @@
# Generated by Django 5.0.9 on 2024-12-14 18:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0077_chatmodel_alter_agent_chat_model_and_more"),
]
operations = [
migrations.AddField(
model_name="khojuser",
name="email_verification_code_expiry",
field=models.DateTimeField(blank=True, default=None, null=True),
),
]

View file

@ -138,6 +138,7 @@ class KhojUser(AbstractUser):
verified_phone_number = models.BooleanField(default=False)
verified_email = models.BooleanField(default=False)
email_verification_code = models.CharField(max_length=200, null=True, default=None, blank=True)
email_verification_code_expiry = models.DateTimeField(null=True, default=None, blank=True)
def save(self, *args, **kwargs):
if not self.uuid:

View file

@ -1,17 +1,40 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Khoj</title>
</head>
<body>
<body style="font-family: 'Verdana', sans-serif; font-weight: 400; font-style: normal; padding: 0; text-align: left; width: 600px; margin: 20px auto;">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<a class="logo" href="https://khoj.dev" target="_blank" style="text-decoration: none; text-decoration: underline dotted;">
<img src="https://assets.khoj.dev/khoj_logo.png" alt="Khoj Logo" style="width: 100px;">
<body
style="font-family: 'Arial', sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f5f5f5;">
<div
style="background-color: #ffffff; border-radius: 10px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); padding: 30px;">
<a href="https://khoj.dev" target="_blank"
style="display: block; text-align: center; margin-bottom: 20px; text-decoration: none;">
<img src="https://assets.khoj.dev/khoj_logo.png" alt="Khoj Logo" style="width: 120px;">
</a>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">Hi! <a href="{{ link }}" target="_blank" style="text-decoration: none; text-decoration: underline dotted;">Click here to sign in on this browser.</a></p>
<p style="color: #333; font-size: large; margin-top: 20px; padding: 0; line-height: 1.5;">- The Khoj Team</p>
<p style="font-size: 16px; color: #333; margin-bottom: 20px;">Use this code to login to Khoj:</p>
<h1 style="font-size: 24px; color: #2c3e50; margin-bottom: 20px; text-align: center;">{{ code }}</h1>
<p style="font-size: 16px; color: #333; margin-bottom: 20px;">It will be valid for 5 minutes.</p>
<p style="font-size: 16px; color: #333; margin-bottom: 20px;">Can't sign in using the code? <a href="{{ link }}" target="_blank"
style="color: #FFA07A; text-decoration: none; font-weight: bold;">Click here to sign in on this
browser.</a></p>
<div style="font-size: 14px; color: #555; margin-top: 30px;">- The Khoj Team</div>
<div style="margin-top: 30px; text-align: center;">
<a href="https://docs.khoj.dev" target="_blank"
style="display: inline-block; margin: 0 10px; padding: 8px 15px; border: 2px solid #FFA07A; color: #FFA07A; text-decoration: none; border-radius: 5px; background-color: transparent;">Docs</a>
<a href="https://github.com/khoj-ai/khoj" target="_blank"
style="display: inline-block; margin: 0 10px; padding: 8px 15px; border: 2px solid #FFA07A; color: #FFA07A; text-decoration: none; border-radius: 5px; background-color: transparent;">GitHub</a>
</div>
</div>
</body>
</html>

View file

@ -4,7 +4,8 @@ import logging
import os
from typing import Optional
from fastapi import APIRouter
import requests
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr
from starlette.authentication import requires
from starlette.config import Config
@ -21,7 +22,11 @@ from khoj.database.adapters import (
get_or_create_user,
)
from khoj.routers.email import send_magic_link_email, send_welcome_email
from khoj.routers.helpers import get_next_url, update_telemetry_state
from khoj.routers.helpers import (
EmailVerificationApiRateLimiter,
get_next_url,
update_telemetry_state,
)
from khoj.utils import state
logger = logging.getLogger(__name__)
@ -98,16 +103,28 @@ async def login_magic_link(request: Request, form: MagicLinkForm):
@auth_router.get("/magic")
async def sign_in_with_magic_link(request: Request, code: str):
user = await aget_user_validated_by_email_verification_code(code)
async def sign_in_with_magic_link(
request: Request,
code: str,
email: str,
rate_limiter=Depends(
EmailVerificationApiRateLimiter(requests=10, window=60 * 60 * 24, slug="magic_link_verification")
),
):
user, code_is_expired = await aget_user_validated_by_email_verification_code(code, email)
if user:
if code_is_expired:
request.session["user"] = {}
return Response(status_code=403)
id_info = {
"email": user.email,
}
request.session["user"] = dict(id_info)
return RedirectResponse(url="/")
return RedirectResponse(request.app.url_path_for("login_page"))
return Response(status_code=401)
@auth_router.post("/token")
@ -140,11 +157,12 @@ async def delete_token(request: Request, token: str):
@auth_router.post("/redirect")
async def auth(request: Request):
async def auth_post(request: Request):
# This is maintained for compatibility with the /login endpoint
form = await request.form()
next_url = get_next_url(request)
for q in request.query_params:
if not q == "next":
if q != "next":
next_url += f"&{q}={request.query_params[q]}"
credential = form.get("credential")
@ -183,7 +201,76 @@ async def auth(request: Request):
return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
@auth_router.get("/redirect")
async def auth(request: Request):
next_url = get_next_url(request)
for q in request.query_params:
if q in ["code", "state", "scope", "authuser", "prompt", "session_state", "access_type"]:
continue
if q != "next":
next_url += f"&{q}={request.query_params[q]}"
code = request.query_params.get("code")
# 1. Construct the full redirect URI including domain
base_url = str(request.base_url).rstrip("/")
redirect_uri = f"{base_url}{request.app.url_path_for('auth')}"
verified_data = requests.post(
"https://oauth2.googleapis.com/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"code": code,
"client_id": os.environ["GOOGLE_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
verified_data.raise_for_status()
credential = verified_data.json().get("id_token")
if not credential:
logger.error("Missing id_token in OAuth response")
return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
try:
idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
except OAuthError as error:
return HTMLResponse(f"<h1>{error.error}</h1>")
khoj_user = await get_or_create_user(idinfo)
if khoj_user:
request.session["user"] = dict(idinfo)
if datetime.timedelta(minutes=3) > (datetime.datetime.now(datetime.timezone.utc) - khoj_user.date_joined):
asyncio.create_task(send_welcome_email(idinfo["name"], idinfo["email"]))
update_telemetry_state(
request=request,
telemetry_type="api",
api="create_user__google",
metadata={"server_id": str(khoj_user.uuid)},
)
logger.log(logging.INFO, f"🥳 New User Created: {khoj_user.uuid}")
return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
@auth_router.get("/logout")
async def logout(request: Request):
request.session.pop("user", None)
return RedirectResponse(url="/")
@auth_router.get("/oauth/metadata")
async def oauth_metadata(request: Request):
redirect_uri = str(request.app.url_path_for("auth"))
return {
"google": {
"client_id": os.environ.get("GOOGLE_CLIENT_ID"),
"redirect_uri": f"{redirect_uri}",
}
}

View file

@ -33,7 +33,7 @@ def is_resend_enabled():
async def send_magic_link_email(email, unique_id, host):
sign_in_link = f"{host}auth/magic?code={unique_id}"
sign_in_link = f"{host}auth/magic?code={unique_id}&email={email}"
if not is_resend_enabled():
logger.debug(f"Email sending disabled. Share this sign-in link with the user: {sign_in_link}")
@ -41,13 +41,13 @@ async def send_magic_link_email(email, unique_id, host):
template = env.get_template("magic_link.html")
html_content = template.render(link=f"{host}auth/magic?code={unique_id}")
html_content = template.render(link=f"{host}auth/magic?code={unique_id}", code=unique_id)
resend.Emails.send(
{
"sender": os.environ.get("RESEND_EMAIL", "noreply@khoj.dev"),
"to": email,
"subject": "Your Sign-In Link for Khoj 🚀",
"subject": f"Your login code to Khoj",
"html": html_content,
}
)

View file

@ -49,6 +49,7 @@ from khoj.database.adapters import (
ais_user_subscribed,
create_khoj_token,
get_khoj_tokens,
get_user_by_email,
get_user_name,
get_user_notion_config,
get_user_subscription_state,
@ -1363,6 +1364,49 @@ class FeedbackData(BaseModel):
sentiment: str
class EmailVerificationApiRateLimiter:
def __init__(self, requests: int, window: int, slug: str):
self.requests = requests
self.window = window
self.slug = slug
def __call__(self, request: Request):
# Rate limiting disabled if billing is disabled
if state.billing_enabled is False:
return
# Extract the email query parameter
email = request.query_params.get("email")
if email:
logger.info(f"Email query parameter: {email}")
user: KhojUser = get_user_by_email(email)
if not user:
raise HTTPException(
status_code=404,
detail="User not found.",
)
# Remove requests outside of the time window
cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=self.window)
count_requests = UserRequests.objects.filter(user=user, created_at__gte=cutoff, slug=self.slug).count()
# Check if the user has exceeded the rate limit
if count_requests >= self.requests:
logger.info(
f"Rate limit: {count_requests}/{self.requests} requests not allowed in {self.window} seconds for email: {email}."
)
raise HTTPException(
status_code=429,
detail="Ran out of login attempts",
)
# Add the current request to the db
UserRequests.objects.create(user=user, slug=self.slug)
class ApiUserRateLimiter:
def __init__(self, requests: int, subscribed_requests: int, window: int, slug: str):
self.requests = requests

View file

@ -57,7 +57,7 @@ def login_page(request: Request):
if request.user.is_authenticated:
return RedirectResponse(url=next_url)
google_client_id = os.environ.get("GOOGLE_CLIENT_ID")
redirect_uri = str(request.app.url_path_for("auth"))
redirect_uri = str(request.app.url_path_for("auth_post"))
return templates.TemplateResponse(
"login.html",
context={