Merge pull request #350 from meienberger/release/1.2.0

Release: 1.2.0
This commit is contained in:
Nicolas Meienberger 2023-04-11 22:18:10 +02:00 committed by GitHub
commit 69386778a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 3035 additions and 972 deletions

View file

@ -1,5 +1,5 @@
module.exports = {
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'import'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'jsx-a11y'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
@ -11,6 +11,7 @@ module.exports = {
'prettier',
'plugin:react/recommended',
'plugin:jsdoc/recommended',
'plugin:jsx-a11y/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {

View file

@ -12,5 +12,15 @@ export const createClient = jest.fn(() => {
quit: jest.fn(),
del: (key: string) => values.delete(key),
ttl: (key: string) => expirations.get(key),
keys: (key: string) => {
const keys = [];
// eslint-disable-next-line no-restricted-syntax
for (const [k] of values) {
if (k.startsWith(key)) {
keys.push(k);
}
}
return keys;
},
};
});

View file

@ -56,6 +56,7 @@ services:
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
networks:
- tipi_main_network
ports:

View file

@ -68,6 +68,7 @@ services:
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
volumes:
- ${PWD}/.env:/runtipi/.env
- ${PWD}/state:/runtipi/state

View file

@ -70,6 +70,7 @@ services:
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
volumes:
- ${PWD}/.env:/runtipi/.env
- ${PWD}/state:/runtipi/state

View file

@ -68,6 +68,7 @@ services:
DOMAIN: ${DOMAIN}
ARCHITECTURE: ${ARCHITECTURE}
REDIS_HOST: ${REDIS_HOST}
DEMO_MODE: ${DEMO_MODE}
volumes:
- ${PWD}/.env:/runtipi/.env
- ${PWD}/state:/runtipi/state

View file

@ -1,6 +1,6 @@
{
"name": "runtipi",
"version": "1.1.2",
"version": "1.2.0",
"description": "A homeserver for everyone",
"scripts": {
"copy:migrations": "mkdir -p dist/migrations && cp -r ./src/server/migrations dist",
@ -30,7 +30,12 @@
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@prisma/client": "^4.11.0",
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1",
"@prisma/client": "^4.12.0",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@runtipi/postgres-migrations": "^5.3.0",
"@tabler/core": "1.0.0-beta17",
@ -51,12 +56,14 @@
"node-cron": "^3.0.1",
"node-fetch-commonjs": "^3.2.4",
"pg": "^8.10.0",
"qrcode.react": "^3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.7",
"react-hot-toast": "^2.4.0",
"react-markdown": "^8.0.3",
"react-select": "^5.6.1",
"react-tooltip": "^4.4.3",
"react-tooltip": "^5.10.5",
"redis": "^4.6.5",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
@ -115,7 +122,7 @@
"next-router-mock": "^0.9.2",
"nodemon": "^2.0.21",
"prettier": "^2.8.4",
"prisma": "^4.11.0",
"prisma": "^4.12.0",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"typescript": "5.0.2",

359
pnpm-lock.yaml generated
View file

@ -4,9 +4,24 @@ dependencies:
'@hookform/resolvers':
specifier: ^2.9.10
version: 2.9.11(react-hook-form@7.43.7)
'@otplib/core':
specifier: ^12.0.1
version: 12.0.1
'@otplib/plugin-crypto':
specifier: ^12.0.1
version: 12.0.1
'@otplib/plugin-thirty-two':
specifier: ^12.0.1
version: 12.0.1
'@prisma/client':
specifier: ^4.11.0
version: 4.11.0(prisma@4.11.0)
specifier: ^4.12.0
version: 4.12.0(prisma@4.12.0)
'@radix-ui/react-dialog':
specifier: ^1.0.3
version: 1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-switch':
specifier: ^1.0.2
version: 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tabs':
specifier: ^1.0.3
version: 1.0.3(react-dom@18.2.0)(react@18.2.0)
@ -67,6 +82,9 @@ dependencies:
pg:
specifier: ^8.10.0
version: 8.10.0
qrcode.react:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@ -76,6 +94,9 @@ dependencies:
react-hook-form:
specifier: ^7.43.7
version: 7.43.7(react@18.2.0)
react-hot-toast:
specifier: ^2.4.0
version: 2.4.0(csstype@3.1.1)(react-dom@18.2.0)(react@18.2.0)
react-markdown:
specifier: ^8.0.3
version: 8.0.5(@types/react@18.0.28)(react@18.2.0)
@ -83,8 +104,8 @@ dependencies:
specifier: ^5.6.1
version: 5.7.0(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
react-tooltip:
specifier: ^4.4.3
version: 4.5.1(react-dom@18.2.0)(react@18.2.0)
specifier: ^5.10.5
version: 5.10.5(react-dom@18.2.0)(react@18.2.0)
redis:
specifier: ^4.6.5
version: 4.6.5
@ -256,8 +277,8 @@ devDependencies:
specifier: ^2.8.4
version: 2.8.4
prisma:
specifier: ^4.11.0
version: 4.11.0
specifier: ^4.12.0
version: 4.12.0
ts-jest:
specifier: ^29.0.3
version: 29.0.5(@babel/core@7.21.3)(esbuild@0.16.17)(jest@29.5.0)(typescript@5.0.2)
@ -1476,6 +1497,23 @@ packages:
resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==}
dev: true
/@otplib/core@12.0.1:
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
dev: false
/@otplib/plugin-crypto@12.0.1:
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
dependencies:
'@otplib/core': 12.0.1
dev: false
/@otplib/plugin-thirty-two@12.0.1:
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
dev: false
/@phc/format@1.0.0:
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@ -1497,8 +1535,8 @@ packages:
resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==}
dev: false
/@prisma/client@4.11.0(prisma@4.11.0):
resolution: {integrity: sha512-0INHYkQIqgAjrt7NzhYpeDQi8x3Nvylc2uDngKyFDDj1tTRQ4uV1HnVmd1sQEraeVAN63SOK0dgCKQHlvjL0KA==}
/@prisma/client@4.12.0(prisma@4.12.0):
resolution: {integrity: sha512-j9/ighfWwux97J2dS15nqhl60tYoH8V0IuSsgZDb6bCFcQD3fXbXmxjYC8GHhIgOk3lB7Pq+8CwElz2MiDpsSg==}
engines: {node: '>=14.17'}
requiresBuild: true
peerDependencies:
@ -1507,16 +1545,16 @@ packages:
prisma:
optional: true
dependencies:
'@prisma/engines-version': 4.11.0-57.8fde8fef4033376662cad983758335009d522acb
prisma: 4.11.0
'@prisma/engines-version': 4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7
prisma: 4.12.0
dev: false
/@prisma/engines-version@4.11.0-57.8fde8fef4033376662cad983758335009d522acb:
resolution: {integrity: sha512-3Vd8Qq06d5xD8Ch5WauWcUUrsVPdMC6Ge8ILji8RFfyhUpqon6qSyGM0apvr1O8n8qH8cKkEFqRPsYjuz5r83g==}
/@prisma/engines-version@4.12.0-67.659ef412370fa3b41cd7bf6e94587c1dfb7f67e7:
resolution: {integrity: sha512-JIHNj5jlXb9mcaJwakM0vpgRYJIAurxTUqM0iX0tfEQA5XLZ9ONkIckkhuAKdAzocZ+80GYg7QSsfpjg7OxbOA==}
dev: false
/@prisma/engines@4.11.0:
resolution: {integrity: sha512-0AEBi2HXGV02cf6ASsBPhfsVIbVSDC9nbQed4iiY5eHttW9ZtMxHThuKZE1pnESbr8HRdgmFSa/Kn4OSNYuibg==}
/@prisma/engines@4.12.0:
resolution: {integrity: sha512-0alKtnxhNB5hYU+ymESBlGI4b9XrGGSdv7Ud+8TE/fBNOEhIud0XQsAR+TrvUZgS4na5czubiMsODw0TUrgkIA==}
requiresBuild: true
/@radix-ui/primitive@1.0.0:
@ -1558,6 +1596,33 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dialog@1.0.3(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.3(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.0(react@18.2.0)
'@radix-ui/react-portal': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.1(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.0.28)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-direction@1.0.0(react@18.2.0):
resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==}
peerDependencies:
@ -1567,6 +1632,45 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.0.2(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-guards@1.0.0(react@18.2.0):
resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
react: 18.2.0
dev: false
/@radix-ui/react-focus-scope@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-spwXlNTfeIprt+kaEWE/qYuYT3ZAqJiAGjN/JgdvgVDTu8yc+HuX+WOWXrKliKnLnwck0F6JDkqIERncnih+4A==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-id@1.0.0(react@18.2.0):
resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==}
peerDependencies:
@ -1577,6 +1681,18 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-portal@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
peerDependencies:
@ -1632,6 +1748,24 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-switch@1.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-BcG/LKehxt36NXG0wPnoCitIfSMtU9Xo7BmythYA1PAMLtsMvW7kALfBzmduQoHTWcKr0AVcFyh0gChBUp9TiQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.2(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.0(react@18.2.0)
'@radix-ui/react-use-size': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-tabs@1.0.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-4CkF/Rx1GcrusI/JZ1Rvyx4okGUs6wEenWA0RG/N+CwkRhTy7t54y7BLsWUXrAz/GRbBfHQg/Odfs/RoW0CiRA==}
peerDependencies:
@ -1670,6 +1804,16 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0):
resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0):
resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==}
peerDependencies:
@ -1679,6 +1823,25 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-previous@1.0.0(react@18.2.0):
resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
react: 18.2.0
dev: false
/@radix-ui/react-use-size@1.0.0(react@18.2.0):
resolution: {integrity: sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.20.13
'@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@redis/bloom@1.2.0(@redis/client@1.5.6):
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
@ -2616,6 +2779,13 @@ packages:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
dev: true
/aria-hidden@1.2.3:
resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==}
engines: {node: '>=10'}
dependencies:
tslib: 2.5.0
dev: false
/aria-query@5.1.3:
resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==}
dependencies:
@ -2996,6 +3166,10 @@ packages:
resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==}
dev: true
/classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
dev: false
/cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
@ -3370,6 +3544,10 @@ packages:
engines: {node: '>=8'}
dev: true
/detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dev: false
/diff-sequences@29.4.3:
resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -4401,6 +4579,11 @@ packages:
has: 1.0.3
has-symbols: 1.0.3
/get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
dev: false
/get-package-type@0.1.0:
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
engines: {node: '>=8.0.0'}
@ -4510,6 +4693,14 @@ packages:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
dev: true
/goober@2.1.12(csstype@3.1.1):
resolution: {integrity: sha512-yXHAvO08FU1JgTXX6Zn6sYCUFfB/OJSX8HHjDSgerZHZmFKAb08cykp5LBw5QnmyMcZyPRMqkdyHUSSzge788Q==}
peerDependencies:
csstype: ^3.0.10
dependencies:
csstype: 3.1.1
dev: false
/gopd@1.0.1:
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
@ -4735,6 +4926,12 @@ packages:
side-channel: 1.0.4
dev: true
/invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
dependencies:
loose-envify: 1.4.0
dev: false
/ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -6905,13 +7102,13 @@ packages:
react-is: 18.2.0
dev: true
/prisma@4.11.0:
resolution: {integrity: sha512-4zZmBXssPUEiX+GeL0MUq/Yyie4ltiKmGu7jCJFnYMamNrrulTBc+D+QwAQSJ01tyzeGHlD13kOnqPwRipnlNw==}
/prisma@4.12.0:
resolution: {integrity: sha512-xqVper4mbwl32BWzLpdznHAYvYDWQQWK2tBfXjdUD397XaveRyAP7SkBZ6kFlIg8kKayF4hvuaVtYwXd9BodAg==}
engines: {node: '>=14.17'}
hasBin: true
requiresBuild: true
dependencies:
'@prisma/engines': 4.11.0
'@prisma/engines': 4.12.0
/prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
@ -6964,6 +7161,14 @@ packages:
resolution: {integrity: sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg==}
dev: true
/qrcode.react@3.1.0(react@18.2.0):
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/qs@6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
@ -7022,6 +7227,20 @@ packages:
react: 18.2.0
dev: false
/react-hot-toast@2.4.0(csstype@3.1.1)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
goober: 2.1.12(csstype@3.1.1)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -7059,6 +7278,41 @@ packages:
- supports-color
dev: false
/react-remove-scroll-bar@2.3.4(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.0.28
react: 18.2.0
react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
tslib: 2.5.0
dev: false
/react-remove-scroll@2.5.5(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.0.28
react: 18.2.0
react-remove-scroll-bar: 2.3.4(@types/react@18.0.28)(react@18.2.0)
react-style-singleton: 2.2.1(@types/react@18.0.28)(react@18.2.0)
tslib: 2.5.0
use-callback-ref: 1.3.0(@types/react@18.0.28)(react@18.2.0)
use-sidecar: 1.1.2(@types/react@18.0.28)(react@18.2.0)
dev: false
/react-select@5.7.0(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lJGiMxCa3cqnUr2Jjtg9YHsaytiZqeNOKeibv6WF5zbK/fPegZ1hg3y/9P1RZVLhqBTs0PfqQLKuAACednYGhQ==}
peerDependencies:
@ -7088,17 +7342,33 @@ packages:
react: 18.2.0
dev: false
/react-tooltip@4.5.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Zo+CSFUGXar1uV+bgXFFDe7VeS2iByeIp5rTgTcc2HqtuOS5D76QapejNNfx320MCY91TlhTQat36KGFTqgcvw==}
engines: {npm: '>=6.13'}
/react-style-singleton@2.2.1(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16.0.0'
react-dom: '>=16.0.0'
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
prop-types: 15.8.1
'@types/react': 18.0.28
get-nonce: 1.0.1
invariant: 2.2.4
react: 18.2.0
tslib: 2.5.0
dev: false
/react-tooltip@5.10.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-3bi4UtoPSdaQh0R17B3vMPhNFiATpAbXIV8AqlHqrrIdqo33OJyxuPHtgborw3KXVQ5a6iyyAmCY8ztjUB4CrA==}
peerDependencies:
react: '>=16.14.0'
react-dom: '>=16.14.0'
dependencies:
'@floating-ui/dom': 1.2.1
classnames: 2.3.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
uuid: 7.0.3
dev: false
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
@ -7779,6 +8049,11 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
dev: false
/through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: true
@ -8107,6 +8382,21 @@ packages:
requires-port: 1.0.0
dev: true
/use-callback-ref@1.3.0(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.0.28
react: 18.2.0
tslib: 2.5.0
dev: false
/use-isomorphic-layout-effect@1.1.2(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
@ -8120,6 +8410,22 @@ packages:
react: 18.2.0
dev: false
/use-sidecar@1.1.2(@types/react@18.0.28)(react@18.2.0):
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.0.28
detect-node-es: 1.1.0
react: 18.2.0
tslib: 2.5.0
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
@ -8146,11 +8452,6 @@ packages:
engines: {node: '>= 0.4.0'}
dev: false
/uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
hasBin: true
dev: false
/uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true

View file

@ -42,12 +42,15 @@ model Update {
}
model User {
id Int @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement())
username String @unique(map: "UQ_78a916df40e02a9deb1c4b75edb") @db.VarChar
password String @db.VarChar
createdAt DateTime @default(now()) @db.Timestamp(6)
updatedAt DateTime @default(now()) @db.Timestamp(6)
operator Boolean @default(false)
id Int @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement())
username String @unique(map: "UQ_78a916df40e02a9deb1c4b75edb") @db.VarChar
password String @db.VarChar
createdAt DateTime @default(now()) @db.Timestamp(6)
updatedAt DateTime @default(now()) @db.Timestamp(6)
operator Boolean @default(false)
totp_secret String?
totp_enabled Boolean @default(false)
salt String?
@@map("user")
}

View file

@ -89,8 +89,14 @@ mkdir -p media/usenet/watch
mkdir -p media/usenet/completed
mkdir -p media/usenet/incomplete
mkdir -p media/downloads
mkdir -p media/downloads/watch
mkdir -p media/downloads/completed
mkdir -p media/downloads/incomplete
mkdir -p media/data
mkdir -p media/data/books
mkdir -p media/data/comics
mkdir -p media/data/movies
mkdir -p media/data/music
mkdir -p media/data/tv

View file

@ -1,10 +1,11 @@
import clsx from 'clsx';
import React from 'react';
import { Tooltip } from 'react-tooltip';
import * as AppTypes from '../../core/types';
import styles from './AppStatus.module.scss';
export const AppStatus: React.FC<{ status: AppTypes.AppStatus; lite?: boolean }> = ({ status, lite }) => {
const formattedStatus = `${status[0]}${status.substring(1, status.length).toLowerCase()}`;
const formattedStatus = `${status[0]?.toUpperCase()}${status.substring(1, status.length).toLowerCase()}`;
const classes = clsx('status-dot status-gray', {
'status-dot-animated status-green': status === 'running',
@ -12,9 +13,12 @@ export const AppStatus: React.FC<{ status: AppTypes.AppStatus; lite?: boolean }>
});
return (
<div data-place="top" data-tip={lite && formattedStatus} className="d-flex align-items-center">
<span className={classes} />
{!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
</div>
<>
{lite && <Tooltip id={formattedStatus} anchorSelect=".appStatus" place="top" />}
<div data-tooltip-content={formattedStatus} data-tooltip-id={formattedStatus} className="appStatus d-flex align-items-center">
<span className={classes} />
{!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
</div>
</>
);
};

View file

@ -1,6 +1,7 @@
import Link from 'next/link';
import React from 'react';
import { IconDownload } from '@tabler/icons-react';
import { Tooltip } from 'react-tooltip';
import { AppStatus } from '../AppStatus';
import { AppLogo } from '../AppLogo/AppLogo';
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
@ -30,9 +31,12 @@ export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; update
</div>
</div>
{updateAvailable && (
<div data-tip="Update available" className="ribbon bg-green ribbon-top">
<IconDownload size={20} />
</div>
<>
<Tooltip anchorSelect=".updateAvailable">Update available</Tooltip>
<div className="updateAvailable ribbon bg-green ribbon-top">
<IconDownload size={20} />
</div>
</>
)}
</Link>
</div>

View file

@ -2,7 +2,6 @@ import Head from 'next/head';
import Link from 'next/link';
import React, { useEffect } from 'react';
import clsx from 'clsx';
import ReactTooltip from 'react-tooltip';
import semver from 'semver';
import { useRouter } from 'next/router';
import { Header } from '../ui/Header';
@ -20,7 +19,7 @@ interface IProps {
export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
const router = useRouter();
const refreshToken = trpc.auth.refreshToken.useMutation({
const { mutate } = trpc.auth.refreshToken.useMutation({
onSuccess: (data) => {
if (data?.token) localStorage.setItem('token', data.token);
},
@ -31,8 +30,8 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
});
useEffect(() => {
refreshToken.mutate();
}, []);
mutate();
}, [mutate]);
const { version } = useSystemStore();
const defaultVersion = '0.0.0';
@ -61,7 +60,6 @@ export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions
<Head>
<title>{`${title} - Tipi`}</title>
</Head>
<ReactTooltip offset={{ right: 1 }} effect="solid" place="bottom" />
<Header isUpdateAvailable={!isLatest} />
<div className="page-wrapper">
<div className="page-header d-print-none">

View file

@ -1,70 +0,0 @@
import React from 'react';
import { act, render, renderHook, screen, waitFor } from '../../../../../tests/test-utils';
import { useToastStore } from '../../../state/toastStore';
import { ToastProvider } from './ToastProvider';
describe('Test: ToastProvider', () => {
it("should render it's children", async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
await waitFor(() => {
expect(screen.getByText('children')).toBeInTheDocument();
});
});
it('should render Toasts', async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
const { result } = renderHook(() => useToastStore());
act(() => {
result.current.addToast({
status: 'success',
title: 'title',
description: 'description',
id: 'id',
});
});
await waitFor(() => {
expect(screen.getByText('title')).toBeInTheDocument();
});
});
it('should remove Toasts when the close button is clicked', async () => {
render(
<ToastProvider>
<div>children</div>
</ToastProvider>,
);
const { result } = renderHook(() => useToastStore());
act(() => {
result.current.addToast({
status: 'success',
title: 'title',
description: 'description',
id: 'id',
});
});
await waitFor(() => {
expect(screen.getByText('title')).toBeInTheDocument();
});
act(() => {
screen.getByTestId('toast-close-button').click();
});
await waitFor(() => {
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
});
});

View file

@ -1,24 +0,0 @@
import React from 'react';
import { IToast, useToastStore } from '../../../state/toastStore';
import { Toast } from '../../ui/Toast';
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { toasts, removeToast } = useToastStore();
const renderToast = (toast: IToast) => {
const { status, title, description, id } = toast;
return <Toast status={status} title={title} message={description} id={id} onClose={() => removeToast(id)} />;
};
return (
<>
{toasts.map((toast) => (
<div key={toast.id} className="position-fixed bottom-0 end-0 p-3" style={{ zIndex: 11 }}>
{renderToast(toast)}
</div>
))}
{children}
</>
);
};

View file

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

View file

@ -15,12 +15,11 @@
background-color: rgba(0, 0, 0, 0);
}
to {
background-color: rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.5);
}
}
.dimmedBackground {
background-color: rgba(0, 0, 0, 0.8);
animation-name: dimmedBackground;
animation-duration: 0.2s;
animation-iteration-count: 1;

View file

@ -0,0 +1,68 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import clsx from 'clsx';
import styles from './Dialog.module.scss';
type Sizes = 'sm' | 'md' | 'lg' | 'xl';
type ModalType = 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
type ModalProps = {
size?: Sizes;
type?: ModalType;
};
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps & ModalProps) => (
<DialogPrimitive.Portal className={clsx(className)} {...props}>
<div className={clsx('modal modal-sm d-block', styles.dimmedBackground)}>
<div className={clsx(`modal-dialog modal-dialog-centered modal-${props.size || 'lg'}`, styles.zoomIn)}>
<div className="shadow modal-content">
<DialogPrimitive.Close className="btn-close mt-1">
<button data-testid="modal-close-button" type="button" className="btn-close" aria-label="Close" />
</DialogPrimitive.Close>
<div data-testid="modal-status" className={clsx('modal-status', { [`bg-${props.type}`]: Boolean(props.type), 'd-none': !props.type })} />
{children}
</div>
</div>
</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Overlay className={clsx('', className)} {...props} ref={ref} />
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & ModalProps>(
({ className, children, ...props }, ref) => (
<DialogPortal type={props.type} size={props.size}>
<DialogPrimitive.Content ref={ref} className={clsx('modal-content mt-1', className)} {...props}>
{children}
</DialogPrimitive.Content>
</DialogPortal>
),
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div data-testid="modal-header" className={clsx('modal-header', className)} {...props} />;
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div className={clsx('modal-footer', className)} {...props} />;
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={clsx('modal-title', className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={clsx('modal-body', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };

View file

@ -0,0 +1 @@
export { Dialog, DialogTitle, DialogFooter, DialogHeader, DialogContent, DialogTrigger, DialogDescription } from './Dialog';

View file

@ -5,8 +5,7 @@ import { Header } from './Header';
describe('Header', () => {
it('renders without crashing', () => {
const { container } = render(<Header />);
expect(container).toBeInTheDocument();
render(<Header />);
});
it('renders the brand logo', () => {
@ -16,22 +15,22 @@ describe('Header', () => {
});
it('renders the dark mode toggle', () => {
const { container } = render(<Header />);
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
render(<Header />);
const darkModeToggle = screen.getByTestId('dark-mode-toggle');
expect(darkModeToggle).toContainElement(screen.getByTestId('icon-moon'));
});
it('renders the light mode toggle', () => {
const { container } = render(<Header />);
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
render(<Header />);
const lightModeToggle = screen.getByTestId('light-mode-toggle');
expect(lightModeToggle).toContainElement(screen.getByTestId('icon-sun'));
});
it('Should toggle the dark mode on click of the dark mode toggle', () => {
const { result } = renderHook(() => useUIStore());
const { container } = render(<Header />);
const darkModeToggle = container.querySelector('[data-tip="Dark mode"]');
render(<Header />);
const darkModeToggle = screen.getByTestId('dark-mode-toggle');
fireEvent.click(darkModeToggle as Element);
expect(result.current.darkMode).toBe(true);
@ -40,8 +39,8 @@ describe('Header', () => {
it('Should toggle the dark mode on click of the light mode toggle', () => {
const { result } = renderHook(() => useUIStore());
const { container } = render(<Header />);
const lightModeToggle = container.querySelector('[data-tip="Light mode"]');
render(<Header />);
const lightModeToggle = screen.getByTestId('light-mode-toggle');
fireEvent.click(lightModeToggle as Element);
expect(result.current.darkMode).toBe(false);
@ -49,8 +48,8 @@ describe('Header', () => {
it('Should remove the token from local storage on logout', async () => {
localStorage.setItem('token', 'token');
const { container } = render(<Header />);
const logoutButton = container.querySelector('[data-tip="Log out"]');
render(<Header />);
const logoutButton = screen.getByTestId('logout-button');
fireEvent.click(logoutButton as Element);
await waitFor(() => {

View file

@ -4,6 +4,7 @@ import Image from 'next/image';
import clsx from 'clsx';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Tooltip } from 'react-tooltip';
import { getUrl } from '../../../core/helpers/url-helpers';
import { useUIStore } from '../../../state/uiStore';
import { NavBar } from '../NavBar';
@ -62,14 +63,17 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
</a>
</div>
</div>
<div className="d-flex">
<div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="nav-link px-0 hide-theme-dark cursor-pointer" data-tip="Dark mode">
<div style={{ zIndex: 1 }} className="d-flex">
<Tooltip anchorSelect=".darkMode">Dark mode</Tooltip>
<div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="darkMode nav-link px-0 hide-theme-dark cursor-pointer" data-testid="dark-mode-toggle">
<IconMoon data-testid="icon-moon" size={20} />
</div>
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
<Tooltip anchorSelect=".lightMode">Light mode</Tooltip>
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="lightMode nav-link px-0 hide-theme-light cursor-pointer" data-testid="light-mode-toggle">
<IconSun data-testid="icon-sun" size={20} />
</div>
<div onClick={() => logout.mutate()} tabIndex={0} onKeyPress={() => logout.mutate()} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
<Tooltip anchorSelect=".logOut">Log out</Tooltip>
<div onClick={() => logout.mutate()} tabIndex={0} onKeyPress={() => logout.mutate()} role="button" className="logOut nav-link px-0 cursor-pointer" data-testid="logout-button">
<IconLogout size={20} />
</div>
</div>

View file

@ -13,16 +13,20 @@ interface IProps {
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
disabled?: boolean;
value?: string;
readOnly?: boolean;
}
export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => (
export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly }, ref) => (
<div className={clsx(className)}>
{label && (
<label htmlFor={name} className="form-label">
{label}
</label>
)}
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<input
aria-label={name}
role="textbox"
disabled={disabled}
name={name}
id={name}
@ -33,6 +37,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
ref={ref}
className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
placeholder={placeholder}
readOnly={readOnly}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>

View file

@ -1,141 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render } from '../../../../../tests/test-utils';
import { Modal } from './Modal';
import { ModalBody } from './ModalBody';
import { ModalFooter } from './ModalFooter';
import { ModalHeader } from './ModalHeader';
describe('Modal component', () => {
it('should render without errors', () => {
const { container } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
expect(container).toBeTruthy();
});
it('should not be visible by default', () => {
const { queryByTestId } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
// display should be none
expect(queryByTestId('modal')).toHaveStyle('display: none');
});
it('should be visible when `isOpen` prop is true', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<p>Test modal content</p>
</Modal>,
);
// display should be block
expect(getByTestId('modal')).toHaveStyle('display: block');
});
it('should not be visible when `isOpen` prop is false', () => {
const { queryByTestId } = render(
<Modal onClose={() => {}}>
<p>Test modal content</p>
</Modal>,
);
expect(queryByTestId('modal')).toHaveStyle('display: none');
});
it('should call the `onClose` prop when the close button is clicked', () => {
const onClose = jest.fn();
const { getByLabelText } = render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.click(getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
it('should call the `onClose` callback when user clicks outside of the modal', () => {
const onClose = jest.fn();
const { container } = render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.click(container);
expect(onClose).toHaveBeenCalled();
});
it('should have the correct `size` class when the `size` prop is passed', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen size="sm">
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal')).toHaveClass('modal-sm');
});
it('should have the correct `type` class when the `type` prop is passed', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen type="primary">
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal-status')).toHaveClass('bg-primary');
expect(getByTestId('modal-status')).not.toHaveClass('d-none');
});
it('should render the modal content as a child of the modal', () => {
const { getByTestId, getByText } = render(
<Modal onClose={() => {}} isOpen>
<p>Test modal content</p>
</Modal>,
);
expect(getByTestId('modal')).toContainElement(getByText('Test modal content'));
});
it('should call the `onClose` callback when the escape key is pressed', () => {
const onClose = jest.fn();
render(
<Modal onClose={onClose} isOpen>
<p>Test modal content</p>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('should correctly render with ModalBody', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalBody>
<p>Test modal content</p>
</ModalBody>
</Modal>,
);
expect(getByTestId('modal-body')).toBeInTheDocument();
});
it('should correctly render with ModalFooter', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalFooter>
<p>Test modal content</p>
</ModalFooter>
</Modal>,
);
expect(getByTestId('modal-footer')).toBeInTheDocument();
});
it('should correctly render with ModalHeader', () => {
const { getByTestId } = render(
<Modal onClose={() => {}} isOpen>
<ModalHeader>
<p>Test modal content</p>
</ModalHeader>
</Modal>,
);
expect(getByTestId('modal-header')).toBeInTheDocument();
});
});

View file

@ -1,65 +0,0 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react';
import styles from './Modal.module.scss';
interface IProps {
children: React.ReactNode;
isOpen?: boolean;
onClose: () => void;
size?: 'sm' | 'md' | 'lg' | 'xl';
type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
}
export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg', type }) => {
const style = { display: 'none' };
if (isOpen) {
style.display = 'block';
}
const [modal, setModal] = useState<HTMLDivElement | null>(null);
// On click outside
const handleClickOutside = useCallback(
(event: MouseEvent) => {
if (modal && !modal.contains(event.target as Node)) {
onClose();
}
},
[modal, onClose],
);
// On click outside
useEffect(() => {
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
}, [handleClickOutside]);
// Close on escape
const handleEscape = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
},
[onClose],
);
// Close on escape
useEffect(() => {
document.addEventListener('keydown', handleEscape, true);
return () => document.removeEventListener('keydown', handleEscape, true);
}, [handleEscape]);
return (
<div data-testid="modal" className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style} role="dialog">
<div ref={setModal} className={clsx(`modal-dialog modal-dialog-centered modal-${size}`, styles.zoomIn)} role="document">
<div className="shadow modal-content">
<button data-testid="modal-close-button" type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
<div data-testid="modal-status" className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
{children}
</div>
</div>
</div>
);
};

View file

@ -1,13 +0,0 @@
import clsx from 'clsx';
import React from 'react';
interface IProps {
children: React.ReactNode;
className?: string;
}
export const ModalBody: React.FC<IProps> = ({ children, className }) => (
<div data-testid="modal-body" className={clsx('modal-body', className)}>
{children}
</div>
);

View file

@ -1,11 +0,0 @@
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const ModalFooter: React.FC<IProps> = ({ children }) => (
<div data-testid="modal-footer" className="modal-footer">
{children}
</div>
);

View file

@ -1,11 +0,0 @@
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const ModalHeader: React.FC<IProps> = ({ children }) => (
<div data-testid="modal-header" className="modal-header">
{children}
</div>
);

View file

@ -1,4 +0,0 @@
export { Modal } from './Modal';
export { ModalBody } from './ModalBody';
export { ModalFooter } from './ModalFooter';
export { ModalHeader } from './ModalHeader';

View file

@ -0,0 +1,16 @@
.otpGroup {
display: flex;
width: 100%;
max-width: 360px;
column-gap: 10px;
}
.otpInput {
width: 100%;
border: 1px solid #ccc;
border-radius: 5px;
text-align: center;
font-size: 32px;
font-weight: bold;
line-height: 1;
}

View file

@ -0,0 +1,262 @@
import React from 'react';
import { faker } from '@faker-js/faker';
import { OtpInput } from './OtpInput';
import { fireEvent, render, screen } from '../../../../../tests/test-utils';
describe('<OtpInput />', () => {
it('should accept value & valueLength props', () => {
// arrange
const value = faker.datatype.number({ min: 0, max: 999999 }).toString();
const valueArray = value.split('');
const valueLength = value.length;
render(<OtpInput value={value} valueLength={valueLength} onChange={() => {}} />);
const inputEls = screen.queryAllByRole('textbox');
// assert
expect(inputEls).toHaveLength(valueLength);
inputEls.forEach((inputEl, idx) => {
expect(inputEl).toHaveValue(valueArray[idx]);
});
});
it('should allow typing of digits', () => {
// arrange
const valueLength = faker.datatype.number({ min: 2, max: 6 }); // random number from 2-6 (minimum 2 so it can focus on the next input)
const onChange = jest.fn();
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
const inputEls = screen.queryAllByRole('textbox');
// assert
expect(inputEls).toHaveLength(valueLength);
inputEls.forEach((inputEl, idx) => {
const digit = faker.datatype.number({ min: 0, max: 9 }).toString(); // random number from 0-9, typing of digits is 1 by 1
// trigger a change event
fireEvent.change(inputEl, {
target: { value: digit }, // pass it as the target.value in the event data
});
// custom matcher to check that "onChange" function was called with the same digit
expect(onChange).toBeCalledTimes(1);
expect(onChange).toBeCalledWith(digit);
const inputFocused = inputEls[idx + 1] || inputEl;
expect(inputFocused).toHaveFocus();
onChange.mockReset(); // resets the call times for the next iteration of the loop
});
});
it('should NOT allow typing of non-digits', () => {
// arrange
const valueLength = faker.datatype.number({ min: 2, max: 6 });
const onChange = jest.fn();
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
const inputEls = screen.queryAllByRole('textbox');
// assert
expect(inputEls).toHaveLength(valueLength);
inputEls.forEach((inputEl) => {
const nonDigit = faker.random.alpha(1);
fireEvent.change(inputEl, {
target: { value: nonDigit },
});
expect(onChange).not.toBeCalled();
onChange.mockReset();
});
});
it('should allow deleting of digits (focus on previous element)', () => {
const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it can focus on the previous input
const valueLength = value.length;
const lastIdx = valueLength - 1;
const onChange = jest.fn();
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
const inputEls = screen.queryAllByRole('textbox');
expect(inputEls).toHaveLength(valueLength);
for (let idx = lastIdx; idx > -1; idx -= 1) {
// loop backwards to simulate the focus on the previous input
const inputEl = inputEls[idx] as HTMLInputElement;
const target = { value: '' };
// trigger both change and keydown event
fireEvent.change(inputEl, { target });
fireEvent.keyDown(inputEl, {
target,
key: 'Backspace',
});
const valueArray = value.split('');
valueArray[idx] = ' '; // the deleted digit is expected to be replaced with a space in the string
const expectedValue = valueArray.join('');
expect(onChange).toBeCalledTimes(1);
expect(onChange).toBeCalledWith(expectedValue);
// custom matcher to check that the focus is on the previous input
// OR
// focus is on the current input if previous input doesn't exist
const inputFocused = inputEls[idx - 1] || inputEl;
expect(inputFocused).toHaveFocus();
onChange.mockReset();
}
});
it('should allow deleting of digits (do NOT focus on previous element)', () => {
const value = faker.datatype.number({ min: 10, max: 999999 }).toString();
const valueArray = value.split('');
const valueLength = value.length;
const lastIdx = valueLength - 1;
const onChange = jest.fn();
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
const inputEls = screen.queryAllByRole('textbox');
expect(inputEls).toHaveLength(valueLength);
for (let idx = lastIdx; idx > 0; idx -= 1) {
// idx > 0, because there's no previous input in index 0
const inputEl = inputEls[idx] as HTMLInputElement;
fireEvent.keyDown(inputEl, {
key: 'Backspace',
target: { value: valueArray[idx] },
});
const prevInputEl = inputEls[idx - 1];
expect(prevInputEl).not.toHaveFocus();
onChange.mockReset();
}
});
it('should NOT allow deleting of digits in the middle', () => {
const value = faker.datatype.number({ min: 100000, max: 999999 }).toString();
const valueLength = value.length;
const onChange = jest.fn();
render(<OtpInput value={value} valueLength={valueLength} onChange={onChange} />);
const inputEls = screen.queryAllByRole('textbox');
const thirdInputEl = inputEls[2] as HTMLInputElement;
const target = { value: '' };
fireEvent.change(thirdInputEl, { target: { value: '' } });
fireEvent.keyDown(thirdInputEl, {
target,
key: 'Backspace',
});
expect(onChange).not.toBeCalled();
});
it('should allow pasting of digits (same length as valueLength)', () => {
const value = faker.datatype.number({ min: 10, max: 999999 }).toString(); // minimum 2-digit so it is considered as a paste event
const valueLength = value.length;
const onChange = jest.fn();
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
const inputEls = screen.queryAllByRole('textbox');
// get a random input element from the input elements to paste the digits on
const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
fireEvent.change(randomInputEl, { target: { value } });
expect(onChange).toBeCalledTimes(1);
expect(onChange).toBeCalledWith(value);
expect(randomInputEl).not.toHaveFocus();
});
it('should NOT allow pasting of digits (less than valueLength)', () => {
const value = faker.datatype.number({ min: 10, max: 99999 }).toString(); // random 2-5 digit code (less than "valueLength")
const valueLength = faker.datatype.number({ min: 6, max: 10 }); // random number from 6-10
const onChange = jest.fn();
render(<OtpInput valueLength={valueLength} onChange={onChange} value="" />);
const inputEls = screen.queryAllByRole('textbox');
const randomIdx = faker.datatype.number({ min: 0, max: valueLength - 1 });
const randomInputEl = inputEls[randomIdx] as HTMLInputElement;
fireEvent.change(randomInputEl, { target: { value } });
expect(onChange).not.toBeCalled();
});
it('should focus to next element on right/down key', () => {
render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
const inputEls = screen.queryAllByRole('textbox');
const firstInputEl = inputEls[0] as HTMLInputElement;
fireEvent.keyDown(firstInputEl, {
key: 'ArrowRight',
});
expect(inputEls[1]).toHaveFocus();
const secondInputEl = inputEls[1] as HTMLInputElement;
fireEvent.keyDown(secondInputEl, {
key: 'ArrowDown',
});
expect(inputEls[2]).toHaveFocus();
});
it('should focus to next element on left/up key', () => {
render(<OtpInput valueLength={3} onChange={jest.fn} value="1234" />);
const inputEls = screen.queryAllByRole('textbox');
const lastInputEl = inputEls[2] as HTMLInputElement;
fireEvent.keyDown(lastInputEl, {
key: 'ArrowLeft',
});
expect(inputEls[1]).toHaveFocus();
const secondInputEl = inputEls[1] as HTMLInputElement;
fireEvent.keyDown(secondInputEl, {
key: 'ArrowUp',
});
expect(inputEls[0]).toHaveFocus();
});
it('should only focus to input if previous input has value', () => {
const valueLength = 6;
render(<OtpInput valueLength={valueLength} onChange={jest.fn} value="" />);
const inputEls = screen.queryAllByRole('textbox');
const lastInputEl = inputEls[valueLength - 1] as HTMLInputElement;
lastInputEl.focus();
const firstInputEl = inputEls[0];
expect(firstInputEl).toHaveFocus();
});
});

View file

@ -0,0 +1,157 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import classes from './OtpInput.module.scss';
type Props = {
value: string;
valueLength: number;
onChange: (value: string) => void;
className?: string;
};
const RE_DIGIT = /^\d+$/;
export const OtpInput = ({ value, valueLength, onChange, className }: Props) => {
const valueItems = useMemo(() => {
const valueArray = value.split('');
const items: string[] = [];
for (let i = 0; i < valueLength; i += 1) {
const char = valueArray[i];
if (char && RE_DIGIT.test(char)) {
items.push(char);
} else {
items.push('');
}
}
return items;
}, [value, valueLength]);
const focusToNextInput = (target: HTMLElement) => {
const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
if (nextElementSibling) {
nextElementSibling.focus();
}
};
const focusToPrevInput = (target: HTMLElement) => {
const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
if (previousElementSibling) {
previousElementSibling.focus();
}
};
const inputOnChange = (e: React.ChangeEvent<HTMLInputElement>, idx: number) => {
const { target } = e;
let targetValue = target.value.trim();
const isTargetValueDigit = RE_DIGIT.test(targetValue);
if (!isTargetValueDigit && targetValue !== '') {
return;
}
const nextInputEl = target.nextElementSibling as HTMLInputElement | null;
// only delete digit if next input element has no value
if (!isTargetValueDigit && nextInputEl && nextInputEl.value !== '') {
return;
}
targetValue = isTargetValueDigit ? targetValue : ' ';
const targetValueLength = targetValue.length;
if (targetValueLength === 1) {
const newValue = value.substring(0, idx) + targetValue + value.substring(idx + 1);
onChange(newValue);
if (!isTargetValueDigit) {
return;
}
focusToNextInput(target);
const nextElementSibling = target.nextElementSibling as HTMLInputElement | null;
if (nextElementSibling) {
nextElementSibling.focus();
}
} else if (targetValueLength === valueLength) {
onChange(targetValue);
target.blur();
}
};
const inputOnFocus = (e: React.FocusEvent<HTMLInputElement>) => {
const { target } = e;
const prevInputEl = target.previousElementSibling as HTMLInputElement | null;
if (prevInputEl && prevInputEl.value === '') {
return prevInputEl.focus();
}
target.setSelectionRange(0, target.value.length);
return null;
};
const inputOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const { key } = e;
const target = e.target as HTMLInputElement;
if (key === 'ArrowRight' || key === 'ArrowDown') {
e.preventDefault();
return focusToNextInput(target);
}
if (key === 'ArrowLeft' || key === 'ArrowUp') {
e.preventDefault();
return focusToPrevInput(target);
}
const targetValue = target.value;
target.setSelectionRange(0, targetValue.length);
if (e.key !== 'Backspace' || target.value !== '') {
return null;
}
focusToPrevInput(target);
const previousElementSibling = target.previousElementSibling as HTMLInputElement | null;
if (previousElementSibling) {
previousElementSibling.focus();
}
return null;
};
return (
<div className={classes.otpGroup}>
{valueItems.map((digit, idx) => (
<input
aria-label={`digit-${idx}`}
onChange={(e) => inputOnChange(e, idx)}
onKeyDown={inputOnKeyDown}
onFocus={inputOnFocus}
// eslint-disable-next-line react/no-array-index-key
key={idx}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{1}"
maxLength={valueLength}
className={clsx('form-control', classes.otpInput, className)}
value={digit}
/>
))}
</div>
);
};

View file

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

View file

@ -0,0 +1,10 @@
.root[data-state='checked'] {
background-color: var(--tblr-primary);
background-position: right center;
--tblr-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e");
&:focus,
&:hover {
--tblr-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e");
}
}

View file

@ -1,9 +1,7 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { Switch } from './Switch';
import { fireEvent, render } from '../../../../../tests/test-utils';
import { fireEvent, render, screen } from '../../../../../tests/test-utils';
describe('Switch', () => {
it('renders the label', () => {
@ -22,16 +20,16 @@ describe('Switch', () => {
});
it('renders the checked state', () => {
const { container } = render(<Switch checked onChange={jest.fn} />);
const checkbox = container.querySelector('input[type="checkbox"]');
render(<Switch checked onChange={jest.fn} />);
const checkbox = screen.getByRole('switch');
expect(checkbox).toBeChecked();
});
it('triggers onChange event when clicked', () => {
const onChange = jest.fn();
const { container } = render(<Switch onChange={onChange} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
render(<Switch onCheckedChange={onChange} />);
const checkbox = screen.getByRole('switch');
fireEvent.click(checkbox);
@ -40,8 +38,8 @@ describe('Switch', () => {
it('triggers onBlur event when blurred', () => {
const onBlur = jest.fn();
const { container } = render(<Switch onBlur={onBlur} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
render(<Switch onBlur={onBlur} />);
const checkbox = screen.getByRole('switch');
fireEvent.blur(checkbox);
@ -49,8 +47,8 @@ describe('Switch', () => {
});
it('should change the checked state when clicked', () => {
const { container } = render(<Switch onChange={jest.fn} />);
const checkbox = container.querySelector('input[type="checkbox"]') as Element;
render(<Switch onChange={jest.fn} />);
const checkbox = screen.getByRole('switch');
fireEvent.click(checkbox);

View file

@ -1,19 +1,22 @@
import React from 'react';
'use client';
interface IProps {
label?: string;
className?: string;
checked?: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
name?: string;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
}
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import clsx from 'clsx';
import classes from './Switch.module.scss';
export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
<div className={className}>
<label htmlFor={name} aria-labelledby={name} className="form-check form-switch">
<input id={name} name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
<span className="form-check-label">{label}</span>
</label>
</div>
type RootProps = typeof SwitchPrimitives.Root;
const Switch = React.forwardRef<React.ElementRef<RootProps>, React.ComponentPropsWithoutRef<RootProps> & { label?: string }>(({ className, ...props }, ref) => (
<label htmlFor={props.name} aria-labelledby={props.name} className={clsx('form-check form-switch form-check-sigle', className)}>
<SwitchPrimitives.Root name={props.name} className={clsx('form-check-input', classes.root)} {...props} ref={ref}>
<SwitchPrimitives.Thumb />
</SwitchPrimitives.Root>
<span id={props.name} className="form-check-label text-muted">
{props.label}
</span>
</label>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -5,7 +5,7 @@ import { App, AppCategory, AppInfo, AppWithInfo } from '../../core/types';
const randomCategory = (): AppCategory[] => {
const categories = Object.values(APP_CATEGORIES);
const randomIndex = faker.datatype.number({ min: 0, max: categories.length - 1 });
return [categories[randomIndex]!];
return [categories[randomIndex] as AppCategory];
};
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {

View file

@ -25,7 +25,7 @@ export type RpcErrorResponse = {
};
};
const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<any> => {
const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<unknown> => {
const response = SuperJSON.serialize(data);
return {

View file

@ -7,7 +7,7 @@ export const handlers = [
getTRPCMock({
path: ['system', 'getVersion'],
type: 'query',
response: { current: '1.0.0', latest: '1.0.0' },
response: { current: '1.0.0', latest: '1.0.0', body: 'hello' },
}),
getTRPCMock({
path: ['system', 'update'],
@ -61,6 +61,7 @@ export const handlers = [
path: ['auth', 'me'],
type: 'query',
response: {
totp_enabled: false,
id: faker.datatype.number(),
username: faker.internet.userName(),
},

View file

@ -1,3 +1,6 @@
/**
* This file is the entry point for the mock service worker.
*/
async function initMocks() {
if (typeof window === 'undefined') {
const { server } = await import('./server');

View file

@ -36,28 +36,28 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
className={className}
value={value}
styles={{
menu: (provided: any) => ({
menu: (provided: object) => ({
...provided,
backgroundColor: bgColor,
color,
}),
control: (provided: any) => ({
control: (provided: object) => ({
...provided,
backgroundColor: bgColor,
color,
borderColor,
}),
option: (provided: any, state: any) => ({
option: (provided: object, state: { isFocused: boolean }) => ({
...provided,
backgroundColor: state.isFocused ? '#243049' : bgColor,
color: state.isFocused ? '#fff' : color,
}),
singleValue: (provided: any) => ({
singleValue: (provided: object) => ({
...provided,
color,
fontSize: '0.8rem',
}),
placeholder: (provided: any) => ({
placeholder: (provided: object) => ({
...provided,
color: '#a5a9b1',
fontSize: '0.8rem',
@ -66,7 +66,7 @@ const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue
onChange={handleChange}
defaultValue={[]}
name="categories"
options={options as any}
options={options}
placeholder="Category"
/>
);

View file

@ -13,70 +13,91 @@ describe('Test: AppActions', () => {
exposable: [],
} as unknown as AppInfo;
it('should render the correct buttons when app status is stopped', () => {
// Arrange
it('should call the callbacks when buttons are clicked', () => {
// arrange
const onStart = jest.fn();
const onRemove = jest.fn();
// @ts-expect-error
const { getByText } = render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
render(<AppActions status="stopped" info={app} onStart={onStart} onUninstall={onRemove} />);
// Act
fireEvent.click(getByText('Start'));
fireEvent.click(getByText('Remove'));
// act
const startButton = screen.getByRole('button', { name: 'Start' });
fireEvent.click(startButton);
const removeButton = screen.getByText('Remove');
fireEvent.click(removeButton);
// Assert
expect(getByText('Start')).toBeInTheDocument();
expect(getByText('Remove')).toBeInTheDocument();
// assert
expect(onStart).toHaveBeenCalled();
expect(onRemove).toHaveBeenCalled();
});
it('should render the correct buttons when app status is running', () => {
// arrange
// @ts-expect-error
const { getByText } = render(<AppActions status="running" info={app} />);
expect(getByText('Stop')).toBeInTheDocument();
expect(getByText('Open')).toBeInTheDocument();
expect(getByText('Settings')).toBeInTheDocument();
render(<AppActions status="running" info={app} />);
// assert
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('should render the correct buttons when app status is starting', () => {
// arrange
// @ts-expect-error
render(<AppActions status="starting" info={app} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
});
it('should render the correct buttons when app status is stopping', () => {
// arrange
// @ts-expect-error
render(<AppActions status="stopping" info={app} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
});
it('should render the correct buttons when app status is removing', () => {
// arrange
// @ts-expect-error
render(<AppActions status="uninstalling" info={app} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
});
it('should render the correct buttons when app status is installing', () => {
// arrange
// @ts-ignore
render(<AppActions status="installing" info={app} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
});
it('should render the correct buttons when app status is updating', () => {
// arrange
// @ts-expect-error
render(<AppActions status="updating" info={app} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByTestId('action-button-loading')).toBeInTheDocument();
});
it('should render the correct buttons when app status is missing', () => {
// arrange
// @ts-expect-error
render(<AppActions status="missing" info={app} />);
expect(screen.getByText('Install')).toBeInTheDocument();
// assert
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
});
});

View file

@ -32,8 +32,10 @@ interface BtnProps {
const ActionButton: React.FC<BtnProps> = (props) => {
const { IconComponent, onClick, title, loading, color, width = 140 } = props;
const testId = loading ? 'action-button-loading' : undefined;
return (
<Button loading={loading} data-testid={`action-button-${title?.toLowerCase()}`} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
<Button loading={loading} data-testid={testId} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
{title}
{IconComponent && <IconComponent className="ms-1" size={14} />}
</Button>

View file

@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { Button } from '../../../../components/ui/Button';
import { Switch } from '../../../../components/ui/Switch';
@ -32,6 +32,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
setValue,
watch,
setError,
control,
} = useForm<FormValues>({});
const watchExposed = watch('exposed', false);
@ -57,7 +58,12 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
const renderExposeForm = () => (
<>
<Switch className="mb-3" {...register('exposed')} label="Expose app" />
<Controller
control={control}
name="exposed"
defaultValue={false}
render={({ field: { onChange, value, ref, ...props } }) => <Switch className="mb-3" ref={ref} checked={value} onCheckedChange={onChange} {...props} label="Expose app" />}
/>
{watchExposed && (
<div className="mb-3">
<Input {...register('domain')} label="Domain name" error={errors.domain?.message} disabled={loading} placeholder="Domain name" />
@ -83,10 +89,8 @@ export const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValu
}
};
const name = initalValues ? 'update' : 'install';
return (
<form data-testid={`${name}-form`} className="flex flex-col" onSubmit={handleSubmit(validate)}>
<form className="flex flex-col" onSubmit={handleSubmit(validate)}>
{formFields.filter(typeFilter).map(renderField)}
{exposable && renderExposeForm()}
<Button loading={loading} type="submit" className="btn-success">

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { InstallForm } from '../InstallForm';
import { Modal, ModalBody, ModalHeader } from '../../../../components/ui/Modal';
import { AppInfo } from '../../../../core/types';
import { FormValues } from '../InstallForm/InstallForm';
@ -12,12 +12,14 @@ interface IProps {
}
export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => (
<Modal onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Install {info.name}</h5>
</ModalHeader>
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
</ModalBody>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">Install {info.name}</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} />
</DialogDescription>
</DialogContent>
</Dialog>
);

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { Button } from '../../../components/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
import { AppInfo } from '../../../core/types';
interface IProps {
@ -11,17 +11,19 @@ interface IProps {
}
export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
<Modal size="sm" onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Stop {info.name} ?</h5>
</ModalHeader>
<ModalBody>
<div className="text-muted">All data will be retained</div>
</ModalBody>
<ModalFooter>
<Button data-testid="modal-stop-button" onClick={onConfirm} className="btn-danger">
Stop
</Button>
</ModalFooter>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">Stop {info.name} ?</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">All data will be retained</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
Stop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -1,7 +1,7 @@
import { IconAlertTriangle } from '@tabler/icons-react';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { Button } from '../../../components/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
import { AppInfo } from '../../../core/types';
interface IProps {
@ -12,19 +12,21 @@ interface IProps {
}
export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
<Modal size="sm" type="danger" onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Uninstall {info.name} ?</h5>
</ModalHeader>
<ModalBody className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>Are you sure?</h3>
<div className="text-muted">All data for this app will be lost.</div>
</ModalBody>
<ModalFooter>
<Button onClick={onConfirm} className="btn-danger">
Uninstall
</Button>
</ModalFooter>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">Uninstall {info.name} ?</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>Are you sure?</h3>
<div className="text-muted">All data for this app will be lost.</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger">
Uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -7,40 +7,40 @@ describe('UpdateModal', () => {
const newVersion = '1.2.3';
it('renders with the correct title and version number', () => {
// Arrange
// arrange
render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={jest.fn()} />);
// Assert
// assert
expect(screen.getByText(`Update ${app.name} ?`)).toBeInTheDocument();
expect(screen.getByText(`${newVersion}`)).toBeInTheDocument();
});
it('should not render when isOpen is false', () => {
// Arrange
// arrange
render(<UpdateModal info={app} newVersion={newVersion} isOpen={false} onClose={jest.fn()} onConfirm={jest.fn()} />);
const modal = screen.queryByTestId('modal');
// Assert (modal should have style display: none)
expect(modal).toHaveStyle('display: none');
// assert
expect(modal).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', () => {
// Arrange
// arrange
const onClose = jest.fn();
render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={onClose} onConfirm={jest.fn()} />);
// Act
// act
const closeButton = screen.getByTestId('modal-close-button');
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
it('calls onConfirm when the update button is clicked', () => {
// Arrange
// arrange
const onConfirm = jest.fn();
render(<UpdateModal info={app} newVersion={newVersion} isOpen onClose={jest.fn()} onConfirm={onConfirm} />);
// Act
// act
const updateButton = screen.getByText('Update');
fireEvent.click(updateButton);
expect(onConfirm).toHaveBeenCalled();

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { Button } from '../../../../components/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
import { AppInfo } from '../../../../core/types';
interface IProps {
@ -12,20 +12,22 @@ interface IProps {
}
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => (
<Modal size="sm" onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Update {info.name} ?</h5>
</ModalHeader>
<ModalBody>
<div className="text-muted">
Update app to latest verion : <b>{newVersion}</b> ?<br />
This will reset your custom configuration (e.g. changes in docker-compose.yml)
</div>
</ModalBody>
<ModalFooter>
<Button data-testid="modal-update-button" onClick={onConfirm} className="btn-success">
Update
</Button>
</ModalFooter>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">Update {info.name} ?</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">
Update app to latest verion : <b>{newVersion}</b> ?<br />
This will reset your custom configuration (e.g. changes in docker-compose.yml)
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
import { InstallForm } from './InstallForm';
import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
import { AppInfo } from '../../../core/types';
import { FormValues } from './InstallForm/InstallForm';
@ -15,12 +15,14 @@ interface IProps {
}
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => (
<Modal onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Update {info.name} config</h5>
</ModalHeader>
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
</ModalBody>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<h5 className="modal-title">Update {info.name} config</h5>
</DialogHeader>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} exposable={info.exposable} initalValues={{ ...config, exposed, domain }} />
</DialogDescription>
</DialogContent>
</Dialog>
);

View file

@ -1,9 +1,8 @@
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { AppDetailsContainer } from './AppDetailsContainer';
describe('Test: AppDetailsContainer', () => {
@ -23,7 +22,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByTestId('action-button-update')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
});
it('should display install button when app is not installed', async () => {
@ -33,7 +32,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByTestId('action-button-install')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
});
it('should display uninstall and start button when app is stopped', async () => {
@ -43,8 +42,8 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByTestId('action-button-remove')).toBeInTheDocument();
expect(screen.getByTestId('action-button-start')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
});
it('should display stop, open and settings buttons when app is running', async () => {
@ -53,9 +52,9 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.getByTestId('action-button-stop')).toBeInTheDocument();
expect(screen.getByTestId('action-button-open')).toBeInTheDocument();
expect(screen.getByTestId('action-button-settings')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('should not display update button when update is not available', async () => {
@ -64,7 +63,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.queryByTestId('action-button-update')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument();
});
it('should not display open button when app has no_gui set to true', async () => {
@ -73,7 +72,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Assert
expect(screen.queryByTestId('action-button-open')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument();
});
});
@ -85,7 +84,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByTestId('action-button-open');
const openButton = screen.getByRole('button', { name: 'Open' });
openButton.click();
// Assert
@ -99,7 +98,7 @@ describe('Test: AppDetailsContainer', () => {
render(<AppDetailsContainer app={app} />);
// Act
const openButton = screen.getByTestId('action-button-open');
const openButton = screen.getByRole('button', { name: 'Open' });
openButton.click();
// Assert
@ -112,23 +111,21 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ overrides: { status: 'missing' } });
server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(openModalButton);
// Act
const installForm = screen.getByTestId('install-form');
fireEvent.submit(installForm);
const installButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(installButton);
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('success');
expect(result.current.toasts[0].title).toEqual('App installed successfully');
expect(screen.getByText('App installed successfully')).toBeInTheDocument();
});
});
it('should display a toast error when install mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(
getTRPCMockError({
path: ['app', 'installApp'],
@ -139,33 +136,15 @@ describe('Test: AppDetailsContainer', () => {
const app = createAppEntity({ overrides: { status: 'missing' } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(openModalButton);
// Act
const installForm = screen.getByTestId('install-form');
fireEvent.submit(installForm);
const installButton = screen.getByRole('button', { name: 'Install' });
fireEvent.click(installButton);
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
// Skipping because trpc.useContext is not working in tests
it.skip('should put the app in installing state when install mutation is called', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const app = createAppEntity({ overrides: { status: 'missing' } });
server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app, delay: 100 }));
render(<AppDetailsContainer app={app} />);
// Act
const installForm = screen.getByTestId('install-form');
fireEvent.submit(installForm);
await waitFor(() => {
expect(screen.getByText('installing')).toBeInTheDocument();
expect(result.current.toasts).toHaveLength(1);
expect(screen.getByText('Failed to install app: my big error')).toBeInTheDocument();
});
});
});
@ -175,60 +154,34 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Update' });
fireEvent.click(openModalButton);
// Act
const updateButton = screen.getByTestId('action-button-update');
updateButton.click();
const modalUpdateButton = screen.getByTestId('modal-update-button');
const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
modalUpdateButton.click();
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('success');
expect(result.current.toasts[0].title).toEqual('App updated successfully');
expect(screen.getByText('App updated successfully')).toBeInTheDocument();
});
});
it('should display a toast error when update mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Update' });
fireEvent.click(openModalButton);
// Act
const updateButton = screen.getByTestId('action-button-update');
updateButton.click();
const modalUpdateButton = screen.getByTestId('modal-update-button');
const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
modalUpdateButton.click();
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
// Skipping because trpc.useContext is not working in tests
it.skip('should put the app in updating state when update mutation is called', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const app = createAppEntity({ overrides: { version: 2 }, overridesInfo: { tipi_version: 3 } });
server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app, delay: 100 }));
render(<AppDetailsContainer app={app} />);
// Act
const updateButton = screen.getByTestId('action-button-update');
updateButton.click();
const modalUpdateButton = screen.getByTestId('modal-update-button');
modalUpdateButton.click();
await waitFor(() => {
expect(screen.getByText('updating')).toBeInTheDocument();
expect(result.current.toasts).toHaveLength(1);
expect(screen.getByText('Failed to update app: my big error')).toBeInTheDocument();
});
});
});
@ -238,62 +191,35 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' } }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Remove' });
fireEvent.click(openModalButton);
// Act
const uninstallButton = screen.getByTestId('action-button-remove');
uninstallButton.click();
const modalUninstallButton = screen.getByText('Uninstall');
const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
modalUninstallButton.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('App uninstalled successfully');
expect(screen.getByText('App uninstalled successfully')).toBeInTheDocument();
});
});
it('should display a toast error when uninstall mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: 'my big error' }));
const app = createAppEntity({ status: 'stopped' });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Remove' });
fireEvent.click(openModalButton);
// Act
const uninstallButton = screen.getByTestId('action-button-remove');
uninstallButton.click();
const modalUninstallButton = screen.getByText('Uninstall');
const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
modalUninstallButton.click();
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
// Skipping because trpc.useContext is not working in tests
it.skip('should put the app in uninstalling state when uninstall mutation is called', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' }, delay: 100 }));
render(<AppDetailsContainer app={app} />);
// Act
const uninstallButton = screen.getByTestId('action-button-remove');
uninstallButton.click();
const modalUninstallButton = screen.getByText('Uninstall');
modalUninstallButton.click();
await waitFor(() => {
expect(screen.getByText('uninstalling')).toBeInTheDocument();
expect(screen.queryByText('installing')).not.toBeInTheDocument();
expect(result.current.toasts).toHaveLength(1);
expect(screen.getByText('Failed to uninstall app: my big error')).toBeInTheDocument();
});
});
});
@ -303,55 +229,31 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
// Act
const startButton = screen.getByTestId('action-button-start');
const startButton = screen.getByRole('button', { name: 'Start' });
startButton.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('App started successfully');
expect(screen.getByText('App started successfully')).toBeInTheDocument();
});
});
it('should display a toast error when start mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: 'my big error' }));
const app = createAppEntity({ status: 'stopped' });
render(<AppDetailsContainer app={app} />);
// Act
const startButton = screen.getByTestId('action-button-start');
const startButton = screen.getByRole('button', { name: 'Start' });
startButton.click();
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
// Skipping because trpc.useContext is not working in tests
it.skip('should put the app in starting state when start mutation is called', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const app = createAppEntity({ status: 'stopped' });
server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app, delay: 100 }));
render(<AppDetailsContainer app={app} />);
// Act
const startButton = screen.getByTestId('action-button-start');
startButton.click();
await waitFor(() => {
expect(screen.getByText('starting')).toBeInTheDocument();
expect(result.current.toasts).toHaveLength(1);
expect(screen.getByText('Failed to start app: my big error')).toBeInTheDocument();
});
});
});
@ -361,61 +263,35 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ status: 'running' });
server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Stop' });
fireEvent.click(openModalButton);
// Act
const stopButton = screen.getByTestId('action-button-stop');
stopButton.click();
const modalStopButton = screen.getByTestId('modal-stop-button');
const modalStopButton = screen.getByRole('button', { name: 'Stop' });
modalStopButton.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('App stopped successfully');
expect(screen.getByText('App stopped successfully')).toBeInTheDocument();
});
});
it('should display a toast error when stop mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: 'my big error' }));
const app = createAppEntity({ status: 'running' });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Stop' });
fireEvent.click(openModalButton);
// Act
const stopButton = screen.getByTestId('action-button-stop');
stopButton.click();
const modalStopButton = screen.getByTestId('modal-stop-button');
const modalStopButton = screen.getByRole('button', { name: 'Stop' });
modalStopButton.click();
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
});
});
// Skipping because trpc.useContext is not working in tests
it.skip('should put the app in stopping state when stop mutation is called', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
const app = createAppEntity({ status: 'running' });
server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
render(<AppDetailsContainer app={app} />);
// Act
const stopButton = screen.getByTestId('action-button-stop');
stopButton.click();
const modalStopButton = screen.getByTestId('modal-stop-button');
modalStopButton.click();
await waitFor(() => {
expect(screen.getByText('stopping')).toBeInTheDocument();
expect(result.current.toasts).toHaveLength(1);
expect(screen.getByText('Failed to stop app: my big error')).toBeInTheDocument();
});
});
});
@ -425,41 +301,35 @@ describe('Test: AppDetailsContainer', () => {
// Arrange
const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
server.use(getTRPCMock({ path: ['app', 'updateAppConfig'], type: 'mutation', response: app }));
const { result } = renderHook(() => useToastStore());
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.click(openModalButton);
// Act
const configButton = screen.getByTestId('action-button-settings');
const configButton = screen.getByRole('button', { name: 'Update' });
configButton.click();
const modalConfigButton = screen.getAllByText('Update');
modalConfigButton[1]?.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('App config updated successfully. Restart the app to apply the changes');
expect(screen.getByText('App config updated successfully. Restart the app to apply the changes')).toBeInTheDocument();
});
});
it('should display a toast error when update config mutation fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: 'my big error' }));
const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
render(<AppDetailsContainer app={app} />);
const openModalButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.click(openModalButton);
// Act
const configButton = screen.getByTestId('action-button-settings');
const configButton = screen.getByRole('button', { name: 'Update' });
configButton.click();
const modalConfigButton = screen.getAllByText('Update');
modalConfigButton[1]?.click();
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
expect(screen.getByText('Failed to update app config: my big error')).toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,6 @@
import React from 'react';
import { toast } from 'react-hot-toast';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { useToastStore } from '../../../../state/toastStore';
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
import { AppStatus } from '../../../../components/AppStatus';
import { AppActions } from '../../components/AppActions';
@ -20,7 +20,6 @@ interface IProps {
}
export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
const { addToast } = useToastStore();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
@ -41,11 +40,11 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
addToast({ title: 'App installed successfully', status: 'success' });
toast.success('App installed successfully');
},
onError: (e) => {
invalidate();
addToast({ title: 'Install error', description: e.message, status: 'error' });
toast.error(`Failed to install app: ${e.message}`);
},
});
@ -56,9 +55,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
addToast({ title: 'App uninstalled successfully', status: 'success' });
toast.success('App uninstalled successfully');
},
onError: (e) => addToast({ title: 'Uninstall error', description: e.message, status: 'error' }),
onError: (e) => toast.error(`Failed to uninstall app: ${e.message}`),
});
const stop = trpc.app.stopApp.useMutation({
@ -68,9 +67,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
addToast({ title: 'App stopped successfully', status: 'success' });
toast.success('App stopped successfully');
},
onError: (e) => addToast({ title: 'Stop error', description: e.message, status: 'error' }),
onError: (e) => toast.error(`Failed to stop app: ${e.message}`),
});
const update = trpc.app.updateApp.useMutation({
@ -80,9 +79,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
addToast({ title: 'App updated successfully', status: 'success' });
toast.success('App updated successfully');
},
onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }),
onError: (e) => toast.error(`Failed to update app: ${e.message}`),
});
const start = trpc.app.startApp.useMutation({
@ -91,18 +90,18 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
},
onSuccess: () => {
invalidate();
addToast({ title: 'App started successfully', status: 'success' });
toast.success('App started successfully');
},
onError: (e) => addToast({ title: 'Start error', description: e.message, status: 'error' }),
onError: (e) => toast.error(`Failed to start app: ${e.message}`),
});
const updateConfig = trpc.app.updateAppConfig.useMutation({
onMutate: () => updateSettingsDisclosure.close(),
onSuccess: () => {
invalidate();
addToast({ title: 'App config updated successfully. Restart the app to apply the changes', status: 'success' });
toast.success('App config updated successfully. Restart the app to apply the changes');
},
onError: (e) => addToast({ title: 'Update error', description: e.message, status: 'error' }),
onError: (e) => toast.error(`Failed to update app config: ${e.message}`),
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);

View file

@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
return (
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
<h2 className="h2 text-center mb-3">Login to your account</h2>
<Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
<Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
<span className="form-label-description">
<Link href="/reset-password">Forgot password?</Link>
</span>
<Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
<Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
<Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
Login
</Button>

View file

@ -0,0 +1,32 @@
import { Button } from '@/components/ui/Button';
import { OtpInput } from '@/components/ui/OtpInput';
import React from 'react';
type Props = {
onSubmit: (totpCode: string) => void;
loading?: boolean;
};
export const TotpForm = (props: Props) => {
const { onSubmit, loading } = props;
const [totpCode, setTotpCode] = React.useState('');
return (
<form
onSubmit={(e) => {
setTotpCode('');
e.preventDefault();
onSubmit(totpCode);
}}
>
<div className="flex items-center justify-center">
<h3 className="">Two-factor authentication</h3>
<p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
<OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
Confirm
</Button>
</div>
</form>
);
};

View file

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

View file

@ -1,9 +1,8 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { LoginContainer } from './LoginContainer';
describe('Test: LoginContainer', () => {
@ -28,8 +27,8 @@ describe('Test: LoginContainer', () => {
// Arrange
render(<LoginContainer />);
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByLabelText('Email address');
const passwordInput = screen.getByLabelText('Password');
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
// Act
fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
@ -49,8 +48,8 @@ describe('Test: LoginContainer', () => {
// Act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByLabelText('Email address');
const passwordInput = screen.getByLabelText('Password');
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
@ -64,14 +63,13 @@ describe('Test: LoginContainer', () => {
it('should show error message if login fails', async () => {
// Arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['auth', 'login'], type: 'mutation', status: 500, message: 'my big error' }));
render(<LoginContainer />);
// Act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByLabelText('Email address');
const passwordInput = screen.getByLabelText('Password');
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
fireEvent.change(passwordInput, { target: { value: 'test' } });
@ -79,11 +77,113 @@ describe('Test: LoginContainer', () => {
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
expect(screen.getByText(/my big error/)).toBeInTheDocument();
});
const token = localStorage.getItem('token');
expect(token).toBeNull();
});
it('should show totp form if totpSessionId is returned', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.datatype.uuid();
server.use(
getTRPCMock({
path: ['auth', 'login'],
type: 'mutation',
response: { totpSessionId },
}),
);
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
// assert
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
});
it('should show error message if totp code is invalid', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.datatype.uuid();
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
totpInputs.forEach((input, index) => {
fireEvent.change(input, { target: { value: index } });
});
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(totpSubmitButton);
// assert
await waitFor(() => {
expect(screen.getByText(/Invalid totp code/)).toBeInTheDocument();
});
});
it('should add token in localStorage if totp code is valid', async () => {
// arrange
const email = faker.internet.email();
const password = faker.internet.password();
const totpSessionId = faker.datatype.uuid();
const token = faker.datatype.uuid();
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: { token } }));
render(<LoginContainer />);
// act
const loginButton = screen.getByRole('button', { name: 'Login' });
const emailInput = screen.getByRole('textbox', { name: 'email' });
const passwordInput = screen.getByRole('textbox', { name: 'password' });
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.change(passwordInput, { target: { value: password } });
fireEvent.click(loginButton);
await waitFor(() => {
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
});
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
totpInputs.forEach((input, index) => {
fireEvent.change(input, { target: { value: index } });
});
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
fireEvent.click(totpSubmitButton);
// assert
await waitFor(() => {
expect(localStorage.getItem('token')).toEqual(token);
});
});
});

View file

@ -1,20 +1,36 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useToastStore } from '../../../../state/toastStore';
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { LoginForm } from '../../components/LoginForm';
import { TotpForm } from '../../components/TotpForm';
type FormValues = { email: string; password: string };
export const LoginContainer: React.FC = () => {
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const router = useRouter();
const { addToast } = useToastStore();
const utils = trpc.useContext();
const login = trpc.auth.login.useMutation({
onError: (e) => {
localStorage.removeItem('token');
addToast({ title: 'Login error', description: e.message, status: 'error' });
toast.error(`Login failed: ${e.message}`);
},
onSuccess: (data) => {
if (data.totpSessionId) {
setTotpSessionId(data.totpSessionId);
} else if (data.token) {
localStorage.setItem('token', data.token);
utils.auth.me.invalidate();
router.push('/');
}
},
});
const verifyTotp = trpc.auth.verifyTotp.useMutation({
onError: (e) => {
toast.error(`Verification failed: ${e.message}`);
},
onSuccess: (data) => {
localStorage.setItem('token', data.token);
@ -29,7 +45,11 @@ export const LoginContainer: React.FC = () => {
return (
<AuthFormLayout>
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
{totpSessionId ? (
<TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
) : (
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
)}
</AuthFormLayout>
);
};

View file

@ -1,9 +1,8 @@
import { faker } from '@faker-js/faker';
import React from 'react';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { RegisterContainer } from './RegisterContainer';
describe('Test: RegisterContainer', () => {
@ -42,7 +41,6 @@ describe('Test: RegisterContainer', () => {
const email = faker.internet.email();
const password = faker.internet.password();
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['auth', 'register'], type: 'mutation', status: 500, message: 'my big error' }));
render(<RegisterContainer />);
@ -59,9 +57,7 @@ describe('Test: RegisterContainer', () => {
// Assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].description).toEqual('my big error');
expect(result.current.toasts[0].status).toEqual('error');
expect(screen.getByText('Registration failed: my big error')).toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,6 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useToastStore } from '../../../../state/toastStore';
import { toast } from 'react-hot-toast';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { RegisterForm } from '../../components/RegisterForm';
@ -8,13 +8,12 @@ import { RegisterForm } from '../../components/RegisterForm';
type FormValues = { email: string; password: string };
export const RegisterContainer: React.FC = () => {
const { addToast } = useToastStore();
const router = useRouter();
const utils = trpc.useContext();
const register = trpc.auth.register.useMutation({
onError: (e) => {
localStorage.removeItem('token');
addToast({ title: 'Register error', description: e.message, status: 'error' });
toast.error(`Registration failed: ${e.message}`);
},
onSuccess: (data) => {
localStorage.setItem('token', data.token);

View file

@ -1,8 +1,7 @@
import React from 'react';
import { fireEvent, render, screen, waitFor, renderHook } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
import { useToastStore } from '../../../../state/toastStore';
import { ResetPasswordContainer } from './ResetPasswordContainer';
const pushFn = jest.fn();
@ -35,7 +34,7 @@ describe('ResetPasswordContainer', () => {
const newPassword = 'new_password';
const response = { email };
server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response, delay: 100 }));
server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response, delay: 100 }));
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');
@ -55,14 +54,13 @@ describe('ResetPasswordContainer', () => {
it('should show error toast if reset password mutation fails', async () => {
// Arrange
const { result, unmount } = renderHook(() => useToastStore());
render(<ResetPasswordContainer isRequested />);
const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
fireEvent.click(resetPasswordForm);
const newPassword = 'new_password';
const error = { message: 'Something went wrong' };
server.use(getTRPCMockError({ path: ['auth', 'resetPassword'], type: 'mutation', message: error.message }));
server.use(getTRPCMockError({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', message: error.message }));
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');
@ -74,15 +72,12 @@ describe('ResetPasswordContainer', () => {
// Assert
await waitFor(() => {
expect(result.current.toasts[0].description).toBe(error.message);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
unmount();
});
it('should call the cancel request mutation when cancel button is clicked', async () => {
// Arrange
const { result, unmount } = renderHook(() => useToastStore());
render(<ResetPasswordContainer isRequested />);
server.use(getTRPCMock({ path: ['auth', 'cancelPasswordChangeRequest'], type: 'mutation', response: true }));
@ -93,16 +88,14 @@ describe('ResetPasswordContainer', () => {
// Assert
await waitFor(() => {
expect(result.current.toasts[0].title).toBe('Password change request cancelled');
expect(screen.getByText('Password change request cancelled')).toBeInTheDocument();
});
unmount();
});
it('should redirect to login page when Back to login button is clicked', async () => {
// Arrange
render(<ResetPasswordContainer isRequested />);
server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');

View file

@ -1,7 +1,7 @@
import { useRouter } from 'next/router';
import React from 'react';
import { toast } from 'react-hot-toast';
import { Button } from '../../../../components/ui/Button';
import { useToastStore } from '../../../../state/toastStore';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { ResetPasswordForm } from '../../components/ResetPasswordForm';
@ -13,21 +13,20 @@ type Props = {
type FormValues = { password: string };
export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
const { addToast } = useToastStore();
const router = useRouter();
const utils = trpc.useContext();
const resetPassword = trpc.auth.resetPassword.useMutation({
const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
onSuccess: () => {
utils.auth.checkPasswordChangeRequest.invalidate();
},
onError: (error) => {
addToast({ title: 'Reset password error', description: error.message, status: 'error' });
toast.error(`Failed to reset password ${error.message}`);
},
});
const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
onSuccess: () => {
utils.auth.checkPasswordChangeRequest.invalidate();
addToast({ title: 'Password change request cancelled', status: 'success' });
toast.success('Password change request cancelled');
},
});

View file

@ -0,0 +1,72 @@
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.random.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 successfully changed')).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.random.alphaNumeric(8);
// act
fireEvent.change(currentPasswordInput, { target: { value: faker.random.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 />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
// act
fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
fireEvent.change(newPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
fireEvent.change(confirmPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Passwords do not match/i)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,63 @@
import React from 'react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { trpc } from '@/utils/trpc';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { toast } from 'react-hot-toast';
const schema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
newPasswordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.newPasswordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['newPasswordConfirm'],
});
}
});
type FormValues = z.infer<typeof schema>;
export const ChangePasswordForm = () => {
const router = useRouter();
const changePassword = trpc.auth.changePassword.useMutation({
onError: (e) => {
toast.error(`Error changing password: ${e.message}`);
},
onSuccess: () => {
toast.success('Password successfully changed');
router.push('/');
},
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (values: FormValues) => {
changePassword.mutate(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 ">
<Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder="Current password" />
<Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder="New password" />
<Input disabled={changePassword.isLoading} {...register('newPasswordConfirm')} error={errors.newPasswordConfirm?.message} className="mt-2" type="password" placeholder="Confirm new password" />
<Button disabled={changePassword.isLoading} className="mt-3" type="submit">
Change password
</Button>
</form>
);
};

View file

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

View file

@ -0,0 +1,285 @@
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: { 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
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(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: { 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(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: { 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(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 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
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(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 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(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: { 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();
});
});
});

View file

@ -0,0 +1,153 @@
import React from 'react';
import { trpc } from '@/utils/trpc';
import { Switch } from '@/components/ui/Switch';
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';
import { toast } from 'react-hot-toast';
import { useDisclosure } from '@/client/hooks/useDisclosure';
export const OtpForm = () => {
const [password, setPassword] = React.useState('');
const [key, setKey] = React.useState('');
const [uri, setUri] = React.useState('');
const [totpCode, setTotpCode] = React.useState('');
// Dialog statuses
const setupOtpDisclosure = useDisclosure();
const disableOtpDisclosure = useDisclosure();
const ctx = trpc.useContext();
const me = trpc.auth.me.useQuery();
const getTotpUri = trpc.auth.getTotpUri.useMutation({
onMutate: () => {
setupOtpDisclosure.close();
},
onError: (e) => {
setPassword('');
toast.error(`Error getting TOTP URI: ${e.message}`);
},
onSuccess: (data) => {
setKey(data.key);
setUri(data.uri);
},
});
const setupTotp = trpc.auth.setupTotp.useMutation({
onMutate: () => {},
onError: (e) => {
setTotpCode('');
toast.error(`Error setting up TOTP: ${e.message}`);
},
onSuccess: () => {
setTotpCode('');
setKey('');
setUri('');
toast.success('Two-factor authentication enabled');
ctx.auth.me.invalidate();
},
});
const disableTotp = trpc.auth.disableTotp.useMutation({
onMutate: () => {
disableOtpDisclosure.close();
},
onError: (e) => {
setPassword('');
toast.error(`Error disabling TOTP: ${e.message}`);
},
onSuccess: () => {
toast.success('Two-factor authentication disabled');
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) {
setupOtpDisclosure.open();
} else {
disableOtpDisclosure.open();
}
};
return (
<>
{!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={setupOtpDisclosure.isOpen} onOpenChange={(o: boolean) => setupOtpDisclosure.toggle(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={disableOtpDisclosure.isOpen} onOpenChange={(o: boolean) => disableOtpDisclosure.toggle(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>
</>
);
};

View file

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

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { Button } from '../../../../components/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
interface IProps {
isOpen: boolean;
@ -10,17 +10,19 @@ interface IProps {
}
export const RestartModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
<Modal size="sm" onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Restart Tipi</h5>
</ModalHeader>
<ModalBody>
<div className="text-muted">Would you like to restart your Tipi server?</div>
</ModalBody>
<ModalFooter>
<Button data-testid="settings-modal-restart-button" onClick={onConfirm} className="btn-danger" loading={loading}>
Restart
</Button>
</ModalFooter>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">Restart Tipi</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">Would you like to restart your Tipi server?</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-danger" loading={loading}>
Restart
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -83,7 +83,7 @@ export const SettingsForm = (props: IProps) => {
};
return (
<form data-testid="settings-form" className="flex flex-col" onSubmit={handleSubmit(validate)}>
<form className="flex flex-col" onSubmit={handleSubmit(validate)}>
<h2 className="text-2xl font-bold">General settings</h2>
<p className="mb-4">This will update your settings.json file. Make sure you know what you are doing before updating these values.</p>
<div className="mb-3">

View file

@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '../../../../components/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../../components/ui/Modal';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../../../../components/ui/Dialog';
interface IProps {
isOpen: boolean;
@ -10,17 +10,19 @@ interface IProps {
}
export const UpdateModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
<Modal size="sm" onClose={onClose} isOpen={isOpen}>
<ModalHeader>
<h5 className="modal-title">Update Tipi</h5>
</ModalHeader>
<ModalBody>
<div className="text-muted">Would you like to update Tipi to the latest version?</div>
</ModalBody>
<ModalFooter>
<Button onClick={onConfirm} className="btn-success" loading={loading}>
Update
</Button>
</ModalFooter>
</Modal>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogHeader>
<h5 className="modal-title">Update Tipi</h5>
</DialogHeader>
<DialogDescription>
<div className="text-muted">Would you like to update Tipi to the latest version?</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success" loading={loading}>
Update
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -1,53 +1,52 @@
import React from 'react';
import { useToastStore } from '@/client/state/toastStore';
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { server } from '@/client/mocks/server';
import { GeneralActions } from './GeneralActions';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
describe('Test: GeneralActions', () => {
it('should render without error', () => {
render(<GeneralActions />);
expect(screen.getByText('Update')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
});
it('should show toast if update mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
server.use(getTRPCMockError({ path: ['system', 'update'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<GeneralActions />);
await waitFor(() => {
expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
});
const updateButton = screen.getByText('Update');
const updateButton = screen.getByRole('button', { name: /Update/i });
fireEvent.click(updateButton);
// act
fireEvent.click(updateButton);
const updateButtonModal = screen.getByRole('button', { name: /Update/i });
fireEvent.click(updateButtonModal);
// 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('Something went wrong');
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
});
it('should log user out if update is successful', async () => {
// arrange
localStorage.setItem('token', '123');
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0' } }));
server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
server.use(getTRPCMock({ path: ['system', 'update'], response: true }));
render(<GeneralActions />);
await waitFor(() => {
expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update to 2.0.0' })).toBeInTheDocument();
});
const updateButton = screen.getByText('Update');
const updateButton = screen.getByRole('button', { name: /Update to 2.0.0/i });
fireEvent.click(updateButton);
// act
fireEvent.click(updateButton);
const updateButtonModal = screen.getByRole('button', { name: /Update/i });
fireEvent.click(updateButtonModal);
// assert
await waitFor(() => {
@ -57,22 +56,18 @@ describe('Test: GeneralActions', () => {
it('should show toast if restart mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<GeneralActions />);
// Find button near the top of the page
const restartButton = screen.getByTestId('settings-modal-restart-button');
const restartButton = screen.getByRole('button', { name: /Restart/i });
// act
fireEvent.click(restartButton);
const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
fireEvent.click(restartButtonModal);
// 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('Something went wrong');
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
});
@ -83,10 +78,12 @@ describe('Test: GeneralActions', () => {
render(<GeneralActions />);
// Find button near the top of the page
const restartButton = screen.getByTestId('settings-modal-restart-button');
const restartButton = screen.getByRole('button', { name: /Restart/i });
// act
fireEvent.click(restartButton);
const restartButtonModal = screen.getByRole('button', { name: /Restart/i });
fireEvent.click(restartButtonModal);
// assert
await waitFor(() => {

View file

@ -1,8 +1,10 @@
import React from 'react';
import semver from 'semver';
import { toast } from 'react-hot-toast';
import Markdown from '@/components/Markdown/Markdown';
import { IconStar } from '@tabler/icons-react';
import { Button } from '../../../../components/ui/Button';
import { useDisclosure } from '../../../../hooks/useDisclosure';
import { useToastStore } from '../../../../state/toastStore';
import { RestartModal } from '../../components/RestartModal';
import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
import { trpc } from '../../../../utils/trpc';
@ -12,7 +14,6 @@ export const GeneralActions = () => {
const versionQuery = trpc.system.getVersion.useQuery(undefined, { staleTime: 0 });
const [loading, setLoading] = React.useState(false);
const { addToast } = useToastStore();
const { setPollStatus } = useSystemStore();
const restartDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
@ -30,7 +31,7 @@ export const GeneralActions = () => {
},
onError: (error) => {
updateDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
toast.error(`Error updating instance: ${error.message}`);
},
onSettled: () => {
setLoading(false);
@ -47,7 +48,7 @@ export const GeneralActions = () => {
},
onError: (error) => {
restartDisclosure.close();
addToast({ title: 'Error', description: error.message, status: 'error' });
toast.error(`Error restarting instance: ${error.message}`);
},
onSettled: () => {
setLoading(false);
@ -61,18 +62,31 @@ export const GeneralActions = () => {
return (
<div>
<Button onClick={updateDisclosure.open} className="mr-2 btn-success">
{versionQuery.data?.body && (
<div className="mt-3 card col-4">
<div className="card-stamp">
<div className="card-stamp-icon bg-yellow">
<IconStar size={80} />
</div>
</div>
<div className="card-body">
<Markdown className="">{versionQuery.data.body}</Markdown>
</div>
</div>
)}
<Button onClick={updateDisclosure.open} className="mt-3 mr-2 btn-success">
Update to {versionQuery.data?.latest}
</Button>
</div>
);
};
return (
<div className="col d-flex flex-column">
<>
<div className="card-body">
<h2 className="mb-4">Actions</h2>
<h3 className="card-title mt-4">Version {versionQuery.data?.current}</h3>
<p className="card-subtitle">Stay up to date with the latest version of Tipi</p>
<h3 className="card-title mt-4">Current version: {versionQuery.data?.current}</h3>
<p className="card-subtitle">{isLatest ? 'Stay up to date with the latest version of Tipi' : `A new version (${versionQuery.data?.latest}) of Tipi is available`}</p>
{renderUpdate()}
<h3 className="card-title mt-4">Maintenance</h3>
<p className="card-subtitle">Common actions to perform on your instance</p>
@ -82,6 +96,6 @@ export const GeneralActions = () => {
</div>
<RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />
<UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={() => update.mutate()} loading={loading} />
</div>
</>
);
};

View file

@ -0,0 +1,9 @@
import React from 'react';
import { render } from '../../../../../../tests/test-utils';
import { SecurityContainer } from './SecurityContainer';
describe('<SecurityContainer />', () => {
it('should render', () => {
render(<SecurityContainer />);
});
});

View file

@ -0,0 +1,27 @@
import React from 'react';
import { IconLock, IconKey } from '@tabler/icons-react';
import { OtpForm } from '../../components/OtpForm';
import { ChangePasswordForm } from '../../components/ChangePasswordForm';
export const SecurityContainer = () => {
return (
<div className="card-body">
<div className="d-flex">
<IconKey className="me-2" />
<h2>Change password</h2>
</div>
<p className="text-muted">Changing your password will log you out of all devices.</p>
<ChangePasswordForm />
<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>
<OtpForm />
</div>
);
};

View file

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

View file

@ -1,9 +1,8 @@
import React from 'react';
import { server } from '@/client/mocks/server';
import { getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { useToastStore } from '../../../../state/toastStore';
import { SettingsContainer } from './SettingsContainer';
import { fireEvent, render, renderHook, screen, waitFor } from '../../../../../../tests/test-utils';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
describe('Test: SettingsContainer', () => {
it('should render without error', () => {
@ -14,7 +13,6 @@ describe('Test: SettingsContainer', () => {
it('should show toast if updateSettings mutation fails', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['system', 'updateSettings'], type: 'mutation', status: 500, message: 'Something went wrong' }));
render(<SettingsContainer />);
const submitButton = screen.getByRole('button', { name: 'Save' });
@ -28,9 +26,7 @@ describe('Test: SettingsContainer', () => {
// 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 saving settings');
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
});
@ -50,7 +46,6 @@ describe('Test: SettingsContainer', () => {
it('should show toast if updateSettings mutation succeeds', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
render(<SettingsContainer />);
const submitButton = screen.getByRole('button', { name: 'Save' });
@ -59,8 +54,7 @@ describe('Test: SettingsContainer', () => {
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].status).toEqual('success');
expect(screen.getByText(/Settings updated. Restart your instance to apply new settings./)).toBeInTheDocument();
});
});
});

View file

@ -1,22 +1,21 @@
import React, { useState } from 'react';
import { trpc } from '@/utils/trpc';
import { useToastStore } from '../../../../state/toastStore';
import { toast } from 'react-hot-toast';
import { SettingsForm, SettingsFormValues } from '../../components/SettingsForm';
export const SettingsContainer = () => {
const [errors, setErrors] = useState<Record<string, string>>({});
const { addToast } = useToastStore();
const getSettings = trpc.system.getSettings.useQuery();
const updateSettings = trpc.system.updateSettings.useMutation({
onSuccess: () => {
addToast({ title: 'Settings updated', description: 'Restart your instance for settings to take effect', status: 'success' });
toast.success('Settings updated. Restart your instance to apply new settings.');
},
onError: (e) => {
if (e.shape?.data.zodError) {
setErrors(e.shape.data.zodError);
}
addToast({ title: 'Error saving settings', description: e.message, status: 'error' });
toast.error(`Error saving settings: ${e.message}`);
},
});

View file

@ -4,6 +4,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Layout } from '../../../../components/Layout';
import { GeneralActions } from '../../containers/GeneralActions';
import { SettingsContainer } from '../../containers/SettingsContainer';
import { SecurityContainer } from '../../containers/SecurityContainer';
export const SettingsPage: NextPage = () => {
return (
@ -13,6 +14,7 @@ export const SettingsPage: NextPage = () => {
<TabsList>
<TabsTrigger value="actions">Actions</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="actions">
<GeneralActions />
@ -20,6 +22,9 @@ export const SettingsPage: NextPage = () => {
<TabsContent value="settings">
<SettingsContainer />
</TabsContent>
<TabsContent value="security">
<SecurityContainer />
</TabsContent>
</Tabs>
</div>
</Layout>

View file

@ -1,46 +0,0 @@
import { create } from 'zustand';
export type IToast = {
id: string;
title: string;
description?: string;
status: 'error' | 'success' | 'warning' | 'info';
position?: 'top';
isClosable?: true;
};
type Store = {
toasts: IToast[];
addToast: (toast: Omit<IToast, 'id'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
};
export const useToastStore = create<Store>((set) => ({
toasts: [],
addToast: (toast: Omit<IToast, 'id'>) => {
const { title, description, status, position = 'top', isClosable = true } = toast;
const id = Math.random().toString(36).substring(2, 9);
const toastToAdd = {
id,
title,
description,
status,
position,
isClosable,
};
set((state) => ({
toasts: [...state.toasts, { ...toastToAdd, id }],
}));
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, 5000);
},
removeToast: (id: string) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
clearToasts: () => set({ toasts: [] }),
}));

View file

@ -3,6 +3,11 @@ import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from '../../server/routers/_app';
/**
* Get base url for the current environment
*
* @returns {string} base url
*/
function getBaseUrl() {
if (typeof window !== 'undefined') {
// browser should use relative path

View file

@ -1,5 +1,11 @@
const objectKeys = <T extends object>(obj: T): (keyof T)[] => Object.keys(obj) as (keyof T)[];
/**
* Type guard to check if a value is not null or undefined
*
* @param {any} value - The value to check
* @returns {value is NonNullable<any>} - True if the value is not null or undefined
*/
function nonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}

View file

@ -4,12 +4,19 @@ import type { AppProps } from 'next/app';
import Head from 'next/head';
import '../client/styles/global.css';
import '../client/styles/global.scss';
import 'react-tooltip/dist/react-tooltip.css';
import { Toaster } from 'react-hot-toast';
import { useUIStore } from '../client/state/uiStore';
import { ToastProvider } from '../client/components/hoc/ToastProvider';
import { StatusProvider } from '../client/components/hoc/StatusProvider';
import { trpc } from '../client/utils/trpc';
import { SystemStatus, useSystemStore } from '../client/state/systemStore';
/**
* Next.js App component
*
* @param {AppProps} props - props passed to the app
* @returns {JSX.Element} - JSX element
*/
function MyApp({ Component, pageProps }: AppProps) {
const { setDarkMode } = useUIStore();
const { setStatus, setVersion, pollStatus } = useSystemStore();
@ -42,11 +49,10 @@ function MyApp({ Component, pageProps }: AppProps) {
<Head>
<title>Tipi</title>
</Head>
<ToastProvider>
<StatusProvider>
<Component {...pageProps} />
</StatusProvider>
</ToastProvider>
<StatusProvider>
<Component {...pageProps} />
</StatusProvider>
<Toaster />
<ReactQueryDevtools />
</main>
);

View file

@ -3,6 +3,11 @@ import { Html, Head, Main, NextScript } from 'next/document';
import { getUrl } from '../client/core/helpers/url-helpers';
/**
* Next.js Document component
*
* @returns {JSX.Element} - JSX element
*/
export default function MyDocument() {
return (
<Html lang="en">

View file

@ -1,9 +1,14 @@
import { type GetServerSidePropsContext } from 'next';
import jwt from 'jsonwebtoken';
import { v4 } from 'uuid';
import { getConfig } from '../core/TipiConfig';
import TipiCache from '../core/TipiCache';
import { Logger } from '../core/Logger';
export const generateSessionId = (prefix: string) => {
return `${prefix}-${v4()}`;
};
export const getServerAuthSession = async (ctx: { req: GetServerSidePropsContext['req']; res: GetServerSidePropsContext['res'] }) => {
const { req } = ctx;
const token = req.headers.authorization?.split(' ')[1];

View file

@ -11,19 +11,23 @@ type CreateContextOptions = {
session: Session | null;
};
/** Use this helper for:
/**
* Use this helper for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
*
* @param {CreateContextOptions} opts - options
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
* */
*/
export const createContextInner = async (opts: CreateContextOptions) => ({
session: opts.session,
});
/**
* This is the actual context you'll use in your router
* @link https://trpc.io/docs/context
* */
*
* @param {CreateNextContextOptions} opts - options
*/
export const createContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;

View file

@ -1,6 +1,3 @@
/**
* @jest-environment node
*/
import fs from 'fs-extra';
import { EventDispatcher } from '.';

View file

@ -94,6 +94,8 @@ class EventDispatcher {
/**
* Poll queue and run events
*
* @returns {NodeJS.Timer} - Interval timer
*/
private pollQueue() {
Logger.info(`EventDispatcher(${this.dispatcherId}): Polling queue...`);
@ -207,7 +209,7 @@ class EventDispatcher {
*
* @param {EventType} type - Event type
* @param {[string[]]} args - Event arguments
* @returns - Promise that resolves when the event is done
* @returns {Promise<{ success: boolean; stdout?: string }>} - Promise that resolves when the event is done
*/
public async dispatchEventAsync(type: EventType, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
const event = this.dispatchEvent(type, args);

View file

@ -48,6 +48,20 @@ class TipiCache {
return client.del(key);
}
public async delByValue(value: string, prefix: string) {
const client = await this.getClient();
const keys = await client.keys(`${prefix}*`);
const promises = keys.map(async (key) => {
const val = await client.get(key);
if (val === value) {
await client.del(key);
}
});
return Promise.all(promises);
}
public async close() {
return this.client.quit();
}

View file

@ -95,13 +95,11 @@ export class TipiConfig {
this.config = parsedConfig.data;
} else {
const errors = formatErrors(parsedConfig.error.flatten());
console.error(`❌ Invalid env config\n${errors}`);
Logger.error(`❌ Invalid env config\n\n${errors}`);
throw new Error('Invalid env config');
}
} else {
const errors = formatErrors(parsedFileConfig.error.flatten());
console.error(`❌ Invalid settings.json file:\n${errors}`);
Logger.error(`❌ Invalid settings.json file:\n${errors}`);
throw new Error('Invalid settings.json file');
}

View file

@ -36,7 +36,7 @@ nextApp.prepare().then(async () => {
app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
app.all('*', (req, res) => {
const parsedUrl = parse(req.url!, true);
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});

View file

@ -0,0 +1,23 @@
-- Create totp_secret field if it doesn't exist
ALTER TABLE "user"
ADD COLUMN IF NOT EXISTS "totp_secret" text DEFAULT NULL;
-- Create totp_enabled field if it doesn't exist
ALTER TABLE "user"
ADD COLUMN IF NOT EXISTS "totp_enabled" boolean DEFAULT FALSE;
-- Add salt field to user table
ALTER TABLE "user"
ADD COLUMN IF NOT EXISTS "salt" text DEFAULT NULL;
-- Set all users to have totp enabled false
UPDATE
"user"
SET
"totp_enabled" = FALSE
WHERE
"totp_enabled" IS NULL;
-- Set totp_enabled column to not null constraint
ALTER TABLE "user"
ALTER COLUMN "totp_enabled" SET NOT NULL;

View file

@ -0,0 +1,212 @@
import { PrismaClient } from '@prisma/client';
import { authRouter } from './auth.router';
import { getTestDbClient } from '../../../../tests/server/db-connection';
let db: PrismaClient;
const TEST_SUITE = 'authrouter';
beforeAll(async () => {
db = await getTestDbClient(TEST_SUITE);
jest.spyOn(console, 'log').mockImplementation(() => {});
});
beforeEach(async () => {
await db.user.deleteMany();
// Mute console.log
});
afterAll(async () => {
await db.user.deleteMany();
await db.$disconnect();
});
describe('Test: verifyTotp', () => {
it('should be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.verifyTotp({ totpCode: '123456', totpSessionId: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
expect(error?.code).toBeDefined();
expect(error?.code).not.toBe(null);
});
});
describe('Test: getTotpUri', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.getTotpUri({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 123456 } });
let error;
// act
try {
await caller.getTotpUri({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: setupTotp', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.setupTotp({ totpCode: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 123456 } });
let error;
// act
try {
await caller.setupTotp({ totpCode: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: disableTotp', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.disableTotp({ password: '123456' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 122 } });
let error;
// act
try {
await caller.disableTotp({ password: '112321' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: changeOperatorPassword', () => {
it('should be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.changeOperatorPassword({ newPassword: '222' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 122 } });
let error;
// act
try {
await caller.changeOperatorPassword({ newPassword: '222' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});
describe('Test: resetPassword', () => {
it('should not be accessible without an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: null });
let error;
// act
try {
await caller.changePassword({ currentPassword: '111', newPassword: '222' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).toBe('UNAUTHORIZED');
});
it('should be accessible with an account', async () => {
// arrange
const caller = authRouter.createCaller({ session: { userId: 122 } });
let error;
// act
try {
await caller.changePassword({ currentPassword: '111', newPassword: '222' });
} catch (e) {
error = e as { code: string };
}
// assert
expect(error?.code).not.toBe('UNAUTHORIZED');
});
});

View file

@ -12,7 +12,16 @@ export const authRouter = router({
refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.refreshToken(ctx.session.id)),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
// Password
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
resetPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changePassword({ newPassword: input.newPassword })),
changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
changePassword: protectedProcedure
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.session.userId), ...input })),
// Totp
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input }) => AuthService.verifyTotp(input)),
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.session.userId), password: input.password })),
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.session.userId), totpCode: input.totpCode })),
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.session.userId), password: input.password })),
});

View file

@ -212,9 +212,9 @@ export const generateEnvFile = (app: App) => {
This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
parses the config file, filters out any apps that are not available and returns an array of app information.
If the config.json file is invalid, it logs an error message.
@returns {Promise<AppInfo[]>} - Returns a promise that resolves with an array of available apps' information.
*/
*/
export const getAvailableApps = async () => {
const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
@ -248,7 +248,6 @@ export const getAvailableApps = async () => {
* If the app is not found, it returns null.
*
* @param {string} id - The app id.
* @param {number} [version] - The current version of the app.
* @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
*/
export const getUpdateInfo = (id: string) => {
@ -273,7 +272,7 @@ export const getUpdateInfo = (id: string) => {
* If an error occurs during the process, it logs the error message and throws an error.
*
* @param {string} id - The app id.
* @param {AppStatus} [status] - The app status.
* @param {App['status']} [status] - The app status.
* @returns {AppInfo | null} - Returns an object with app information or null if the app is not found.
*/
export const getAppInfo = (id: string, status?: App['status']) => {

View file

@ -3,6 +3,9 @@ import fs from 'fs-extra';
import * as argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { faker } from '@faker-js/faker';
import { TotpAuthenticator } from '@/server/utils/totp';
import { generateSessionId } from '@/server/common/get-server-auth-session';
import { encrypt } from '../../utils/encryption';
import { setConfig } from '../../core/TipiConfig';
import { createUser } from '../../tests/user.factory';
import { AuthServiceClass } from './auth.service';
@ -38,7 +41,7 @@ describe('Login', () => {
// Act
const { token } = await AuthService.login({ username: email, password: 'password' });
const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
const decoded = jwt.verify(token as string, 'test') as jwt.JwtPayload;
// Assert
expect(decoded).toBeDefined();
@ -60,6 +63,278 @@ describe('Login', () => {
await createUser({ email }, db);
await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
});
// TOTP
it('should return a totp session id the user totp_enabled is true', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
await createUser({ email, totp_enabled: true, totp_secret: totpSecret }, db);
// act
const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
// assert
expect(totpSessionId).toBeDefined();
expect(totpSessionId).not.toBeNull();
expect(token).toBeUndefined();
});
});
describe('Test: verifyTotp', () => {
it('should return a valid jsonwebtoken if the totp is correct', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act
const { token } = await AuthService.verifyTotp({ totpSessionId, totpCode: otp });
// assert
expect(token).toBeDefined();
expect(token).not.toBeNull();
});
it('should throw if the totp is incorrect', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = generateSessionId('otp');
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' })).rejects.toThrowError('Invalid TOTP');
});
it('should throw if the totpSessionId is invalid', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: true, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp })).rejects.toThrowError('TOTP session not found');
});
it('should throw if the user does not exist', async () => {
// arrange
const totpSessionId = generateSessionId('otp');
await TipiCache.set(totpSessionId, '1234');
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
});
it('should throw if the user totp_enabled is false', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_enabled: false, totp_secret: encryptedTotpSecret, salt }, db);
const totpSessionId = generateSessionId('otp');
const otp = TotpAuthenticator.generate(totpSecret);
await TipiCache.set(totpSessionId, user.id.toString());
// act & assert
await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp })).rejects.toThrowError('TOTP is not enabled for this user');
});
});
describe('Test: getTotpUri', () => {
it('should return a valid totp uri', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act
const { uri, key } = await AuthService.getTotpUri({ userId: user.id, password: 'password' });
// assert
expect(uri).toBeDefined();
expect(uri).not.toBeNull();
expect(key).toBeDefined();
expect(key).not.toBeNull();
expect(uri).toContain(key);
});
it('should create a new totp secret if the user does not have one', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('salt');
});
it('should regenerate a new totp secret if the user already has one', async () => {
// arrange
const email = faker.internet.email();
const salt = faker.random.word();
const totpSecret = TotpAuthenticator.generateSecret();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
// act
await AuthService.getTotpUri({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_secret');
expect(userFromDb).toHaveProperty('salt');
expect(userFromDb?.totp_secret).not.toEqual(encryptedTotpSecret);
expect(userFromDb?.salt).toEqual(salt);
});
it('should thorw an error if user has already configured totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is already enabled for this user');
});
it('should throw an error if the user password is incorrect', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
// act & assert
await expect(AuthService.getTotpUri({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
});
it('should throw an error if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.getTotpUri({ userId, password: 'password' })).rejects.toThrowError('User not found');
});
});
describe('Test: setupTotp', () => {
it('should enable totp for the user', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
const otp = TotpAuthenticator.generate(totpSecret);
// act
await AuthService.setupTotp({ userId: user.id, totpCode: otp });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeTruthy();
});
it('should throw if the user has already enabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('TOTP is already enabled for this user');
});
it('should throw if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.setupTotp({ userId, totpCode: '1234' })).rejects.toThrowError('User not found');
});
it('should throw if the otp is invalid', async () => {
// arrange
const email = faker.internet.email();
const totpSecret = TotpAuthenticator.generateSecret();
const salt = faker.random.word();
const encryptedTotpSecret = encrypt(totpSecret, salt);
const user = await createUser({ email, totp_secret: encryptedTotpSecret, salt }, db);
// act & assert
await expect(AuthService.setupTotp({ userId: user.id, totpCode: '1234' })).rejects.toThrowError('Invalid TOTP code');
});
});
describe('Test: disableTotp', () => {
it('should disable totp for the user', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act
await AuthService.disableTotp({ userId: user.id, password: 'password' });
const userFromDb = await db.user.findUnique({ where: { id: user.id } });
// assert
expect(userFromDb).toBeDefined();
expect(userFromDb).not.toBeNull();
expect(userFromDb).toHaveProperty('totp_enabled');
expect(userFromDb?.totp_enabled).toBeFalsy();
});
it('should throw if the user has already disabled totp', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: false }, db);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'password' })).rejects.toThrowError('TOTP is not enabled for this user');
});
it('should throw if the user does not exist', async () => {
// arrange
const userId = 11;
// act & assert
await expect(AuthService.disableTotp({ userId, password: 'password' })).rejects.toThrowError('User not found');
});
it('should throw if the password is invalid', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
// act & assert
await expect(AuthService.disableTotp({ userId: user.id, password: 'wrong' })).rejects.toThrowError('Invalid password');
});
});
describe('Register', () => {
@ -264,7 +539,7 @@ describe('Test: isConfigured', () => {
});
});
describe('Test: changePassword', () => {
describe('Test: changeOperatorPassword', () => {
it('should change the password of the operator user', async () => {
// Arrange
const email = faker.internet.email();
@ -274,7 +549,7 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthService.changePassword({ newPassword });
const result = await AuthService.changeOperatorPassword({ newPassword });
// Assert
expect(result.email).toBe(email.toLowerCase());
@ -291,7 +566,7 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({});
// Act & Assert
await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('No password change request found');
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('No password change request found');
});
it('should throw if there is no operator user', async () => {
@ -303,7 +578,26 @@ describe('Test: changePassword', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act & Assert
await expect(AuthService.changePassword({ newPassword })).rejects.toThrowError('Operator user not found');
await expect(AuthService.changeOperatorPassword({ newPassword })).rejects.toThrowError('Operator user not found');
});
it('should reset totp_secret and totp_enabled if totp is enabled', async () => {
// Arrange
const email = faker.internet.email();
const user = await createUser({ email, totp_enabled: true }, db);
const newPassword = faker.internet.password();
// @ts-expect-error - mocking fs
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthService.changeOperatorPassword({ newPassword });
// Assert
expect(result.email).toBe(email.toLowerCase());
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
expect(updatedUser?.password).not.toBe(user.password);
expect(updatedUser?.totp_enabled).toBe(false);
expect(updatedUser?.totp_secret).toBeNull();
});
});
@ -314,7 +608,7 @@ describe('Test: checkPasswordChangeRequest', () => {
fs.__createMockFiles({ '/runtipi/state/password-change-request': '' });
// Act
const result = await AuthServiceClass.checkPasswordChangeRequest();
const result = AuthServiceClass.checkPasswordChangeRequest();
// Assert
expect(result).toBe(true);
@ -326,7 +620,7 @@ describe('Test: checkPasswordChangeRequest', () => {
fs.__createMockFiles({});
// Act
const result = await AuthServiceClass.checkPasswordChangeRequest();
const result = AuthServiceClass.checkPasswordChangeRequest();
// Assert
expect(result).toBe(false);
@ -346,3 +640,47 @@ describe('Test: cancelPasswordChangeRequest', () => {
expect(fs.existsSync('/runtipi/state/password-change-request')).toBe(false);
});
});
describe('Test: changePassword', () => {
it('should change the password of the user', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const newPassword = faker.internet.password();
// act
await AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' });
// assert
const updatedUser = await db.user.findUnique({ where: { id: user.id } });
expect(updatedUser?.password).not.toBe(user.password);
});
it('should throw if the user does not exist', async () => {
// arrange
const newPassword = faker.internet.password();
// act & assert
await expect(AuthService.changePassword({ userId: 1, newPassword, currentPassword: 'password' })).rejects.toThrowError('User not found');
});
it('should throw if the password is incorrect', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const newPassword = faker.internet.password();
// act & assert
await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'wrongpassword' })).rejects.toThrowError('Current password is invalid');
});
it('should throw if password is less than 8 characters', async () => {
// arrange
const email = faker.internet.email();
const user = await createUser({ email }, db);
const newPassword = faker.internet.password(7);
// act & assert
await expect(AuthService.changePassword({ userId: user.id, newPassword, currentPassword: 'password' })).rejects.toThrowError('Password must be at least 8 characters');
});
});

View file

@ -1,11 +1,13 @@
import { PrismaClient } from '@prisma/client';
import * as argon2 from 'argon2';
import { v4 } from 'uuid';
import jwt from 'jsonwebtoken';
import validator from 'validator';
import { TotpAuthenticator } from '@/server/utils/totp';
import { generateSessionId } from '@/server/common/get-server-auth-session';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
import { decrypt, encrypt } from '../../utils/encryption';
type UsernamePasswordInput = {
username: string;
@ -44,7 +46,14 @@ export class AuthServiceClass {
throw new Error('Wrong password');
}
const session = v4();
const session = generateSessionId('auth');
if (user.totp_enabled) {
const totpSessionId = generateSessionId('otp');
await TipiCache.set(totpSessionId, user.id.toString());
return { totpSessionId };
}
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
@ -52,6 +61,152 @@ export class AuthServiceClass {
return { token };
};
/**
* Verify TOTP code and return a JWT token
*
* @param {object} params - An object containing the TOTP session ID and the TOTP code
* @param {string} params.totpSessionId - The TOTP session ID
* @param {string} params.totpCode - The TOTP code
* @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
*/
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
const { totpSessionId, totpCode } = params;
const userId = await TipiCache.get(totpSessionId);
if (!userId) {
throw new Error('TOTP session not found');
}
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled || !user.totp_secret || !user.salt) {
throw new Error('TOTP is not enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
}
const session = generateSessionId('otp');
const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
await TipiCache.set(session, user.id.toString());
return { token };
};
/**
* Given a userId returns the TOTP URI and the secret key
*
* @param {object} params - An object containing the userId and the user's password
* @param {number} params.userId - The user's ID
* @param {string} params.password - The user's password
* @returns {Promise<{uri: string, key: string}>} - A promise that resolves to an object containing the TOTP URI and the secret key
*/
public getTotpUri = async (params: { userId: number; password: string }) => {
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Invalid password');
}
if (user.totp_enabled) {
throw new Error('TOTP is already enabled for this user');
}
let { salt } = user;
const newTotpSecret = TotpAuthenticator.generateSecret();
if (!salt) {
salt = generateSessionId('');
}
const encryptedTotpSecret = encrypt(newTotpSecret, salt);
await this.prisma.user.update({
where: { id: userId },
data: {
totp_secret: encryptedTotpSecret,
salt,
},
});
const uri = TotpAuthenticator.keyuri(user.username, 'Runtipi', newTotpSecret);
return { uri, key: newTotpSecret };
};
public setupTotp = async (params: { userId: number; totpCode: string }) => {
const { userId, totpCode } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
if (user.totp_enabled || !user.totp_secret || !user.salt) {
throw new Error('TOTP is already enabled for this user');
}
const totpSecret = decrypt(user.totp_secret, user.salt);
const isValid = TotpAuthenticator.check(totpCode, totpSecret);
if (!isValid) {
throw new Error('Invalid TOTP code');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: true,
},
});
return true;
};
public disableTotp = async (params: { userId: number; password: string }) => {
const { userId, password } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
if (!user.totp_enabled) {
throw new Error('TOTP is not enabled for this user');
}
const isPasswordValid = await argon2.verify(user.password, password);
if (!isPasswordValid) {
throw new Error('Invalid password');
}
await this.prisma.user.update({
where: { id: userId },
data: {
totp_enabled: false,
totp_secret: null,
},
});
return true;
};
/**
* Creates a new user with the provided email and password and returns a session token
*
@ -86,7 +241,7 @@ export class AuthServiceClass {
const hash = await argon2.hash(password);
const newUser = await this.prisma.user.create({ data: { username: email, password: hash, operator: true } });
const session = v4();
const session = generateSessionId('auth');
const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(session, newUser.id.toString());
@ -103,7 +258,7 @@ export class AuthServiceClass {
public me = async (userId: number | undefined) => {
if (!userId) return null;
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true } });
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) }, select: { id: true, username: true, totp_enabled: true } });
if (!user) return null;
@ -139,7 +294,7 @@ export class AuthServiceClass {
// Expire token in 6 seconds
await TipiCache.set(session, userId, 6);
const newSession = v4();
const newSession = generateSessionId('auth');
const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
await TipiCache.set(newSession, userId);
@ -165,7 +320,7 @@ export class AuthServiceClass {
* @returns {Promise<string>} - The username of the operator user
* @throws {Error} - If the operator user is not found or if there is no password change request
*/
public changePassword = async (params: { newPassword: string }) => {
public changeOperatorPassword = async (params: { newPassword: string }) => {
if (!AuthServiceClass.checkPasswordChangeRequest()) {
throw new Error('No password change request found');
}
@ -178,7 +333,7 @@ export class AuthServiceClass {
}
const hash = await argon2.hash(newPassword);
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash, totp_enabled: false, totp_secret: null } });
await unlinkFile(`/runtipi/state/password-change-request`);
@ -213,4 +368,31 @@ export class AuthServiceClass {
return true;
};
public changePassword = async (params: { currentPassword: string; newPassword: string; userId: number }) => {
const { currentPassword, newPassword, userId } = params;
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error('User not found');
}
const valid = await argon2.verify(user.password, currentPassword);
if (!valid) {
throw new Error('Current password is invalid');
}
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
const hash = await argon2.hash(newPassword);
await this.prisma.user.update({ where: { id: user.id }, data: { password: hash } });
await TipiCache.delByValue(userId.toString(), 'auth');
return true;
};
}

View file

@ -66,10 +66,11 @@ describe('Test: getVersion', () => {
jest.restoreAllMocks();
});
it('It should return version', async () => {
it('It should return version with body', async () => {
// Arrange
const body = faker.random.words(10);
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }) }));
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`, body }) }));
// Act
const version = await SystemService.getVersion();
@ -78,6 +79,7 @@ describe('Test: getVersion', () => {
expect(version).toBeDefined();
expect(version.current).toBeDefined();
expect(semver.valid(version.latest)).toBeTruthy();
expect(version.body).toBeDefined();
});
it('Should return undefined for latest if request fails', async () => {

View file

@ -39,21 +39,25 @@ export class SystemServiceClass {
/**
* Get the current and latest version of Tipi
*
* @returns {Promise<{ current: string; latest: string }>}
* @returns {Promise<{ current: string; latest: string }>} The current and latest version
*/
public getVersion = async (): Promise<{ current: string; latest?: string }> => {
public getVersion = async () => {
try {
let version = await this.cache.get('latestVersion');
let body = await this.cache.get('latestVersionBody');
if (!version) {
const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const release = (await data.json()) as { name: string };
const release = (await data.json()) as { name: string; body: string };
version = release.name.replace('v', '');
body = release.body;
await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
await this.cache.set('latestVersionBody', body || '', 60 * 60);
}
return { current: TipiConfig.getConfig().version, latest: version?.replace('v', '') };
return { current: TipiConfig.getConfig().version, latest: version?.replace('v', ''), body };
} catch (e) {
Logger.error(e);
return { current: TipiConfig.getConfig().version, latest: undefined };

View file

@ -1,19 +1,15 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';
import * as argon2 from 'argon2';
import { User } from '@prisma/client';
import { prisma } from '../db/client';
type CreateUserParams = {
email?: string;
operator?: boolean;
};
const createUser = async (params: CreateUserParams, db = prisma) => {
const { email, operator = true } = params;
const createUser = async (params: Partial<User & { email?: string }>, db = prisma) => {
const { email, operator = true, ...rest } = params;
const hash = await argon2.hash('password');
const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim();
const user = await db.user.create({ data: { username, password: hash, operator } });
const user = await db.user.create({ data: { username, password: hash, operator, ...rest } });
return user;
};

View file

@ -4,8 +4,10 @@ import { typeToFlattenedError, ZodError } from 'zod';
import { type Context } from './context';
/**
* Convert ZodError to a record
*
* @param errors
* @param {typeToFlattenedError<string>} errors - errors
* @returns {Record<string, string>} record
*/
export function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
const record: Record<string, string> = {};

View file

@ -0,0 +1,32 @@
import { faker } from '@faker-js/faker';
import { setConfig } from '../../core/TipiConfig';
import { encrypt, decrypt } from '../encryption';
describe('Test: encrypt', () => {
it('should encrypt the provided data', () => {
// arrange
setConfig('jwtSecret', faker.random.word());
const data = faker.random.word();
const salt = faker.random.word();
// act
const encryptedData = encrypt(data, salt);
// assert
expect(encryptedData).not.toEqual(data);
});
it('should decrypt the provided data', () => {
// arrange
setConfig('jwtSecret', faker.random.word());
const data = faker.random.word();
const salt = faker.random.word();
// act
const encryptedData = encrypt(data, salt);
const decryptedData = decrypt(encryptedData, salt);
// assert
expect(decryptedData).toEqual(data);
});
});

Some files were not shown because too many files have changed in this diff Show more