chore: move 2fa related code into it's own OtpForm component
This commit is contained in:
parent
d841c43b77
commit
5f32cb23fa
5 changed files with 473 additions and 453 deletions
307
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
Normal file
307
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
Normal file
|
@ -0,0 +1,307 @@
|
|||
import React from 'react';
|
||||
import { server } from '@/client/mocks/server';
|
||||
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
|
||||
import { useToastStore } from '@/client/state/toastStore';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
|
||||
import { OtpForm } from './OtpForm';
|
||||
|
||||
describe('<OtpForm />', () => {
|
||||
it('should render', () => {
|
||||
render(<OtpForm />);
|
||||
});
|
||||
|
||||
it('should prompt for password when enabling 2FA', async () => {
|
||||
// arrange
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should prompt for password when disabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show show error toast if password is incorrect while enabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid password');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<OtpForm />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid password');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success toast if password is correct while disabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
|
||||
|
||||
render(<OtpForm />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('success');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Success');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show secret key and QR code when enabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'secret key' })).toHaveValue('test');
|
||||
expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast if submitted totp code is invalid', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'setupTotp'], type: 'mutation', message: 'Invalid code' }));
|
||||
|
||||
render(<OtpForm />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
|
||||
|
||||
inputEls.forEach((inputEl) => {
|
||||
fireEvent.change(inputEl, { target: { value: '1' } });
|
||||
});
|
||||
|
||||
const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
|
||||
enable2FAButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid code');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success toast if submitted totp code is valid', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'setupTotp'], type: 'mutation', response: true }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
|
||||
|
||||
inputEls.forEach((inputEl) => {
|
||||
fireEvent.change(inputEl, { target: { value: '1' } });
|
||||
});
|
||||
|
||||
const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
|
||||
enable2FAButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('success');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Success');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('can close the setup modal by clicking on the esc key', async () => {
|
||||
// arrange
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can close the disable modal by clicking on the esc key', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
|
||||
render(<OtpForm />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
162
src/client/modules/Settings/components/OtpForm/OtpForm.tsx
Normal file
162
src/client/modules/Settings/components/OtpForm/OtpForm.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { trpc } from '@/utils/trpc';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { IconLock } from '@tabler/icons-react';
|
||||
import { useToastStore } from '@/client/state/toastStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { OtpInput } from '@/components/ui/OtpInput';
|
||||
|
||||
export const OtpForm = () => {
|
||||
const { addToast } = useToastStore();
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [key, setKey] = React.useState('');
|
||||
const [uri, setUri] = React.useState('');
|
||||
const [totpCode, setTotpCode] = React.useState('');
|
||||
|
||||
// Dialog statuses
|
||||
const [isSetupTotpOpen, setIsSetupTotpOpen] = React.useState(false);
|
||||
const [isDisableTotpOpen, setIsDisableTotpOpen] = React.useState(false);
|
||||
|
||||
const ctx = trpc.useContext();
|
||||
const me = trpc.auth.me.useQuery();
|
||||
|
||||
const getTotpUri = trpc.auth.getTotpUri.useMutation({
|
||||
onMutate: () => {
|
||||
setIsSetupTotpOpen(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPassword('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setKey(data.key);
|
||||
setUri(data.uri);
|
||||
},
|
||||
});
|
||||
|
||||
const setupTotp = trpc.auth.setupTotp.useMutation({
|
||||
onMutate: () => {},
|
||||
onError: (e) => {
|
||||
setTotpCode('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTotpCode('');
|
||||
setKey('');
|
||||
setUri('');
|
||||
addToast({ title: 'Success', description: 'Two-factor authentication enabled', status: 'success' });
|
||||
ctx.auth.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const disableTotp = trpc.auth.disableTotp.useMutation({
|
||||
onMutate: () => {
|
||||
setIsDisableTotpOpen(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPassword('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast({ title: 'Success', description: 'Two-factor authentication disabled', status: 'success' });
|
||||
ctx.auth.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const renderSetupQr = () => {
|
||||
if (!uri || me.data?.totp_enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Scan this QR code with your authenticator app.</p>
|
||||
<QRCodeSVG value={uri} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Or enter this key manually.</p>
|
||||
<Input name="secret key" value={key} readOnly />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Enter the code from your authenticator app.</p>
|
||||
<OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
|
||||
<Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTotp = (enabled: boolean) => {
|
||||
if (enabled) setIsSetupTotpOpen(true);
|
||||
else {
|
||||
setIsDisableTotpOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex">
|
||||
<IconLock className="me-2" />
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
</div>
|
||||
<p className="text-muted">
|
||||
Two-factor authentication (2FA) adds an additional layer of security to your account.
|
||||
<br />
|
||||
When enabled, you will be prompted to enter a code from your authenticator app when you log in.
|
||||
</p>
|
||||
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
|
||||
{getTotpUri.isLoading && (
|
||||
<div className="progress w-50">
|
||||
<div className="progress-bar progress-bar-indeterminate bg-green" />
|
||||
</div>
|
||||
)}
|
||||
{renderSetupQr()}
|
||||
<Dialog open={isSetupTotpOpen} onOpenChange={(o) => setIsSetupTotpOpen(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password needed</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="d-flex flex-column">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
getTotpUri.mutate({ password });
|
||||
}}
|
||||
>
|
||||
<p className="text-muted">Your password is required to setup two-factor authentication.</p>
|
||||
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
|
||||
<Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isDisableTotpOpen} onOpenChange={(o) => setIsDisableTotpOpen(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password needed</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="d-flex flex-column">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
disableTotp.mutate({ password });
|
||||
}}
|
||||
>
|
||||
<p className="text-muted">Your password is required to disable two-factor authentication.</p>
|
||||
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
|
||||
<Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
1
src/client/modules/Settings/components/OtpForm/index.ts
Normal file
1
src/client/modules/Settings/components/OtpForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { OtpForm } from './OtpForm';
|
|
@ -1,307 +1,9 @@
|
|||
import React from 'react';
|
||||
import { server } from '@/client/mocks/server';
|
||||
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
|
||||
import { useToastStore } from '@/client/state/toastStore';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
|
||||
import { render } from '../../../../../../tests/test-utils';
|
||||
import { SecurityContainer } from './SecurityContainer';
|
||||
|
||||
describe('<SecurityContainer />', () => {
|
||||
it('should render', () => {
|
||||
render(<SecurityContainer />);
|
||||
});
|
||||
|
||||
it('should prompt for password when enabling 2FA', async () => {
|
||||
// arrange
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should prompt for password when disabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show show error toast if password is incorrect while enabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid password');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
|
||||
render(<SecurityContainer />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid password');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success toast if password is correct while disabling 2FA', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
|
||||
|
||||
render(<SecurityContainer />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('success');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Success');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show secret key and QR code when enabling 2FA', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'secret key' })).toHaveValue('test');
|
||||
expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast if submitted totp code is invalid', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'setupTotp'], type: 'mutation', message: 'Invalid code' }));
|
||||
|
||||
render(<SecurityContainer />);
|
||||
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
|
||||
|
||||
inputEls.forEach((inputEl) => {
|
||||
fireEvent.change(inputEl, { target: { value: '1' } });
|
||||
});
|
||||
|
||||
const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
|
||||
enable2FAButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('error');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Error');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Invalid code');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success toast if submitted totp code is valid', async () => {
|
||||
// arrange
|
||||
const { result } = renderHook(() => useToastStore());
|
||||
server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'setupTotp'], type: 'mutation', response: true }));
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
|
||||
submitButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
|
||||
|
||||
inputEls.forEach((inputEl) => {
|
||||
fireEvent.change(inputEl, { target: { value: '1' } });
|
||||
});
|
||||
|
||||
const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
|
||||
enable2FAButton.click();
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]?.status).toEqual('success');
|
||||
expect(result.current.toasts[0]?.title).toEqual('Success');
|
||||
expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication enabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('can close the setup modal by clicking on the esc key', async () => {
|
||||
// arrange
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can close the disable modal by clicking on the esc key', async () => {
|
||||
// arrange
|
||||
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
|
||||
render(<SecurityContainer />);
|
||||
const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
|
||||
await waitFor(() => {
|
||||
expect(twoFactorAuthButton).toBeEnabled();
|
||||
});
|
||||
|
||||
// act
|
||||
twoFactorAuthButton.click();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,162 +1,10 @@
|
|||
import { Switch } from '@/components/ui/Switch';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import React from 'react';
|
||||
import { trpc } from '@/utils/trpc';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useToastStore } from '@/client/state/toastStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { OtpInput } from '@/components/ui/OtpInput';
|
||||
import { IconLock } from '@tabler/icons-react';
|
||||
import { OtpForm } from '../../components/OtpForm';
|
||||
|
||||
export const SecurityContainer = () => {
|
||||
const { addToast } = useToastStore();
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [key, setKey] = React.useState('');
|
||||
const [uri, setUri] = React.useState('');
|
||||
const [totpCode, setTotpCode] = React.useState('');
|
||||
|
||||
// Dialog statuses
|
||||
const [isSetupTotpOpen, setIsSetupTotpOpen] = React.useState(false);
|
||||
const [isDisableTotpOpen, setIsDisableTotpOpen] = React.useState(false);
|
||||
|
||||
const ctx = trpc.useContext();
|
||||
const me = trpc.auth.me.useQuery();
|
||||
|
||||
const getTotpUri = trpc.auth.getTotpUri.useMutation({
|
||||
onMutate: () => {
|
||||
setIsSetupTotpOpen(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPassword('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setKey(data.key);
|
||||
setUri(data.uri);
|
||||
},
|
||||
});
|
||||
|
||||
const setupTotp = trpc.auth.setupTotp.useMutation({
|
||||
onMutate: () => {},
|
||||
onError: (e) => {
|
||||
setTotpCode('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTotpCode('');
|
||||
setKey('');
|
||||
setUri('');
|
||||
addToast({ title: 'Success', description: 'Two-factor authentication enabled', status: 'success' });
|
||||
ctx.auth.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const disableTotp = trpc.auth.disableTotp.useMutation({
|
||||
onMutate: () => {
|
||||
setIsDisableTotpOpen(false);
|
||||
},
|
||||
onError: (e) => {
|
||||
setPassword('');
|
||||
addToast({ title: 'Error', description: e.message, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast({ title: 'Success', description: 'Two-factor authentication disabled', status: 'success' });
|
||||
ctx.auth.me.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleTotp = (enabled: boolean) => {
|
||||
if (enabled) setIsSetupTotpOpen(true);
|
||||
else {
|
||||
setIsDisableTotpOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSetupQr = () => {
|
||||
if (!uri || me.data?.totp_enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Scan this QR code with your authenticator app.</p>
|
||||
<QRCodeSVG value={uri} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Or enter this key manually.</p>
|
||||
<Input name="secret key" value={key} readOnly />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-muted">Enter the code from your authenticator app.</p>
|
||||
<OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
|
||||
<Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-body">
|
||||
<div className="d-flex">
|
||||
<IconLock className="me-2" />
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
</div>
|
||||
<p className="text-muted">
|
||||
Two-factor authentication (2FA) adds an additional layer of security to your account.
|
||||
<br />
|
||||
When enabled, you will be prompted to enter a code from your authenticator app when you log in.
|
||||
</p>
|
||||
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
|
||||
{getTotpUri.isLoading && (
|
||||
<div className="progress w-50">
|
||||
<div className="progress-bar progress-bar-indeterminate bg-green" />
|
||||
</div>
|
||||
)}
|
||||
{renderSetupQr()}
|
||||
<Dialog open={isSetupTotpOpen} onOpenChange={(o) => setIsSetupTotpOpen(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password needed</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="d-flex flex-column">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
getTotpUri.mutate({ password });
|
||||
}}
|
||||
>
|
||||
<p className="text-muted">Your password is required to setup two-factor authentication.</p>
|
||||
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
|
||||
<Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isDisableTotpOpen} onOpenChange={(o) => setIsDisableTotpOpen(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password needed</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="d-flex flex-column">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
disableTotp.mutate({ password });
|
||||
}}
|
||||
>
|
||||
<p className="text-muted">Your password is required to disable two-factor authentication.</p>
|
||||
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
|
||||
<Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</form>
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<OtpForm />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue