Skip to content
Closed
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 docker-compose.development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- PORT=10001
- VITE_API_BASE=http://localhost:10000
- VITE_WS_BASE=ws://localhost:10000
- VITE_OTP_ENABLED=false
ports:
- "10001:10001"
api:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ services:
- PORT=10001
- VITE_API_BASE=http://localhost:10000
- VITE_WS_BASE=ws://localhost:10000
- VITE_OTP_ENABLED=false
ports:
- "10001:10001"
api:
Expand Down
61 changes: 17 additions & 44 deletions frontend/src/components/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,39 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { HTMLAttributes, SyntheticEvent, useState } from "react";
import { Spinner } from "@/components/util/spinner.tsx";
import { authenticationProviderInstance } from "@/lib/authentication-provider.ts";
import { useNavigate } from "react-router-dom";
import { ApiClient } from "@/lib/api.ts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";

const api = new ApiClient(authenticationProviderInstance);

export function LoginForm({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
interface LoginFormProps extends HTMLAttributes<HTMLDivElement> {
onSuccess: (token: string, api: ApiClient) => Promise<void>;
}

export function LoginForm({ className, onSuccess, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const navigate = useNavigate();

async function onSubmit(event: SyntheticEvent) {
const onSubmit = async (event: SyntheticEvent) => {
event.preventDefault();
setIsLoading(true);

const email = (event.target as HTMLFormElement).email.value;
const password = (event.target as HTMLFormElement).password.value;
const form = event.target as HTMLFormElement;
const email = (form.email as HTMLInputElement).value;
const password = (form.password as HTMLInputElement).value;

try {
const { token } = await api.login(email, password);
authenticationProviderInstance.login(token);

const dataSets = await api.dataSets().getDataSets();
if (dataSets.length === 0) {
const account = await api.getAccount();
if (account.isStaff) {
navigate('/onboarding');
} else {
navigate('/no-data-sets');
}
} else {
const dataSetAgents = await api.agents().getDatasetAvailableAgents(dataSets[0].id!)
const agentId = dataSetAgents?.[0]?.id;
const page = agentId
? `/data-sets/${dataSets[0].id}/chat/new/${agentId}`
: `/data-sets/${dataSets[0].id}/chat/new`;
navigate(page);
}
await onSuccess(token, api);
} catch {
setIsError(true);
} finally {
setIsLoading(false);
}
}
};

return (
<div className={cn("grid gap-6", className)} {...props}>
Expand All @@ -60,6 +46,7 @@ export function LoginForm({ className, ...props }: HTMLAttributes<HTMLDivElement
</Alert>}
<Input
id="email"
name="email"
placeholder="user@example.com"
type="email"
autoCapitalize="none"
Expand All @@ -69,6 +56,7 @@ export function LoginForm({ className, ...props }: HTMLAttributes<HTMLDivElement
/>
<Input
id="password"
name="password"
placeholder="password"
type="password"
autoCapitalize="none"
Expand All @@ -77,27 +65,12 @@ export function LoginForm({ className, ...props }: HTMLAttributes<HTMLDivElement
disabled={isLoading}
/>
</div>
<Button disabled={isLoading}>
{isLoading && (
<Spinner />
)}
<Button type="submit" disabled={isLoading}>
{isLoading && <Spinner />}
Continue
</Button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t"/>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or
</span>
</div>
</div>
<Button disabled variant="outline">
Log in with SSO
</Button>
</div>
)
);
}
51 changes: 51 additions & 0 deletions frontend/src/components/login/otp-login-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Button } from "@/components/ui/button.tsx";
import { useEffect, useState } from "react";
import { authenticationProviderInstance } from "@/lib/authentication-provider.ts";
import { ApiClient } from "@/lib/api.ts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useNavigate } from "react-router-dom";

const api = new ApiClient(authenticationProviderInstance);

interface OtpLoginFormProps {
onSuccess: (token: string, api: ApiClient) => Promise<void>;
}

export function OtpLoginForm({ onSuccess }: OtpLoginFormProps) {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onClick = () => {
window.location.href = `${import.meta.env.VITE_API_BASE}/api/auth/otp/start`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused - is that SSO or OTP? These are two different concepts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's OTP, I need to change old button label if that's what you mean.

}
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const error = params.get("error")

if (token) {
onSuccess(token, api);
window.history.replaceState({}, document.title, window.location.pathname);
}
if (error) {
setErrorMessage(error);
window.history.replaceState({}, document.title, window.location.pathname);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate]);
return (
<div className="grid gap-1">
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>Login failed</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
<Button
variant="outline"
onClick={onClick}
>
OTP Login
</Button>
</div>
);
}
4 changes: 2 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class ApiClient {
async getAllDocumentSourcePlugins(): Promise<SourcePlugin[]> {
const response = await fetch(`${this.apiBase}/api/plugins/document_source_plugins?page_size=1000`, this._requestConfiguration());
return (await response.json()).choices as SourcePlugin[];
}
}

catalog(): CatalogApiClient {
return new CatalogApiClient(this.apiBase, this.authenticationProvider);
Expand Down Expand Up @@ -110,4 +110,4 @@ export class ApiClient {
}
}
}
}
}
43 changes: 42 additions & 1 deletion frontend/src/pages/login.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { LoginForm } from "@/components/login/login-form.tsx";
import logoUrl from '@/assets/logo.png';
import logoSvgUrl from '@/assets/logo.svg';
import {authenticationProviderInstance} from "@/lib/authentication-provider.ts";
import {ApiClient} from "@/lib/api.ts";
import {useNavigate} from "react-router-dom";
import {OtpLoginForm} from "@/components/login/otp-login-form.tsx";

export function LoginPage() {
const navigate = useNavigate();

const onSuccess = async (token:string, api: ApiClient) => {
authenticationProviderInstance.login(token);

const dataSets = await api.dataSets().getDataSets();
if (dataSets.length === 0) {
const account = await api.getAccount();
if (account.isStaff) {
navigate('/onboarding');
} else {
navigate('/no-data-sets');
}
} else {
const dataSetAgents = await api.agents().getDatasetAvailableAgents(dataSets[0].id!)
const agentId = dataSetAgents?.[0]?.id;
const page = agentId
? `/data-sets/${dataSets[0].id}/chat/new/${agentId}`
: `/data-sets/${dataSets[0].id}/chat/new`;
navigate(page);
}
}
return (
<>
<div className="container relative hidden h-full flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
Expand All @@ -25,7 +51,22 @@ export function LoginPage() {
Enter your email and password to get started
</p>
</div>
<LoginForm />
<LoginForm onSuccess={onSuccess}/>
{import.meta.env.VITE_OTP_ENABLED === "true" && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or
</span>
</div>
</div>
<OtpLoginForm onSuccess={onSuccess}/>
</>
)}
</div>
</div>
</div>
Expand Down
31 changes: 31 additions & 0 deletions server/account/services.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from abc import ABC, abstractmethod

from django.conf import settings
from rest_framework.authtoken.models import Token

from .models import User

Expand All @@ -16,3 +19,31 @@ def is_service_account_name_available(self, name: str) -> bool:
"""
email = self.generate_service_account_email(name)
return not User.objects.filter(email=email).exists()


class OTPLoginService(ABC):
NOT_CONFIGURED_ERROR_MESSAGE = "OTP service not configured."

def create_user(self, email: str) -> User:
user, _ = User.objects.get_or_create(email=email)
return user

def create_token(self, user: User) -> Token:
token, _ = Token.objects.get_or_create(user=user)
return token

@abstractmethod
def get_email_from_token(self, token: str) -> str:
pass

@abstractmethod
def get_redirect_url(self) -> str:
pass

@abstractmethod
def get_token(self, code: str) -> str:
pass

@abstractmethod
def is_enabled(self) -> bool:
pass
2 changes: 2 additions & 0 deletions server/account/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

urlpatterns = [
path("api/auth/login", account.views.login.LoginView.as_view(), name="login"),
path("api/auth/otp/callback", account.views.login.OTPCallbackView.as_view(), name="otp_callback"),
path("api/auth/otp/start", account.views.login.OTPStartView.as_view(), name="otp_start"),
path("api/account", account.views.accounts.AccountView.as_view(), name="account"),
path("api/users", account.views.users.UserListView.as_view(), name="user_list"),
path("api/users/<int:id>", account.views.users.UserView.as_view(), name="user_details"),
Expand Down
78 changes: 78 additions & 0 deletions server/account/views/login.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import logging
import urllib.parse

from django.conf import settings
from django.contrib.auth import authenticate
from django.shortcuts import redirect
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from rest_framework.views import APIView
from utils.functions import import_from_string

from account.serializers import TokenResponseSerializer

logger = logging.getLogger(__name__)


class LoginView(APIView):
@swagger_auto_schema(
Expand All @@ -30,3 +38,73 @@ def post(self, request):
serializer = TokenResponseSerializer({"token": token.key})
return Response(serializer.data)
return Response({}, status=403)


class OTPCallbackView(APIView):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see any use case for an one time password login flow besides our demo server - why is that in the core of the server?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought if we can enable it on FE(It points to specific endpoints) it will be consistent to have that also implemented by default on BE side, so we have clear messages even if it's not configured. But, if you think that's redundant I can move it away from this repo. Please let me know

DEFAULT_ERROR_MESSAGE = "Could not sign in. Please try again."

@swagger_auto_schema(
operation_description="Callback endpoint for authentication provider hosted OTP login. Exchanges code for access token and redirects.",
manual_parameters=[
openapi.Parameter(
"code",
openapi.IN_QUERY,
description="Authorization code from auth provider",
type=openapi.TYPE_STRING,
required=True,
),
],
responses={
200: openapi.Response(description="User authenticated successfully"),
400: "Bad Request",
401: "Unauthorized",
},
)
def get(self, request):
code = request.query_params.get("code")
redirect_uri = f"{settings.FRONTEND_BASE_URL}/login"
try:
otp_service_class = import_from_string(settings.OTP_AUTH_SERVICE)
otp_service = otp_service_class()
if not otp_service.is_enabled():
return redirect(
f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': otp_service.NOT_CONFIGURED_ERROR_MESSAGE})}"
)
token = otp_service.get_token(code)
user_email = otp_service.get_email_from_token(token)
user = otp_service.create_user(email=user_email)
token = otp_service.create_token(user=user)
return redirect(f"{redirect_uri}?{urllib.parse.urlencode({'token': token.key})}")
except Exception as e:
logger.error(e, exc_info=True)
return redirect(f"{redirect_uri}?{urllib.parse.urlencode({'error': self.DEFAULT_ERROR_MESSAGE})}")


class OTPStartView(APIView):
DEFAULT_ERROR_MESSAGE = "Service unavailable, please try again later."

@swagger_auto_schema(
operation_description="Start the OTP login flow. Redirects user to the authentication provider hosted login page.",
responses={
302: openapi.Response(description="Redirect to authentication provider or frontend login with error"),
400: "Bad Request",
503: "Service Unavailable",
},
)
def get(self, request):
try:
otp_service_class = import_from_string(settings.OTP_AUTH_SERVICE)
otp_service = otp_service_class()
if not otp_service.is_enabled():
return redirect(
f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': otp_service.NOT_CONFIGURED_ERROR_MESSAGE})}"
)
auth_url = otp_service.get_redirect_url()

return redirect(auth_url)

except Exception as e:
logger.error(e, exc_info=True)
return redirect(
f"{settings.FRONTEND_BASE_URL}/login?{urllib.parse.urlencode({'error': self.DEFAULT_ERROR_MESSAGE})}"
)
Loading
Loading