feat: add 2fa form on login if user has it enabled
This commit is contained in:
parent
274d5e4a9a
commit
2e50af2a58
6 changed files with 172 additions and 10 deletions
|
@ -61,6 +61,7 @@ export const handlers = [
|
|||
path: ['auth', 'me'],
|
||||
type: 'query',
|
||||
response: {
|
||||
totp_enabled: false,
|
||||
id: faker.datatype.number(),
|
||||
username: faker.internet.userName(),
|
||||
},
|
||||
|
|
|
@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
|
|||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="h2 text-center mb-3">Login to your account</h2>
|
||||
<Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
|
||||
<Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
|
||||
<span className="form-label-description">
|
||||
<Link href="/reset-password">Forgot password?</Link>
|
||||
</span>
|
||||
<Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
|
||||
<Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
|
||||
<Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
|
||||
Login
|
||||
</Button>
|
||||
|
|
32
src/client/modules/Auth/components/TotpForm/TotpForm.tsx
Normal file
32
src/client/modules/Auth/components/TotpForm/TotpForm.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Button } from '@/components/ui/Button';
|
||||
import { OtpInput } from '@/components/ui/OtpInput';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
onSubmit: (totpCode: string) => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const TotpForm = (props: Props) => {
|
||||
const { onSubmit, loading } = props;
|
||||
const [totpCode, setTotpCode] = React.useState('');
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
setTotpCode('');
|
||||
e.preventDefault();
|
||||
onSubmit(totpCode);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<h3 className="">Two-factor authentication</h3>
|
||||
<p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
|
||||
<OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
|
||||
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
1
src/client/modules/Auth/components/TotpForm/index.ts
Normal file
1
src/client/modules/Auth/components/TotpForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { TotpForm } from './TotpForm';
|
|
@ -28,8 +28,8 @@ describe('Test: LoginContainer', () => {
|
|||
// Arrange
|
||||
render(<LoginContainer />);
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByLabelText('Email address');
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
// Act
|
||||
fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
|
||||
|
@ -49,8 +49,8 @@ describe('Test: LoginContainer', () => {
|
|||
|
||||
// Act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByLabelText('Email address');
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
|
@ -70,8 +70,8 @@ describe('Test: LoginContainer', () => {
|
|||
|
||||
// Act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByLabelText('Email address');
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
|
@ -86,4 +86,111 @@ describe('Test: LoginContainer', () => {
|
|||
const token = localStorage.getItem('token');
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('should show totp form if totpSessionId is returned', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.datatype.uuid();
|
||||
server.use(
|
||||
getTRPCMock({
|
||||
path: ['auth', 'login'],
|
||||
type: 'mutation',
|
||||
response: { totpSessionId },
|
||||
}),
|
||||
);
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if totp code is invalid', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.datatype.uuid();
|
||||
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
|
||||
|
||||
totpInputs.forEach((input, index) => {
|
||||
fireEvent.change(input, { target: { value: index } });
|
||||
});
|
||||
|
||||
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(totpSubmitButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0].description).toEqual('Invalid totp code');
|
||||
expect(result.current.toasts[0].status).toEqual('error');
|
||||
});
|
||||
});
|
||||
|
||||
it('should add token in localStorage if totp code is valid', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.datatype.uuid();
|
||||
const token = faker.datatype.uuid();
|
||||
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: { token } }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
|
||||
|
||||
totpInputs.forEach((input, index) => {
|
||||
fireEvent.change(input, { target: { value: index } });
|
||||
});
|
||||
|
||||
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(totpSubmitButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('token')).toEqual(token);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useToastStore } from '../../../../state/toastStore';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
import { AuthFormLayout } from '../../components/AuthFormLayout';
|
||||
import { LoginForm } from '../../components/LoginForm';
|
||||
import { TotpForm } from '../../components/TotpForm';
|
||||
|
||||
type FormValues = { email: string; password: string };
|
||||
|
||||
export const LoginContainer: React.FC = () => {
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const { addToast } = useToastStore();
|
||||
const utils = trpc.useContext();
|
||||
|
@ -16,6 +18,21 @@ export const LoginContainer: React.FC = () => {
|
|||
localStorage.removeItem('token');
|
||||
addToast({ title: 'Login error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.totpSessionId) {
|
||||
setTotpSessionId(data.totpSessionId);
|
||||
} else if (data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
utils.auth.me.invalidate();
|
||||
router.push('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = trpc.auth.verifyTotp.useMutation({
|
||||
onError: (e) => {
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem('token', data.token);
|
||||
utils.auth.me.invalidate();
|
||||
|
@ -29,7 +46,11 @@ export const LoginContainer: React.FC = () => {
|
|||
|
||||
return (
|
||||
<AuthFormLayout>
|
||||
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
|
||||
{totpSessionId ? (
|
||||
<TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
|
||||
) : (
|
||||
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
|
||||
)}
|
||||
</AuthFormLayout>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue