Skip to content

Commit

Permalink
feat(mobile): add 2FA and SSO login
Browse files Browse the repository at this point in the history
  • Loading branch information
prathameshkurunkar7 committed Mar 6, 2024
1 parent afd7be0 commit b8374e7
Show file tree
Hide file tree
Showing 7 changed files with 422 additions and 202 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from "react";
import { PropsWithChildren } from "react";

export type CalloutObject = {
Expand All @@ -13,10 +12,26 @@ export const SuccessCallout = ({
return (
<div
key="success"
className="ion-margin bg-zinc-900 rounded-md border-2 border-green-4 00 p-2"
className="ion-margin bg-zinc-900 rounded-md border-2 border-green-400 p-2"
role="complementary"
>
<p className="font-normal text-green-400">{props.message}</p>
</div>
);
};


export const ErrorCallout = ({
children,
...props
}: PropsWithChildren<{ message?: string }>) => {
return (
<div
key="success"
className="ion-margin bg-zinc-900 rounded-md border-2 border-red-500 p-2"
role="alert"
>
<p className="font-normal text-red-400">{props.message}</p>
</div>
);
};
38 changes: 38 additions & 0 deletions mobile/src/components/layout/AuthContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { PropsWithChildren, useState } from 'react'
import { IonContent, IonHeader, IonImg, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import raven_logo from '../../assets/raven_logo.png'
import { LoginWithEmail } from '@/pages/auth/LoginWithEmail';
import { Login } from '@/pages/auth/Login';


const AuthContainer = ({ children, ...props }: PropsWithChildren) => {
const [isLoginWithEmailScreen, setIsLoginWithEmailScreen] = useState<boolean>(false)

return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonTitle>Raven</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className='ion-padding'>
<div className="left-0 right-0 top-1/4 p-2 transform justify-center items-center">
<IonHeader collapse="condense" translucent>
<IonToolbar>
<IonImg src={raven_logo} alt="Raven Logo" className="block m-auto mb-4 w-40" />
</IonToolbar>
</IonHeader>

<div className='w-100'>
{
isLoginWithEmailScreen ? <LoginWithEmail setIsLoginWithEmailScreen={setIsLoginWithEmailScreen}/> : <Login setIsLoginWithEmailScreen={setIsLoginWithEmailScreen}/>
}
</div>
</div>
</IonContent>
</IonPage>
)

}

export default AuthContainer;
6 changes: 3 additions & 3 deletions mobile/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from "@ionic/react"
import { BiChat, BiHash, BiUser } from "react-icons/bi"
import { Redirect, Route } from "react-router-dom"
import { Route } from "react-router-dom"
import { Channels } from "../../pages/channels"
import { DirectMessageList } from "../../pages/direct-messages/DirectMessageList"
import { Profile } from "../../pages/profile"
import { PropsWithChildren, useContext } from "react"
import { UserContext } from "../../utils/auth/UserProvider"
import { FullPageLoader } from "./loaders/FullPageLoader"
import { Login } from "../../pages/auth/Login"
import AuthContainer from "./AuthContainer"

export const Navbar = () => {
const { currentUser, isLoading } = useContext(UserContext)
Expand Down Expand Up @@ -68,7 +68,7 @@ export const ProtectedRoute = ({ children }: PropsWithChildren) => {
return <FullPageLoader />
}
if (!currentUser || currentUser === 'Guest') {
return <Login />
return <AuthContainer />
} else {
return children
}
Expand Down
273 changes: 158 additions & 115 deletions mobile/src/pages/auth/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,128 +1,171 @@
import { IonButton, IonContent, IonHeader, IonImg, IonInput, IonItem, IonPage, IonSpinner, IonTitle, IonToolbar, IonText, IonRow } from '@ionic/react'
import { ErrorBanner } from '../../components/layout'
import {SuccessCallout, CalloutObject} from '@/components/common/SuccessCallout'
import raven_logo from '../../assets/raven_logo.png'
import { useContext, useState } from 'react'
import { UserContext } from '../../utils/auth/UserProvider'
import { IonButton, IonInput, IonItem, IonSpinner, IonText } from '@ionic/react'
import { ErrorCallout } from '@/components/common/Callouts'
import { useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { LoginWithEmail } from '@/pages/auth/LoginWithEmail'
import { BiLogoFacebookCircle, BiLogoGithub, BiLogoGoogle, BiMailSend } from 'react-icons/bi'
import { useFrappeGetCall, FrappeError, useFrappeAuth, AuthResponse } from 'frappe-react-sdk'
import { LoginContext, LoginInputs } from '@/types/Auth/Login'
import { TwoFactor } from './TwoFactor'
import { LoginWithEmailProps } from './LoginWithEmail'

type Inputs = {
email: string,
password: string
const SocialProviderIcons = {
"github": <BiLogoGithub size="18" />,
"google": <BiLogoGoogle size="18" />,
"facebook": <BiLogoFacebookCircle size="18" />
}

export const Login = () => {
interface SocialProvider {
name: 'github' | 'google' | 'facebook'
provider_name: string,
auth_url: string,
redirect_to: string,
icon: {
src: string,
alt: string
},
}

const [error, setError] = useState<Error | null>(null)
const { login } = useContext(UserContext)
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>()
const [loading, setLoading] = useState(false)
const [callout, setCallout] = useState<CalloutObject | null>(null)
const [isLoginWithEmailLink, setIsLoginWithEmailLink] = useState<boolean>(false)

// to show/unshow login with email section
const onClickLoginWithEmail = () =>{
setError(null)
setCallout(null)
setIsLoginWithEmailLink(!isLoginWithEmailLink)
}
export const Login = (props: LoginWithEmailProps) => {

const { control, handleSubmit,formState: { errors,isSubmitting } } = useForm<LoginInputs>()
// GET call for Login Context (settings for social logins, email link etc)
const { data: loginContext, mutate } = useFrappeGetCall<LoginContext>('raven.api.login.get_context', {
"redirect-to": "/raven"
}, 'raven.api.login.get_context', {
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false
})
const [error, setError] = useState<FrappeError | null>(null)

const { login } = useFrappeAuth()
// 2FA switch enabled and 2FA response
const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState<boolean>(false)
const [loginWithTwoFAResponse, setLoginWithTwoFAResponse] = useState<AuthResponse | null>(null)

async function onSubmit(values: Inputs) {

async function onSubmit(values: LoginInputs) {
setError(null)
setLoading(true)
return login(values.email, values.password).catch((error) => { setError(error) }).finally(() => setLoading(false))
if (loginContext?.message?.two_factor_is_enabled) {
// first 2FA call to send temp id and verification to user
return login({ username: values.email, password: values.password }).then((res: AuthResponse) => {
if (res?.verification && res?.tmp_id) {
setIsTwoFactorEnabled(true)
setLoginWithTwoFAResponse(res)
}
}).catch((error) => setError(error))
} else {
// if 2FA is disabled, do normal login
return login({ username: values.email, password: values.password }).then(() => {
//Reload the page so that the boot info is fetched again
const URL = import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ``
window.location.replace(`${URL}/channel`)
}).catch((error) => { setError(error) })
}
}

return (
<IonPage>
<IonHeader translucent>
<IonToolbar>
<IonTitle>Raven</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className='ion-padding'>
<div slot='fixed' className="left-0 right-0 top-1/4 p-2 transform justify-center items-center">
<IonHeader collapse="condense" translucent>
<IonToolbar>
<IonImg src={raven_logo} alt="Raven Logo" className="block m-auto mb-4 w-40" />
</IonToolbar>
</IonHeader>
{error && <ErrorBanner overrideHeading={error.message} />}
{ callout && <SuccessCallout message={callout.message}/> }
{
isLoginWithEmailLink ?
<LoginWithEmail
setCallout={setCallout}
setError={setError}
onClickLoginWithEmail={onClickLoginWithEmail}
/>:
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<IonItem>
<Controller
name="email"
control={control}
rules={{
required: "Email/Username is required",
}}
render={({ field }) => <IonInput
onIonChange={(e) => field.onChange(e.detail.value)}
required
placeholder='jane@example.com'
className={!!errors?.email ? 'ion-invalid ion-touched' : ''}
label='Email/Username'
errorText={errors?.email?.message}
inputMode='email'
labelPlacement='stacked'
/>}
/>
</IonItem>
<IonItem>
<Controller
name="password"
control={control}
rules={{
required: "Password is required."
}}
render={({ field }) => <IonInput
type="password"
onIonChange={(e) => field.onChange(e.detail.value)}
required
errorText={errors?.password?.message}
placeholder='********'
className={!!errors?.password ? 'ion-invalid ion-touched' : ''}
label='Password'
labelPlacement='stacked'
/>}
/>
</IonItem>
<IonButton
type="submit"
className='ion-margin-top'
expand="block"
>
{loading ? <IonSpinner name="crescent" /> : "Login"}
</IonButton>
</form>
<IonRow class="ion-justify-content-center ion-margin-top ion-margin-bottom">
<IonText color="medium" >
or
</IonText>
</IonRow>
<IonButton
type="button"
onClick={onClickLoginWithEmail}
expand="block"
>
{loading ? <IonSpinner name="crescent" /> : "Login With Email Link"}
</IonButton>
</div>
}

</div>
</IonContent>
<>
{error && <ErrorCallout message={error.message} />}
{
isTwoFactorEnabled ? <TwoFactor loginWithTwoFAResponse={loginWithTwoFAResponse} setIsTwoFactorEnabled={setIsTwoFactorEnabled} /> :
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<IonItem>
<Controller
name="email"
control={control}
rules={{
required: "Email/Username is required",
}}
render={({ field }) => <IonInput
onIonChange={(e) => field.onChange(e.detail.value)}
required
placeholder='jane@example.com'
className={!!errors?.email ? 'ion-invalid ion-touched' : ''}
label='Email/Username'
errorText={errors?.email?.message}
inputMode='email'
labelPlacement='stacked'
/>}
/>
</IonItem>
<IonItem>
<Controller
name="password"
control={control}
rules={{
required: "Password is required."
}}
render={({ field }) => <IonInput
type="password"
onIonChange={(e) => field.onChange(e.detail.value)}
required
errorText={errors?.password?.message}
placeholder='********'
className={!!errors?.password ? 'ion-invalid ion-touched' : ''}
label='Password'
labelPlacement='stacked'
/>}
/>
</IonItem>
<IonButton
type="submit"
className='ion-margin-top'
expand="block"
>
{isSubmitting ? <IonSpinner name="crescent" /> : "Login"}
</IonButton>
</form>
{/* Show Separator only when either Email Link or Social Logins are enabled */}
{
loginContext?.message?.login_with_email_link || loginContext?.message?.social_login ?
// <IonRow className="mt-8 mb-8">
<div className="flex flex-col w-full items-center">
<IonText>OR</IonText>
</div>
: null
}
{/* Map all social oauth providers */}
{
loginContext?.message?.social_login ? loginContext?.message?.provider_logins.map((soc: SocialProvider, i: number) => {
return (
<IonButton className='ion-margin-top' fill="outline" type="button" expand="block" size="default" href={soc.auth_url}>
{/* <Link to={soc.auth_url} className="items-center"> */}
<div className='flex items-center'>
<div className='flex mr-1'>
{SocialProviderIcons[soc.name] ? SocialProviderIcons[soc.name] : <img src={soc.icon.src} alt={soc.icon.alt} ></img>}
</div>
<IonText color="dark">Login with {soc.provider_name}</IonText>
</div>

{/* </Link> */}
</IonButton>

)
}) : null
}

</IonPage>
{
loginContext?.message?.login_with_email_link ?

<IonButton
disabled={isSubmitting}
expand="block"
className="ion-margin-top cursor-default"
fill='outline'
type='button'
size="default"
onClick={()=>props.setIsLoginWithEmailScreen(true)}
>
<BiMailSend size="18" className="mr-1" />
<IonText color="dark">Login with Email Link</IonText>
</IonButton>
: null
}
</div>
}
</>
)
}
}
Loading

0 comments on commit b8374e7

Please sign in to comment.