Selaa lähdekoodia

chore: move 2fa related code into it's own OtpForm component

Nicolas Meienberger 2 vuotta sitten
vanhempi
commit
2c2843f926

+ 307 - 0
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx

@@ -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 - 0
src/client/modules/Settings/components/OtpForm/OtpForm.tsx

@@ -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 - 0
src/client/modules/Settings/components/OtpForm/index.ts

@@ -0,0 +1 @@
+export { OtpForm } from './OtpForm';

+ 1 - 299
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx

@@ -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();
-    });
-  });
 });

+ 2 - 154
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx

@@ -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>
   );
 };