commit
69386778a7
105 changed files with 3035 additions and 972 deletions
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -56,6 +56,7 @@ services:
|
|||
DOMAIN: ${DOMAIN}
|
||||
ARCHITECTURE: ${ARCHITECTURE}
|
||||
REDIS_HOST: ${REDIS_HOST}
|
||||
DEMO_MODE: ${DEMO_MODE}
|
||||
networks:
|
||||
- tipi_main_network
|
||||
ports:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
15
package.json
15
package.json
|
@ -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
359
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { ToastProvider } from './ToastProvider';
|
|
@ -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;
|
68
src/client/components/ui/Dialog/Dialog.tsx
Normal file
68
src/client/components/ui/Dialog/Dialog.tsx
Normal 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 };
|
1
src/client/components/ui/Dialog/index.ts
Normal file
1
src/client/components/ui/Dialog/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Dialog, DialogTitle, DialogFooter, DialogHeader, DialogContent, DialogTrigger, DialogDescription } from './Dialog';
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -1,4 +0,0 @@
|
|||
export { Modal } from './Modal';
|
||||
export { ModalBody } from './ModalBody';
|
||||
export { ModalFooter } from './ModalFooter';
|
||||
export { ModalHeader } from './ModalHeader';
|
16
src/client/components/ui/OtpInput/OtpInput.module.scss
Normal file
16
src/client/components/ui/OtpInput/OtpInput.module.scss
Normal 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;
|
||||
}
|
262
src/client/components/ui/OtpInput/OtpInput.test.tsx
Normal file
262
src/client/components/ui/OtpInput/OtpInput.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
157
src/client/components/ui/OtpInput/OtpInput.tsx
Normal file
157
src/client/components/ui/OtpInput/OtpInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
src/client/components/ui/OtpInput/index.ts
Normal file
1
src/client/components/ui/OtpInput/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { OtpInput } from './OtpInput';
|
10
src/client/components/ui/Switch/Switch.module.scss
Normal file
10
src/client/components/ui/Switch/Switch.module.scss
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
|
|||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="h2 text-center mb-3">Login to your account</h2>
|
||||
<Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
|
||||
<Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
|
||||
<span className="form-label-description">
|
||||
<Link href="/reset-password">Forgot password?</Link>
|
||||
</span>
|
||||
<Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
|
||||
<Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
|
||||
<Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
|
||||
Login
|
||||
</Button>
|
||||
|
|
32
src/client/modules/Auth/components/TotpForm/TotpForm.tsx
Normal file
32
src/client/modules/Auth/components/TotpForm/TotpForm.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Button } from '@/components/ui/Button';
|
||||
import { OtpInput } from '@/components/ui/OtpInput';
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
onSubmit: (totpCode: string) => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const TotpForm = (props: Props) => {
|
||||
const { onSubmit, loading } = props;
|
||||
const [totpCode, setTotpCode] = React.useState('');
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
setTotpCode('');
|
||||
e.preventDefault();
|
||||
onSubmit(totpCode);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<h3 className="">Two-factor authentication</h3>
|
||||
<p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
|
||||
<OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
|
||||
<Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
1
src/client/modules/Auth/components/TotpForm/index.ts
Normal file
1
src/client/modules/Auth/components/TotpForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { TotpForm } from './TotpForm';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ChangePasswordForm } from './ChangePasswordForm';
|
285
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
Normal file
285
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
153
src/client/modules/Settings/components/OtpForm/OtpForm.tsx
Normal file
153
src/client/modules/Settings/components/OtpForm/OtpForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
1
src/client/modules/Settings/components/OtpForm/index.ts
Normal file
1
src/client/modules/Settings/components/OtpForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { OtpForm } from './OtpForm';
|
|
@ -1,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>
|
||||
);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 />);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { SecurityContainer } from './SecurityContainer';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: [] }),
|
||||
}));
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import fs from 'fs-extra';
|
||||
import { EventDispatcher } from '.';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
23
src/server/migrations/00006-add-totp-user-fields.sql
Normal file
23
src/server/migrations/00006-add-totp-user-fields.sql
Normal 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;
|
212
src/server/routers/auth/auth.router.test.ts
Normal file
212
src/server/routers/auth/auth.router.test.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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 })),
|
||||
});
|
||||
|
|
|
@ -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']) => {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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> = {};
|
||||
|
|
32
src/server/utils/__tests__/encryption.test.ts
Normal file
32
src/server/utils/__tests__/encryption.test.ts
Normal 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
Loading…
Add table
Reference in a new issue