chore: bump next-safe-action

This commit is contained in:
Nicolas Meienberger 2023-10-01 19:09:02 +02:00 committed by Nicolas Meienberger
parent 1b434e7355
commit fbefc31bd3
8 changed files with 16 additions and 668 deletions

View file

@ -62,7 +62,7 @@
"lodash.merge": "^4.6.2",
"next": "13.5.3",
"next-intl": "^2.20.0",
"next-safe-action": "^3.0.1",
"next-safe-action": "^3.4.0",
"pg": "^8.11.1",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
@ -158,8 +158,6 @@
},
"homepage": "https://github.com/meienberger/runtipi#readme",
"pnpm": {
"patchedDependencies": {
"next-safe-action@3.0.1": "patches/next-safe-action@3.0.1.patch"
}
"patchedDependencies": {}
}
}

View file

@ -1,312 +0,0 @@
diff --git a/dist/hook.d.ts b/dist/hook.d.ts
index 42220517df6bad298dd77f2e1961d6798ecfef0d..7b32f3634610ae20c0b108034a7da8048bc96205 100644
--- a/dist/hook.d.ts
+++ b/dist/hook.d.ts
@@ -1,5 +1,9 @@
-import { z } from 'zod';
-import { C as ClientCaller, H as HookCallbacks, a as HookRes } from './types-31a698ec.js';
+import { z } from "zod";
+import {
+ C as ClientCaller,
+ H as HookCallbacks,
+ a as HookRes,
+} from "./types-31a698ec.js";
/**
* Use the action from a Client Component via hook.
@@ -8,14 +12,17 @@ import { C as ClientCaller, H as HookCallbacks, a as HookRes } from './types-31a
*
* {@link https://github.com/TheEdoRan/next-safe-action/tree/main/packages/next-safe-action#2-the-hook-way See an example}
*/
-declare const useAction: <const IV extends z.ZodTypeAny, const Data>(clientCaller: ClientCaller<IV, Data>, cb?: HookCallbacks<IV, Data> | undefined) => {
- execute: (input: z.input<IV>) => void;
- isExecuting: boolean;
- res: HookRes<IV, Data>;
- reset: () => void;
- hasExecuted: boolean;
- hasSucceded: boolean;
- hasErrored: boolean;
+declare const useAction: <const IV extends z.ZodTypeAny, const Data>(
+ clientCaller: ClientCaller<IV, Data>,
+ cb?: HookCallbacks<IV, Data> | undefined
+) => {
+ execute: (input: z.input<IV>) => void;
+ isExecuting: boolean;
+ res: HookRes<IV, Data>;
+ reset: () => void;
+ hasExecuted: boolean;
+ hasSucceded: boolean;
+ hasErrored: boolean;
};
/**
* Use the action from a Client Component via hook, with optimistic data update.
@@ -27,15 +34,22 @@ declare const useAction: <const IV extends z.ZodTypeAny, const Data>(clientCalle
*
* {@link https://github.com/TheEdoRan/next-safe-action/tree/main/packages/next-safe-action#optimistic-update--experimental See an example}
*/
-declare const useOptimisticAction: <const IV extends z.ZodTypeAny, const Data>(clientCaller: ClientCaller<IV, Data>, initialOptData: Data, cb?: HookCallbacks<IV, Data> | undefined) => {
- execute: (input: z.input<IV>, newOptimisticData: Partial<Data>) => Promise<void>;
- isExecuting: boolean;
- res: HookRes<IV, Data>;
- optimisticData: Data;
- reset: () => void;
- hasExecuted: boolean;
- hasSucceded: boolean;
- hasErrored: boolean;
+declare const useOptimisticAction: <const IV extends z.ZodTypeAny, const Data>(
+ clientCaller: ClientCaller<IV, Data>,
+ initialOptData: Data,
+ cb?: HookCallbacks<IV, Data> | undefined
+) => {
+ execute: (
+ input: z.input<IV>,
+ newOptimisticData: Partial<Data>
+ ) => Promise<void>;
+ isExecuting: boolean;
+ res: HookRes<IV, Data>;
+ optimisticData: Data;
+ reset: () => void;
+ hasExecuted: boolean;
+ hasSucceded: boolean;
+ hasErrored: boolean;
};
export { HookCallbacks, HookRes, useAction, useOptimisticAction };
diff --git a/dist/hook.mjs b/dist/hook.mjs
index 89d77b1da720c87508e525f09e2c0005a1986819..5b0d50450441734813511cce9c6df203104c6f66 100644
--- a/dist/hook.mjs
+++ b/dist/hook.mjs
@@ -1,3 +1,5 @@
+"use client";
+
// src/hook.ts
import {
experimental_useOptimistic,
@@ -5,31 +7,34 @@ import {
useEffect,
useRef,
useState,
- useTransition
+ useTransition,
} from "react";
// src/utils.ts
-var isNextRedirectError = (e) => e instanceof Error && e.message === "NEXT_REDIRECT";
-var isNextNotFoundError = (e) => e instanceof Error && e.message === "NEXT_NOT_FOUND";
+var isError = (e) => e instanceof Error;
+var isNextRedirectError = (e) => isError(e) && e.message === "NEXT_REDIRECT";
+var isNextNotFoundError = (e) => isError(e) && e.message === "NEXT_NOT_FOUND";
// src/hook.ts
-"use client";
var getActionStatus = (res) => {
const hasSucceded = typeof res.data !== "undefined";
- const hasErrored = typeof res.validationError !== "undefined" || typeof res.serverError !== "undefined" || typeof res.fetchError !== "undefined";
+ const hasErrored =
+ typeof res.validationError !== "undefined" ||
+ typeof res.serverError !== "undefined" ||
+ typeof res.fetchError !== "undefined";
const hasExecuted = hasSucceded || hasErrored;
return { hasExecuted, hasSucceded, hasErrored };
};
-var useActionCallbacks = (res, hasSucceded, hasErrored, reset, cb) => {
+var useActionCallbacks = (input, res, hasSucceded, hasErrored, reset, cb) => {
const onSuccessRef = useRef(cb?.onSuccess);
const onErrorRef = useRef(cb?.onError);
useEffect(() => {
const onSuccess = onSuccessRef.current;
const onError = onErrorRef.current;
if (onSuccess && hasSucceded) {
- onSuccess(res.data, reset);
+ onSuccess(res.data, reset, input);
} else if (onError && hasErrored) {
- onError(res, reset);
+ onError(res, reset, input);
}
}, [hasErrored, hasSucceded, res, reset]);
};
@@ -37,21 +42,31 @@ var useAction = (clientCaller, cb) => {
const [isExecuting, startTransition] = useTransition();
const executor = useRef(clientCaller);
const [res, setRes] = useState({});
+ const [input, setInput] = useState();
+ const onExecuteRef = useRef(cb?.onExecute);
const { hasExecuted, hasSucceded, hasErrored } = getActionStatus(res);
- const execute = useCallback((input) => {
+ const execute = useCallback((input2) => {
+ setInput(input2);
+ const onExecute = onExecuteRef.current;
+ if (onExecute) {
+ onExecute(input2);
+ }
return startTransition(() => {
- return executor.current(input).then((res2) => setRes(res2)).catch((e) => {
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
- throw e;
- }
- setRes({ fetchError: e });
- });
+ return executor
+ .current(input2)
+ .then((res2) => setRes(res2))
+ .catch((e) => {
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
+ throw e;
+ }
+ setRes({ fetchError: e });
+ });
});
}, []);
const reset = useCallback(() => {
setRes({});
}, []);
- useActionCallbacks(res, hasSucceded, hasErrored, reset, cb);
+ useActionCallbacks(input, res, hasSucceded, hasErrored, reset, cb);
return {
execute,
isExecuting,
@@ -59,34 +74,47 @@ var useAction = (clientCaller, cb) => {
reset,
hasExecuted,
hasSucceded,
- hasErrored
+ hasErrored,
};
};
var useOptimisticAction = (clientCaller, initialOptData, cb) => {
const [res, setRes] = useState({});
- const [optState, syncState] = experimental_useOptimistic({ ...initialOptData, ...res.data, __isExecuting__: false }, (state, newState) => ({
- ...state,
- ...newState,
- __isExecuting__: true
- }));
+ const [input, setInput] = useState();
+ const [optState, syncState] = experimental_useOptimistic(
+ { ...initialOptData, ...res.data, __isExecuting__: false },
+ (state, newState) => ({
+ ...state,
+ ...newState,
+ __isExecuting__: true,
+ })
+ );
const executor = useRef(clientCaller);
+ const onExecuteRef = useRef(cb?.onExecute);
const { hasExecuted, hasSucceded, hasErrored } = getActionStatus(res);
const execute = useCallback(
- (input, newOptimisticData) => {
+ (input2, newOptimisticData) => {
syncState(newOptimisticData);
- return executor.current(input).then((res2) => setRes(res2)).catch((e) => {
- if (isNextRedirectError(e) || isNextNotFoundError(e)) {
- throw e;
- }
- setRes({ fetchError: e });
- });
+ setInput(input2);
+ const onExecute = onExecuteRef.current;
+ if (onExecute) {
+ onExecute(input2);
+ }
+ return executor
+ .current(input2)
+ .then((res2) => setRes(res2))
+ .catch((e) => {
+ if (isNextRedirectError(e) || isNextNotFoundError(e)) {
+ throw e;
+ }
+ setRes({ fetchError: e });
+ });
},
[syncState]
);
const reset = useCallback(() => {
setRes({});
}, []);
- useActionCallbacks(res, hasSucceded, hasErrored, reset, cb);
+ useActionCallbacks(input, res, hasSucceded, hasErrored, reset, cb);
const { __isExecuting__, ...optimisticData } = optState;
return {
execute,
@@ -97,11 +125,8 @@ var useOptimisticAction = (clientCaller, initialOptData, cb) => {
reset,
hasExecuted,
hasSucceded,
- hasErrored
+ hasErrored,
};
};
-export {
- useAction,
- useOptimisticAction
-};
+export { useAction, useOptimisticAction };
//# sourceMappingURL=hook.mjs.map
diff --git a/dist/types-31a698ec.d.ts b/dist/types-31a698ec.d.ts
index c9339a0a530dd1825af0c6e1b4a6e9a58adc3c97..1eb8b8303d1534deee9f0084b6c62c9eabb147b2 100644
--- a/dist/types-31a698ec.d.ts
+++ b/dist/types-31a698ec.d.ts
@@ -1,30 +1,53 @@
-import { z } from 'zod';
+import { z } from "zod";
/**
* Type of the function called from Client Components with typesafe input data for the Server Action.
*/
-type ClientCaller<IV extends z.ZodTypeAny, Data> = (input: z.input<IV>) => Promise<{
- data?: Data;
- serverError?: true;
- validationError?: Partial<Record<keyof z.input<IV>, string[]>>;
+type ClientCaller<IV extends z.ZodTypeAny, Data> = (
+ input: z.input<IV>
+) => Promise<{
+ data?: Data;
+ serverError?: string;
+ validationError?: Partial<Record<keyof z.input<IV>, string[]>>;
}>;
/**
* Type of the function that executes server code when defining a new safe action.
*/
-type ActionServerFn<IV extends z.ZodTypeAny, Data, Context extends object> = (parsedInput: z.input<IV>, ctx: Context) => Promise<Data>;
+type ActionServerFn<IV extends z.ZodTypeAny, Data, Context extends object> = (
+ parsedInput: z.input<IV>,
+ ctx: Context
+) => Promise<Data>;
/**
* Type of `res` object returned by `useAction` and `useOptimisticAction` hooks.
*/
-type HookRes<IV extends z.ZodTypeAny, Data> = Awaited<ReturnType<ClientCaller<IV, Data>>> & {
- fetchError?: unknown;
+type HookRes<IV extends z.ZodTypeAny, Data> = Awaited<
+ ReturnType<ClientCaller<IV, Data>>
+> & {
+ fetchError?: unknown;
};
/**
* Type of hooks callbacks (`onSuccess` and `onError`).
* These are executed when the action succeeds or fails.
*/
type HookCallbacks<IV extends z.ZodTypeAny, Data> = {
- onSuccess?: (data: NonNullable<Pick<HookRes<IV, Data>, "data">["data"]>, reset: () => void) => void;
- onError?: (error: Omit<HookRes<IV, Data>, "data">, reset: () => void) => void;
+ onSuccess?: (
+ data: NonNullable<Pick<HookRes<IV, Data>, "data">["data"]>,
+ reset: () => void,
+ input: z.input<IV>
+ ) => void;
+ onError?: (
+ error: Omit<HookRes<IV, Data>, "data">,
+ reset: () => void,
+ input: z.input<IV>
+ ) => void;
+ onExecute?: (input: z.input<IV>) => unknown;
};
+type MaybePromise<T> = T | Promise<T>;
-export { ActionServerFn as A, ClientCaller as C, HookCallbacks as H, HookRes as a };
+export {
+ ActionServerFn as A,
+ ClientCaller as C,
+ HookCallbacks as H,
+ MaybePromise as M,
+ HookRes as a,
+};

View file

@ -1,10 +1,5 @@
lockfileVersion: '6.0'
patchedDependencies:
next-safe-action@3.0.1:
hash: i3wdronoj5npuplkzavpvyaj6e
path: patches/next-safe-action@3.0.1.patch
importers:
.:
@ -100,8 +95,8 @@ importers:
specifier: ^2.20.0
version: 2.20.0(next@13.5.3)(react@18.2.0)
next-safe-action:
specifier: ^3.0.1
version: 3.0.1(patch_hash=i3wdronoj5npuplkzavpvyaj6e)(next@13.5.3)(react@18.2.0)(zod@3.21.4)
specifier: ^3.4.0
version: 3.4.0(next@13.5.3)(react@18.2.0)(zod@3.21.4)
pg:
specifier: ^8.11.1
version: 8.11.1
@ -525,7 +520,7 @@ packages:
'@babel/helper-validator-option': 7.22.5
browserslist: 4.21.5
lru-cache: 5.1.1
semver: 6.3.0
semver: 6.3.1
/@babel/helper-environment-visitor@7.22.5:
resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==}
@ -2055,7 +2050,7 @@ packages:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.5.3
semver: 7.5.4
tar: 6.1.13
transitivePeerDependencies:
- encoding
@ -3781,7 +3776,7 @@ packages:
'@typescript-eslint/typescript-estree': 5.59.11(typescript@5.2.2)
eslint: 8.50.0
eslint-scope: 5.1.1
semver: 7.5.3
semver: 7.5.4
transitivePeerDependencies:
- supports-color
- typescript
@ -5086,6 +5081,7 @@ packages:
/defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
requiresBuild: true
dependencies:
clone: 1.0.4
dev: true
@ -5644,7 +5640,7 @@ packages:
eslint-plugin-import: 2.27.5(@typescript-eslint/parser@6.7.3)(eslint-import-resolver-typescript@3.5.5)(eslint@8.50.0)
object.assign: 4.1.4
object.entries: 1.1.6
semver: 6.3.0
semver: 6.3.1
dev: true
/eslint-config-airbnb-typescript@17.0.0(@typescript-eslint/eslint-plugin@6.7.3)(@typescript-eslint/parser@6.7.3)(eslint-plugin-import@2.27.5)(eslint@8.50.0):
@ -6049,7 +6045,7 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
hasBin: true
dependencies:
'@eslint-community/eslint-utils': 4.3.0(eslint@8.50.0)
'@eslint-community/eslint-utils': 4.4.0(eslint@8.50.0)
'@eslint-community/regexpp': 4.9.0
'@eslint/eslintrc': 2.1.2
'@eslint/js': 8.50.0
@ -8918,8 +8914,8 @@ packages:
react: 18.2.0
dev: true
/next-safe-action@3.0.1(patch_hash=i3wdronoj5npuplkzavpvyaj6e)(next@13.5.3)(react@18.2.0)(zod@3.21.4):
resolution: {integrity: sha512-qQOHz4Z1vnW9fKAl3+nmSoONtX8kvqJBJJ4PkRlkSF8AfFJnYp7PZ5qvtdIBTzxNoQLtM/CyVqlAM/6dCHJ62w==}
/next-safe-action@3.4.0(next@13.5.3)(react@18.2.0)(zod@3.21.4):
resolution: {integrity: sha512-EUmcChSlfIjdHu2n6L4w3EQnb1Np9C2OEWObPSKggdK/IhihDDatsunZKGcuimxFO3JbBdpFiUYcMe2slbovUg==}
engines: {node: '>=16'}
peerDependencies:
next: '>= 13.4.2'
@ -8930,7 +8926,6 @@ packages:
react: 18.2.0
zod: 3.21.4
dev: false
patched: true
/next@13.5.3(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6):
resolution: {integrity: sha512-4Nt4HRLYDW/yRpJ/QR2t1v63UOMS55A38dnWv3UDOWGezuY0ZyFO1ABNbD7mulVzs9qVhgy2+ppjdsANpKP1mg==}
@ -10689,7 +10684,7 @@ packages:
methods: 1.1.2
mime: 2.6.0
qs: 6.11.0
semver: 7.5.3
semver: 7.5.4
transitivePeerDependencies:
- supports-color
dev: true

View file

@ -1,55 +1,9 @@
import React from 'react';
import { server } from '@/client/mocks/server';
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { faker } from '@faker-js/faker';
import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
import { ChangePasswordForm } from './ChangePasswordForm';
describe('<ChangePasswordForm />', () => {
it('should show success toast upon password change', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'changePassword'], type: 'mutation', response: true }));
render(<ChangePasswordForm />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
const newPassword = faker.string.alphanumeric(8);
// act
fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
fireEvent.change(newPasswordInput, { target: { value: newPassword } });
fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText('Password changed successfully')).toBeInTheDocument();
});
});
it('should show error toast if change password failed', async () => {
// arrange
server.use(getTRPCMockError({ path: ['auth', 'changePassword'], type: 'mutation', message: 'Invalid password' }));
render(<ChangePasswordForm />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
const newPassword = faker.string.alphanumeric(8);
// act
fireEvent.change(currentPasswordInput, { target: { value: faker.string.alphanumeric(8) } });
fireEvent.change(newPasswordInput, { target: { value: newPassword } });
fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
});
});
it('should show error in the form if passwords do not match', async () => {
// arrange
render(<ChangePasswordForm />);

View file

@ -1,285 +0,0 @@
import React from 'react';
import { server } from '@/client/mocks/server';
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
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: { totpEnabled: true, id: 12, username: 'test', locale: 'en', operator: true } }));
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
server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test', locale: 'en', operator: true } }));
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 two-factor authentication/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
});
});
it('should show show error toast if password is incorrect while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
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 two-factor authentication/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid password/)).toBeInTheDocument();
});
});
it('should show success toast if password is correct while disabling 2FA', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
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 two-factor authentication/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText('Two-factor authentication disabled')).toBeInTheDocument();
});
});
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 two-factor authentication/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 two-factor authentication' })).toBeDisabled();
});
it('should show error toast if submitted totp code is invalid', async () => {
// arrange
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 two-factor authentication/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 two-factor authentication' });
enable2FAButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid code/)).toBeInTheDocument();
});
});
it('should show success toast if submitted totp code is valid', async () => {
// arrange
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 two-factor authentication/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 two-factor authentication' });
enable2FAButton.click();
// assert
await waitFor(() => {
expect(screen.getByText('Two-factor authentication enabled')).toBeInTheDocument();
});
});
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: { locale: 'en', totpEnabled: true, username: '', id: 1, operator: 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();
});
fireEvent.keyDown(document, { key: 'Escape' });
// assert
await waitFor(() => {
expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
});
});
});

View file

@ -50,7 +50,6 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
// ctx.auth.me.invalidate();
}
},
});
@ -65,7 +64,6 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
toast.error(data.failure.reason);
} else {
toast.success(t('2fa-disable-success'));
//ctx.auth.me.invalidate();
}
},
});

View file

@ -4,6 +4,6 @@ import { SecurityContainer } from './SecurityContainer';
describe('<SecurityContainer />', () => {
it('should render', () => {
render(<SecurityContainer />);
render(<SecurityContainer totpEnabled={false} />);
});
});

View file

@ -5,11 +5,11 @@ import React from 'react';
import { SystemServiceClass } from '@/server/services/system';
import { getSettings } from '@/server/core/TipiConfig';
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { SettingsTabTriggers } from './components/SettingsTabTriggers';
import { GeneralActions } from './components/GeneralActions';
import { SettingsContainer } from './components/SettingsContainer';
import { SecurityContainer } from './components/SecurityContainer';
import { getUserFromCookie } from '@/server/common/session.helpers';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
@ -29,7 +29,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
return (
<div className="card d-flex">
<Tabs defaultValue={(tab as string) || 'actions'}>
<Tabs defaultValue={tab || 'actions'}>
<SettingsTabTriggers />
<TabsContent value="actions">
<GeneralActions version={version} />