Add support for basic auth on FE

This commit is contained in:
Weves
2023-12-25 01:16:09 -08:00
committed by Chris Weaver
parent 1e84b0daa4
commit dab3ba8a41
21 changed files with 606 additions and 45 deletions

11
web/src/app/auth/lib.ts Normal file
View File

@@ -0,0 +1,11 @@
export async function requestEmailVerification(email: string) {
return await fetch("/api/auth/request-verify-token", {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
email: email,
}),
});
}

View File

@@ -0,0 +1,113 @@
"use client";
import { TextFormField } from "@/components/admin/connectors/Field";
import { usePopup } from "@/components/admin/connectors/Popup";
import { basicLogin, basicSignup } from "@/lib/user";
import { Button } from "@tremor/react";
import { Form, Formik } from "formik";
import { useRouter } from "next/navigation";
import * as Yup from "yup";
import { requestEmailVerification } from "../lib";
import { useState } from "react";
import { Spinner } from "@/components/Spinner";
export function EmailPasswordForm({
isSignup = false,
shouldVerify,
}: {
isSignup?: boolean;
shouldVerify?: boolean;
}) {
const router = useRouter();
const { popup, setPopup } = usePopup();
const [isWorking, setIsWorking] = useState(false);
return (
<>
{isWorking && <Spinner />}
{popup}
<Formik
initialValues={{
email: "",
password: "",
}}
validationSchema={Yup.object().shape({
email: Yup.string().email().required(),
password: Yup.string().required(),
})}
onSubmit={async (values) => {
if (isSignup) {
// login is fast, no need to show a spinner
setIsWorking(true);
const response = await basicSignup(values.email, values.password);
if (!response.ok) {
const errorDetail = (await response.json()).detail;
let errorMsg = "Unknown error";
if (errorDetail === "REGISTER_USER_ALREADY_EXISTS") {
errorMsg =
"An account already exists with the specified email.";
}
setPopup({
type: "error",
message: `Failed to sign up - ${errorMsg}`,
});
return;
}
}
const loginResponse = await basicLogin(values.email, values.password);
if (loginResponse.ok) {
if (isSignup && shouldVerify) {
await requestEmailVerification(values.email);
router.push("/auth/waiting-on-verification");
} else {
router.push("/");
}
} else {
setIsWorking(false);
const errorDetail = (await loginResponse.json()).detail;
let errorMsg = "Unknown error";
if (errorDetail === "LOGIN_BAD_CREDENTIALS") {
errorMsg = "Invalid email or password";
}
setPopup({
type: "error",
message: `Failed to login - ${errorMsg}`,
});
}
}}
>
{({ isSubmitting, values }) => (
<Form>
<TextFormField
name="email"
label="Email"
type="email"
placeholder="email@yourcompany.com"
/>
<TextFormField
name="password"
label="Password"
type="password"
placeholder="**************"
/>
<div className="flex">
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-full"
>
{isSignup ? "Sign Up" : "Log In"}
</Button>
</div>
</Form>
)}
</Formik>
</>
);
}

View File

@@ -7,9 +7,11 @@ import {
AuthTypeMetadata,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { getWebVersion, getBackendVersion } from "@/lib/version";
import Image from "next/image";
import { SignInButton } from "./SignInButton";
import { EmailPasswordForm } from "./EmailPasswordForm";
import { Card, Title, Text } from "@tremor/react";
import Link from "next/link";
const Page = async ({
searchParams,
@@ -32,24 +34,17 @@ const Page = async ({
console.log(`Some fetch failed for the login page - ${e}`);
}
let web_version: string | null = null;
let backend_version: string | null = null;
try {
[web_version, backend_version] = await Promise.all([
getWebVersion(),
getBackendVersion(),
]);
} catch (e) {
console.log(`Version info fetch failed for the login page - ${e}`);
}
// simply take the user to the home page if Auth is disabled
if (authTypeMetadata?.authType === "disabled") {
return redirect("/");
}
// if user is already logged in, take them to the main app page
if (currentUser && currentUser.is_active && currentUser.is_verified) {
if (currentUser && currentUser.is_active) {
if (authTypeMetadata?.requiresVerification && !currentUser.is_verified) {
return redirect("/auth/waiting-on-verification");
}
return redirect("/");
}
@@ -77,20 +72,38 @@ const Page = async ({
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<h2 className="text-center text-xl text-strong font-bold mt-6">
Log In to Danswer
</h2>
{authUrl && authTypeMetadata && (
<SignInButton
authorizeUrl={authUrl}
authType={authTypeMetadata?.authType}
/>
<>
<h2 className="text-center text-xl text-strong font-bold mt-6">
Log In to Danswer
</h2>
<SignInButton
authorizeUrl={authUrl}
authType={authTypeMetadata?.authType}
/>
</>
)}
{authTypeMetadata?.authType === "basic" && (
<Card className="mt-4 w-96">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">
Log In to Danswer
</Title>
</div>
<EmailPasswordForm />
<div className="flex">
<Text className="mt-4 mx-auto">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-link font-medium">
Create an account
</Link>
</Text>
</div>
</Card>
)}
</div>
</div>
<div className="fixed bottom-4 right-4 z-50 text-slate-400 p-2">
VERSION w{web_version} b{backend_version}
</div>
</main>
);
};

View File

@@ -0,0 +1,83 @@
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import {
getCurrentUserSS,
getAuthTypeMetadataSS,
AuthTypeMetadata,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import Image from "next/image";
import { EmailPasswordForm } from "../login/EmailPasswordForm";
import { Card, Title, Text } from "@tremor/react";
import Link from "next/link";
const Page = async () => {
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let authTypeMetadata: AuthTypeMetadata | null = null;
let currentUser: User | null = null;
try {
[authTypeMetadata, currentUser] = await Promise.all([
getAuthTypeMetadataSS(),
getCurrentUserSS(),
]);
} catch (e) {
console.log(`Some fetch failed for the login page - ${e}`);
}
// simply take the user to the home page if Auth is disabled
if (authTypeMetadata?.authType === "disabled") {
return redirect("/");
}
// if user is already logged in, take them to the main app page
if (currentUser && currentUser.is_active) {
if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) {
return redirect("/");
}
return redirect("/auth/waiting-on-verification");
}
// only enable this page if basic login is enabled
if (authTypeMetadata?.authType !== "basic") {
return redirect("/");
}
return (
<main>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Card className="mt-4 w-96">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">
Sign Up for Danswer
</Title>
</div>
<EmailPasswordForm
isSignup
shouldVerify={authTypeMetadata?.requiresVerification}
/>
<div className="flex">
<Text className="mt-4 mx-auto">
Already have an account?{" "}
<Link href="/auth/login" className="text-link font-medium">
Log In
</Link>
</Text>
</div>
</Card>
</div>
</div>
</main>
);
};
export default Page;

View File

@@ -0,0 +1,80 @@
"use client";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import { Text } from "@tremor/react";
import { RequestNewVerificationEmail } from "../waiting-on-verification/RequestNewVerificationEmail";
import { User } from "@/lib/types";
export function Verify({ user }: { user: User | null }) {
const searchParams = useSearchParams();
const router = useRouter();
const [error, setError] = useState("");
async function verify() {
const token = searchParams.get("token");
if (!token) {
setError(
"Missing verification token. Try requesting a new verification email."
);
return;
}
const response = await fetch("/api/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
if (response.ok) {
router.push("/");
} else {
const errorDetail = (await response.json()).detail;
setError(
`Failed to verify your email - ${errorDetail}. Please try requesting a new verification email.`
);
}
}
useEffect(() => {
verify();
}, []);
return (
<main>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto animate-pulse">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
{!error ? (
<Text className="mt-2">Verifying your email...</Text>
) : (
<div>
<Text className="mt-2">{error}</Text>
{user && (
<div className="text-center">
<RequestNewVerificationEmail email={user.email}>
<Text className="mt-2 text-link">
Get new verification email
</Text>
</RequestNewVerificationEmail>
</div>
)}
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,30 @@
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { Verify } from "./Verify";
import { User } from "@/lib/types";
import { redirect } from "next/navigation";
export default async function Page() {
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let authTypeMetadata: AuthTypeMetadata | null = null;
let currentUser: User | null = null;
try {
[authTypeMetadata, currentUser] = await Promise.all([
getAuthTypeMetadataSS(),
getCurrentUserSS(),
]);
} catch (e) {
console.log(`Some fetch failed for the login page - ${e}`);
}
if (!authTypeMetadata?.requiresVerification || currentUser?.is_verified) {
return redirect("/");
}
return <Verify user={currentUser} />;
}

View File

@@ -0,0 +1,46 @@
"use client";
import { usePopup } from "@/components/admin/connectors/Popup";
import { requestEmailVerification } from "../lib";
import { Spinner } from "@/components/Spinner";
import { useState } from "react";
export function RequestNewVerificationEmail({
children,
email,
}: {
children: JSX.Element | string;
email: string;
}) {
const { popup, setPopup } = usePopup();
const [isRequestingVerification, setIsRequestingVerification] =
useState(false);
return (
<button
className="text-link"
onClick={async () => {
setIsRequestingVerification(true);
const response = await requestEmailVerification(email);
setIsRequestingVerification(false);
if (response.ok) {
setPopup({
type: "success",
message: "A new verification email has been sent!",
});
} else {
const errorDetail = (await response.json()).detail;
setPopup({
type: "error",
message: `Failed to send a new verification email - ${errorDetail}`,
});
}
}}
>
{isRequestingVerification && <Spinner />}
{popup}
{children}
</button>
);
}

View File

@@ -0,0 +1,69 @@
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import Image from "next/image";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import { Text } from "@tremor/react";
import { RequestNewVerificationEmail } from "./RequestNewVerificationEmail";
export default async function Page() {
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let authTypeMetadata: AuthTypeMetadata | null = null;
let currentUser: User | null = null;
try {
[authTypeMetadata, currentUser] = await Promise.all([
getAuthTypeMetadataSS(),
getCurrentUserSS(),
]);
} catch (e) {
console.log(`Some fetch failed for the login page - ${e}`);
}
if (!currentUser) {
if (authTypeMetadata?.authType === "disabled") {
return redirect("/");
}
return redirect("/auth/login");
}
if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) {
return redirect("/");
}
return (
<main>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<div className="flex">
<Text className="text-center font-medium text-lg mt-6 w-108">
Hey <i>{currentUser.email}</i> - it looks like you haven&apos;t
verified your email yet.
<br />
Check your inbox for an email from us to get started!
<br />
<br />
If you don&apos;t see anything, click{" "}
<RequestNewVerificationEmail email={currentUser.email}>
here
</RequestNewVerificationEmail>{" "}
to request a new email.
</Text>
</div>
</div>
</div>
</main>
);
}

View File

@@ -1,4 +1,8 @@
import { getAuthDisabledSS, getCurrentUserSS } from "@/lib/userSS";
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { fetchSS } from "@/lib/utilsSS";
import { Connector, DocumentSet, User, ValidSources } from "@/lib/types";
@@ -31,7 +35,7 @@ export default async function ChatPage({
const currentChatId = chatId ? parseInt(chatId) : null;
const tasks = [
getAuthDisabledSS(),
getAuthTypeMetadataSS(),
getCurrentUserSS(),
fetchSS("/manage/connector"),
fetchSS("/manage/document-set"),
@@ -45,7 +49,7 @@ export default async function ChatPage({
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (User | Response | boolean | null)[] = [
let results: (User | Response | AuthTypeMetadata | null)[] = [
null,
null,
null,
@@ -59,7 +63,7 @@ export default async function ChatPage({
} catch (e) {
console.log(`Some fetch failed for the main search page - ${e}`);
}
const authDisabled = results[0] as boolean;
const authTypeMetadata = results[0] as AuthTypeMetadata;
const user = results[1] as User | null;
const connectorsResponse = results[2] as Response | null;
const documentSetsResponse = results[3] as Response | null;
@@ -67,10 +71,15 @@ export default async function ChatPage({
const chatSessionsResponse = results[5] as Response | null;
const chatSessionMessagesResponse = results[6] as Response | null;
const authDisabled = authTypeMetadata.authType === "disabled";
if (!authDisabled && !user) {
return redirect("/auth/login");
}
if (!user?.is_verified && authTypeMetadata.requiresVerification) {
return redirect("/auth/waiting-on-verification");
}
let connectors: Connector<any>[] = [];
if (connectorsResponse?.ok) {
connectors = await connectorsResponse.json();

View File

@@ -1,6 +1,10 @@
import { SearchSection } from "@/components/search/SearchSection";
import { Header } from "@/components/Header";
import { getAuthDisabledSS, getCurrentUserSS } from "@/lib/userSS";
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { ApiKeyModal } from "@/components/openai/ApiKeyModal";
@@ -21,7 +25,7 @@ export default async function Home() {
noStore();
const tasks = [
getAuthDisabledSS(),
getAuthTypeMetadataSS(),
getCurrentUserSS(),
fetchSS("/manage/connector"),
fetchSS("/manage/document-set"),
@@ -31,22 +35,32 @@ export default async function Home() {
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (User | Response | boolean | null)[] = [null, null, null, null];
let results: (User | Response | AuthTypeMetadata | null)[] = [
null,
null,
null,
null,
];
try {
results = await Promise.all(tasks);
} catch (e) {
console.log(`Some fetch failed for the main search page - ${e}`);
}
const authDisabled = results[0] as boolean;
const authTypeMetadata = results[0] as AuthTypeMetadata;
const user = results[1] as User | null;
const connectorsResponse = results[2] as Response | null;
const documentSetsResponse = results[3] as Response | null;
const personaResponse = results[4] as Response | null;
const authDisabled = authTypeMetadata.authType === "disabled";
if (!authDisabled && !user) {
return redirect("/auth/login");
}
if (!user?.is_verified && authTypeMetadata.requiresVerification) {
return redirect("/auth/waiting-on-verification");
}
let connectors: Connector<any>[] = [];
if (connectorsResponse?.ok) {
connectors = await connectorsResponse.json();

View File

@@ -1,4 +1,4 @@
export type AuthType = "disabled" | "google_oauth" | "oidc" | "saml";
export type AuthType = "disabled" | "basic" | "google_oauth" | "oidc" | "saml";
export const INTERNAL_URL = process.env.INTERNAL_URL || "http://127.0.0.1:8080";
export const NEXT_PUBLIC_DISABLE_STREAMING =

View File

@@ -19,3 +19,38 @@ export const logout = async (): Promise<Response> => {
});
return response;
};
export const basicLogin = async (
email: string,
password: string
): Promise<Response> => {
const params = new URLSearchParams([
["username", email],
["password", password],
]);
const response = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
});
return response;
};
export const basicSignup = async (email: string, password: string) => {
const response = await fetch("/api/auth/register", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
username: email,
password,
}),
});
return response;
};

View File

@@ -7,6 +7,7 @@ import { AuthType } from "./constants";
export interface AuthTypeMetadata {
authType: AuthType;
autoRedirect: boolean;
requiresVerification: boolean;
}
export const getAuthTypeMetadataSS = async (): Promise<AuthTypeMetadata> => {
@@ -15,15 +16,24 @@ export const getAuthTypeMetadataSS = async (): Promise<AuthTypeMetadata> => {
throw new Error("Failed to fetch data");
}
const data: { auth_type: string } = await res.json();
const data: { auth_type: string; requires_verification: boolean } =
await res.json();
const authType = data.auth_type as AuthType;
// for SAML / OIDC, we auto-redirect the user to the IdP when the user visits
// Danswer in an un-authenticated state
if (authType === "oidc" || authType === "saml") {
return { authType, autoRedirect: true };
return {
authType,
autoRedirect: true,
requiresVerification: data.requires_verification,
};
}
return { authType, autoRedirect: false };
return {
authType,
autoRedirect: false,
requiresVerification: data.requires_verification,
};
};
export const getAuthDisabledSS = async (): Promise<boolean> => {
@@ -65,6 +75,8 @@ export const getAuthUrlSS = async (authType: AuthType): Promise<string> => {
switch (authType) {
case "disabled":
return "";
case "basic":
return "";
case "google_oauth": {
return await getGoogleOAuthUrlSS();
}