diff --git a/.gitignore b/.gitignore
index f2e3ced..f66b568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
+coverage
*.local
# Environment variables
diff --git a/eslint.config.js b/eslint.config.js
index 5e6b472..4d7c104 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -3,21 +3,34 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
-export default defineConfig([
- globalIgnores(['dist']),
+export default tseslint.config(
+ { ignores: ['dist'] },
{
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
- extends: [
- js.configs.recommended,
- tseslint.configs.recommended,
- reactHooks.configs.flat.recommended,
- reactRefresh.configs.vite,
- ],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ '@typescript-eslint/no-explicit-any': 'warn', // Change from error to warning
+ },
},
-])
+ {
+ files: ['src/test/**/*.{ts,tsx}'],
+ rules: {
+ 'react-refresh/only-export-components': 'off',
+ '@typescript-eslint/no-explicit-any': 'off', // Allow any in test files
+ },
+ },
+)
diff --git a/index.html b/index.html
index c1b6187..9fe4b63 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
@@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
- yaltopia-ticket-admin
+ Yaltopia Ticket Admin
diff --git a/package-lock.json b/package-lock.json
index 0dff5d1..907e7b2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@sentry/react": "^10.39.0",
"@tanstack/react-query": "^5.90.12",
- "axios": "^1.13.2",
+ "axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -42,6 +42,7 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
@@ -435,6 +436,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -1106,20 +1117,20 @@
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
- "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
+ "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ajv": "^6.12.4",
+ "ajv": "^6.14.0",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.1",
- "minimatch": "^3.1.2",
+ "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1"
},
"engines": {
@@ -1143,9 +1154,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.39.2",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
- "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
+ "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3871,17 +3882,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
- "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
+ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.54.0",
- "@typescript-eslint/type-utils": "8.54.0",
- "@typescript-eslint/utils": "8.54.0",
- "@typescript-eslint/visitor-keys": "8.54.0",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/type-utils": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
@@ -3894,8 +3905,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.54.0",
- "eslint": "^8.57.0 || ^9.0.0",
+ "@typescript-eslint/parser": "^8.56.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
@@ -3910,16 +3921,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
- "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
+ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.54.0",
- "@typescript-eslint/types": "8.54.0",
- "@typescript-eslint/typescript-estree": "8.54.0",
- "@typescript-eslint/visitor-keys": "8.54.0",
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3930,19 +3941,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz",
- "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
+ "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.54.0",
- "@typescript-eslint/types": "^8.54.0",
+ "@typescript-eslint/tsconfig-utils": "^8.56.1",
+ "@typescript-eslint/types": "^8.56.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3957,14 +3968,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz",
- "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
+ "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.54.0",
- "@typescript-eslint/visitor-keys": "8.54.0"
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3975,9 +3986,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz",
- "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
+ "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3992,15 +4003,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz",
- "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
+ "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.54.0",
- "@typescript-eslint/typescript-estree": "8.54.0",
- "@typescript-eslint/utils": "8.54.0",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
@@ -4012,14 +4023,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz",
- "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
+ "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4031,18 +4042,18 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz",
- "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
+ "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.54.0",
- "@typescript-eslint/tsconfig-utils": "8.54.0",
- "@typescript-eslint/types": "8.54.0",
- "@typescript-eslint/visitor-keys": "8.54.0",
+ "@typescript-eslint/project-service": "8.56.1",
+ "@typescript-eslint/tsconfig-utils": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3",
- "minimatch": "^9.0.5",
+ "minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
@@ -4058,36 +4069,49 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
- "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
+ "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -4098,16 +4122,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz",
- "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
+ "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.54.0",
- "@typescript-eslint/types": "8.54.0",
- "@typescript-eslint/typescript-estree": "8.54.0"
+ "@typescript-eslint/scope-manager": "8.56.1",
+ "@typescript-eslint/types": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4117,19 +4141,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz",
- "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
+ "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.54.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.56.1",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4139,6 +4163,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -4160,6 +4197,37 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -4327,9 +4395,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4437,6 +4505,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
+ "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4481,13 +4568,13 @@
}
},
"node_modules/axios": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
- "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -5272,9 +5359,9 @@
}
},
"node_modules/eslint": {
- "version": "9.39.2",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
- "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
+ "version": "9.39.3",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
+ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5284,7 +5371,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.39.2",
+ "@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -5860,6 +5947,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -6030,6 +6124,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -6255,6 +6388,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6327,9 +6501,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
+ "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7698,16 +7872,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.54.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz",
- "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==",
+ "version": "8.56.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
+ "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.54.0",
- "@typescript-eslint/parser": "8.54.0",
- "@typescript-eslint/typescript-estree": "8.54.0",
- "@typescript-eslint/utils": "8.54.0"
+ "@typescript-eslint/eslint-plugin": "8.56.1",
+ "@typescript-eslint/parser": "8.56.1",
+ "@typescript-eslint/typescript-estree": "8.56.1",
+ "@typescript-eslint/utils": "8.56.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7717,7 +7891,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
diff --git a/package.json b/package.json
index d057be6..815d0ba 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@sentry/react": "^10.39.0",
"@tanstack/react-query": "^5.90.12",
- "axios": "^1.13.2",
+ "axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -51,6 +51,7 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
+ "@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
diff --git a/public/admin-icon.svg b/public/admin-icon.svg
new file mode 100644
index 0000000..ee6fe69
--- /dev/null
+++ b/public/admin-icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/components/DebugLogin.tsx b/src/components/DebugLogin.tsx
deleted file mode 100644
index c676022..0000000
--- a/src/components/DebugLogin.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-// Temporary debug component - Remove after fixing login issue
-import { useEffect } from 'react'
-
-export function DebugLogin() {
- useEffect(() => {
- console.log('=== LOGIN DEBUG INFO ===')
- console.log('Current path:', window.location.pathname)
- console.log('Access token:', localStorage.getItem('access_token'))
- console.log('User:', localStorage.getItem('user'))
- console.log('Token exists:', !!localStorage.getItem('access_token'))
-
- // Parse user if exists
- const userStr = localStorage.getItem('user')
- if (userStr) {
- try {
- const user = JSON.parse(userStr)
- console.log('User role:', user.role)
- console.log('Is admin:', user.role === 'ADMIN')
- } catch (e) {
- console.error('Failed to parse user:', e)
- }
- }
- console.log('========================')
- }, [])
-
- return null
-}
diff --git a/src/components/__tests__/ErrorBoundary.test.tsx b/src/components/__tests__/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..242e615
--- /dev/null
+++ b/src/components/__tests__/ErrorBoundary.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@/test/test-utils'
+import { ErrorBoundary } from '../ErrorBoundary'
+import { Sentry } from '@/lib/sentry'
+
+// Mock Sentry
+vi.mock('@/lib/sentry', () => ({
+ Sentry: {
+ captureException: vi.fn(),
+ },
+}))
+
+// Component that throws an error
+const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
+ if (shouldThrow) {
+ throw new Error('Test error')
+ }
+ return No error
+}
+
+describe('ErrorBoundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Suppress console.error for cleaner test output
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ it('should render children when there is no error', () => {
+ render(
+
+ Test Content
+
+ )
+
+ expect(screen.getByText('Test Content')).toBeInTheDocument()
+ })
+
+ it('should render error UI when an error is thrown', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument()
+ expect(screen.getByText(/An unexpected error occurred/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Go to Home/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Refresh Page/i })).toBeInTheDocument()
+ })
+
+ it('should log error to Sentry', () => {
+ render(
+
+
+
+ )
+
+ expect(Sentry.captureException).toHaveBeenCalled()
+ })
+
+ it('should show error message in development mode', () => {
+ const originalEnv = import.meta.env.DEV
+ import.meta.env.DEV = true
+
+ render(
+
+
+
+ )
+
+ expect(screen.getByText('Test error')).toBeInTheDocument()
+
+ import.meta.env.DEV = originalEnv
+ })
+
+ it('should handle refresh button click', () => {
+ render(
+
+
+
+ )
+
+ const refreshButton = screen.getByRole('button', { name: /Refresh Page/i })
+
+ // Verify button exists and is clickable
+ expect(refreshButton).toBeInTheDocument()
+ expect(refreshButton).toBeEnabled()
+
+ // Note: We can't easily test window.location.reload() in jsdom
+ // The button's onClick handler calls window.location.reload() which is tested manually
+ })
+})
diff --git a/src/components/ui/badge-variants.ts b/src/components/ui/badge-variants.ts
new file mode 100644
index 0000000..1bd9724
--- /dev/null
+++ b/src/components/ui/badge-variants.ts
@@ -0,0 +1,21 @@
+import { cva } from "class-variance-authority"
+
+export const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index e87d62b..0abefc2 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -1,27 +1,8 @@
import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
+import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-
-const badgeVariants = cva(
- "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
- {
- variants: {
- variant: {
- default:
- "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
- destructive:
- "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
- outline: "text-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
+import { badgeVariants } from "./badge-variants"
export interface BadgeProps
extends React.HTMLAttributes,
@@ -33,4 +14,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
)
}
-export { Badge, badgeVariants }
+export { Badge }
diff --git a/src/components/ui/button-variants.ts b/src/components/ui/button-variants.ts
new file mode 100644
index 0000000..7f96851
--- /dev/null
+++ b/src/components/ui/button-variants.ts
@@ -0,0 +1,31 @@
+import { cva } from "class-variance-authority"
+
+export const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 65d4fcd..25fa2bd 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -1,38 +1,9 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "bg-primary text-primary-foreground shadow hover:bg-primary/90",
- destructive:
- "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
- outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary:
- "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
+import { buttonVariants } from "./button-variants"
export interface ButtonProps
extends React.ButtonHTMLAttributes,
@@ -54,4 +25,4 @@ const Button = React.forwardRef(
)
Button.displayName = "Button"
-export { Button, buttonVariants }
+export { Button }
diff --git a/src/layouts/app-shell.tsx b/src/layouts/app-shell.tsx
index bac8caf..e264b7f 100644
--- a/src/layouts/app-shell.tsx
+++ b/src/layouts/app-shell.tsx
@@ -1,5 +1,5 @@
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"
-import { useEffect, useState } from "react"
+import { useState } from "react"
import {
LayoutDashboard,
Users,
@@ -53,19 +53,21 @@ const adminNavigationItems = [
export function AppShell() {
const location = useLocation()
const navigate = useNavigate()
- const [user, setUser] = useState(null)
const [searchQuery, setSearchQuery] = useState("")
-
- useEffect(() => {
+
+ // Initialize user from localStorage
+ const [user] = useState(() => {
const userStr = localStorage.getItem('user')
if (userStr) {
try {
- setUser(JSON.parse(userStr))
+ return JSON.parse(userStr)
} catch (error) {
console.error('Failed to parse user data:', error)
+ return null
}
}
- }, [])
+ return null
+ })
const isActive = (path: string) => {
return location.pathname.startsWith(path)
@@ -98,7 +100,6 @@ export function AppShell() {
}
const handleNotificationClick = () => {
- console.log('Notification button clicked')
navigate('/notifications')
}
diff --git a/src/lib/__tests__/utils.test.ts b/src/lib/__tests__/utils.test.ts
index 4e3bb8f..dbc545b 100644
--- a/src/lib/__tests__/utils.test.ts
+++ b/src/lib/__tests__/utils.test.ts
@@ -1,25 +1,50 @@
import { describe, it, expect } from 'vitest'
import { cn } from '../utils'
-describe('utils', () => {
- describe('cn', () => {
- it('should merge class names correctly', () => {
- const result = cn('text-red-500', 'bg-blue-500')
- expect(result).toContain('text-red-500')
- expect(result).toContain('bg-blue-500')
- })
+describe('cn utility', () => {
+ it('should merge class names', () => {
+ const result = cn('class1', 'class2')
+ expect(result).toBe('class1 class2')
+ })
- it('should handle conditional classes', () => {
- const result = cn('base-class', false && 'hidden', true && 'visible')
- expect(result).toContain('base-class')
- expect(result).toContain('visible')
- expect(result).not.toContain('hidden')
- })
+ it('should handle conditional classes', () => {
+ const isConditional = true
+ const isHidden = false
+ const result = cn('base', isConditional && 'conditional', isHidden && 'hidden')
+ expect(result).toBe('base conditional')
+ })
- it('should merge conflicting Tailwind classes', () => {
- const result = cn('p-4', 'p-8')
- // tailwind-merge should keep only p-8
- expect(result).toBe('p-8')
+ it('should merge Tailwind classes correctly', () => {
+ const result = cn('px-2 py-1', 'px-4')
+ expect(result).toBe('py-1 px-4')
+ })
+
+ it('should handle arrays of classes', () => {
+ const result = cn(['class1', 'class2'], 'class3')
+ expect(result).toBe('class1 class2 class3')
+ })
+
+ it('should handle objects with boolean values', () => {
+ const result = cn({
+ 'class1': true,
+ 'class2': false,
+ 'class3': true,
})
+ expect(result).toBe('class1 class3')
+ })
+
+ it('should handle undefined and null values', () => {
+ const result = cn('class1', undefined, null, 'class2')
+ expect(result).toBe('class1 class2')
+ })
+
+ it('should handle empty input', () => {
+ const result = cn()
+ expect(result).toBe('')
+ })
+
+ it('should merge conflicting Tailwind classes', () => {
+ const result = cn('text-red-500', 'text-blue-500')
+ expect(result).toBe('text-blue-500')
})
})
diff --git a/src/pages/activity-log/index.tsx b/src/pages/activity-log/index.tsx
index 2b0137f..9c83c47 100644
--- a/src/pages/activity-log/index.tsx
+++ b/src/pages/activity-log/index.tsx
@@ -13,7 +13,7 @@ import {
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
-import { auditService } from "@/services"
+import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns"
export default function ActivityLogPage() {
@@ -26,7 +26,7 @@ export default function ActivityLogPage() {
const { data: auditData, isLoading } = useQuery({
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
queryFn: async () => {
- const params: any = { page, limit }
+ const params: Record = { page, limit }
if (search) params.search = search
if (actionFilter) params.action = actionFilter
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
@@ -130,7 +130,7 @@ export default function ActivityLogPage() {
- {auditData?.data?.map((log: any) => (
+ {auditData?.data?.map((log: AuditLog) => (
{log.id}
{log.userId || 'N/A'}
@@ -143,7 +143,7 @@ export default function ActivityLogPage() {
{log.resourceId}
{log.ipAddress || 'N/A'}
- {format(new Date(log.timestamp || log.createdAt), 'MMM dd, yyyy HH:mm:ss')}
+ {format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
- {apiUsage?.map((endpoint: any, index: number) => (
+ {apiUsage?.map((endpoint: ApiUsageData, index: number) => (
- {endpoint.endpoint}
- {endpoint.calls}
- {endpoint.avgDuration?.toFixed(2) || 'N/A'}
+ {endpoint.date}
+ {endpoint.requests}
+ {endpoint.avgResponseTime?.toFixed(2) || 'N/A'}
))}
diff --git a/src/pages/admin/analytics/storage.tsx b/src/pages/admin/analytics/storage.tsx
index 92af5f6..56e3c52 100644
--- a/src/pages/admin/analytics/storage.tsx
+++ b/src/pages/admin/analytics/storage.tsx
@@ -2,11 +2,17 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
import { analyticsService } from "@/services"
+import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types"
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
+interface ChartDataItem {
+ name: string
+ value: number
+}
+
export default function AnalyticsStoragePage() {
- const { data: storage, isLoading } = useQuery({
+ const { data: storage, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'storage'],
queryFn: () => analyticsService.getStorageAnalytics(),
})
@@ -19,7 +25,7 @@ export default function AnalyticsStoragePage() {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
- const chartData = storage?.byCategory?.map((cat: any) => ({
+ const chartData: ChartDataItem[] = storage?.byCategory?.map((cat) => ({
name: cat.category,
value: cat.size,
})) || []
@@ -73,7 +79,7 @@ export default function AnalyticsStoragePage() {
fill="#8884d8"
dataKey="value"
>
- {chartData.map((_entry: any, index: number) => (
+ {chartData.map((_entry: ChartDataItem, index: number) => (
|
))}
@@ -97,13 +103,13 @@ export default function AnalyticsStoragePage() {
- {storage.topUsers.map((user: any, index: number) => (
+ {storage.topUsers.map((user: StorageByUser, index: number) => (
-
{user.user}
-
{user.files} files
+
{user.userName || user.email}
+
{user.documentCount} files
-
{formatBytes(user.size)}
+
{formatBytes(user.storageUsed)}
))}
diff --git a/src/pages/admin/announcements/index.tsx b/src/pages/admin/announcements/index.tsx
index 5a1f656..714772d 100644
--- a/src/pages/admin/announcements/index.tsx
+++ b/src/pages/admin/announcements/index.tsx
@@ -20,16 +20,17 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Plus, Edit, Trash2 } from "lucide-react"
-import { announcementService } from "@/services"
+import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
+import type { ApiError } from "@/types/error.types"
export default function AnnouncementsPage() {
const queryClient = useQueryClient()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [formDialogOpen, setFormDialogOpen] = useState(false)
- const [selectedAnnouncement, setSelectedAnnouncement] = useState(null)
- const [formData, setFormData] = useState({
+ const [selectedAnnouncement, setSelectedAnnouncement] = useState(null)
+ const [formData, setFormData] = useState({
title: '',
message: '',
type: 'info' as 'info' | 'warning' | 'success' | 'error',
@@ -45,20 +46,21 @@ export default function AnnouncementsPage() {
})
const createMutation = useMutation({
- mutationFn: (data: any) => announcementService.createAnnouncement(data),
+ mutationFn: (data: CreateAnnouncementData) => announcementService.createAnnouncement(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement created successfully")
setFormDialogOpen(false)
resetForm()
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to create announcement")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to create announcement")
},
})
const updateMutation = useMutation({
- mutationFn: ({ id, data }: { id: string; data: any }) =>
+ mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
announcementService.updateAnnouncement(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
@@ -66,8 +68,9 @@ export default function AnnouncementsPage() {
setFormDialogOpen(false)
resetForm()
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to update announcement")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update announcement")
},
})
@@ -78,8 +81,9 @@ export default function AnnouncementsPage() {
toast.success("Announcement deleted successfully")
setDeleteDialogOpen(false)
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to delete announcement")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to delete announcement")
},
})
@@ -101,7 +105,7 @@ export default function AnnouncementsPage() {
setFormDialogOpen(true)
}
- const handleOpenEditDialog = (announcement: any) => {
+ const handleOpenEditDialog = (announcement: Announcement) => {
setSelectedAnnouncement(announcement)
setFormData({
title: announcement.title || '',
@@ -174,7 +178,7 @@ export default function AnnouncementsPage() {
- {announcements?.map((announcement: any) => (
+ {announcements?.map((announcement: Announcement) => (
{announcement.title}
@@ -268,7 +272,7 @@ export default function AnnouncementsPage() {
- {auditData?.data?.map((log: any) => (
+ {auditData?.data?.map((log: AuditLog) => (
{log.action}
@@ -79,7 +79,7 @@ export default function AuditPage() {
{log.userId || 'N/A'}
{log.ipAddress || 'N/A'}
- {format(new Date(log.createdAt), 'MMM dd, yyyy HH:mm')}
+ {format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
- {apiKeys?.map((key: any) => (
+ {apiKeys?.map((key: ApiKey) => (
{key.name}
{key.userId || 'N/A'}
- {key.lastUsedAt ? format(new Date(key.lastUsedAt), 'MMM dd, yyyy') : 'Never'}
+ {key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
-
- {key.revoked ? 'Revoked' : 'Active'}
+
+ {key.isActive ? 'Active' : 'Revoked'}
- {!key.revoked && (
+ {key.isActive && (
- {failedLogins?.data?.map((login: any) => (
+ {failedLogins?.data?.map((login: FailedLogin) => (
{login.email}
{login.ipAddress}
- {login.userAgent}
+ {login.ipAddress}
{login.reason || 'N/A'}
- {format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')}
+ {format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
-
- {login.blocked ? 'Yes' : 'No'}
+
+ N/A
diff --git a/src/pages/admin/security/rate-limits.tsx b/src/pages/admin/security/rate-limits.tsx
index ed928ba..081000d 100644
--- a/src/pages/admin/security/rate-limits.tsx
+++ b/src/pages/admin/security/rate-limits.tsx
@@ -9,6 +9,7 @@ import {
TableRow,
} from "@/components/ui/table"
import { securityService } from "@/services"
+import type { RateLimitViolation } from "@/types/security.types"
export default function RateLimitsPage() {
const { data: violations, isLoading } = useQuery({
@@ -39,7 +40,7 @@ export default function RateLimitsPage() {
- {violations?.map((violation: any) => (
+ {violations?.map((violation: RateLimitViolation) => (
{violation.userId || 'N/A'}
{violation.ipAddress}
diff --git a/src/pages/admin/security/sessions.tsx b/src/pages/admin/security/sessions.tsx
index 6886171..eaea60f 100644
--- a/src/pages/admin/security/sessions.tsx
+++ b/src/pages/admin/security/sessions.tsx
@@ -10,7 +10,7 @@ import {
TableRow,
} from "@/components/ui/table"
import { LogOut } from "lucide-react"
-import { securityService } from "@/services"
+import { securityService, type ActiveSession } from "@/services"
import { format } from "date-fns"
export default function SessionsPage() {
@@ -43,7 +43,7 @@ export default function SessionsPage() {
- {sessions?.map((session: any) => (
+ {sessions?.map((session: ActiveSession) => (
{session.userId || 'N/A'}
{session.ipAddress}
diff --git a/src/pages/admin/security/suspicious.tsx b/src/pages/admin/security/suspicious.tsx
index 3a72fda..0f066ce 100644
--- a/src/pages/admin/security/suspicious.tsx
+++ b/src/pages/admin/security/suspicious.tsx
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Shield, Ban } from "lucide-react"
import { securityService } from "@/services"
+import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
export default function SuspiciousActivityPage() {
const { data: suspicious, isLoading } = useQuery({
@@ -27,7 +28,7 @@ export default function SuspiciousActivityPage() {
Loading...
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
- {suspicious?.suspiciousIPs?.map((ip: any, index: number) => (
+ {suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
{ip.ipAddress}
@@ -60,7 +61,7 @@ export default function SuspiciousActivityPage() {
Loading...
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
- {suspicious?.suspiciousEmails?.map((email: any, index: number) => (
+ {suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
{email.email}
diff --git a/src/pages/admin/settings/index.tsx b/src/pages/admin/settings/index.tsx
index 784f002..f84e469 100644
--- a/src/pages/admin/settings/index.tsx
+++ b/src/pages/admin/settings/index.tsx
@@ -15,8 +15,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Plus } from "lucide-react"
-import { settingsService } from "@/services"
+import { settingsService, type Setting } from "@/services"
import { toast } from "sonner"
+import type { ApiError } from "@/types/error.types"
export default function SettingsPage() {
const queryClient = useQueryClient()
@@ -41,8 +42,9 @@ export default function SettingsPage() {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting updated successfully")
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to update setting")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update setting")
},
})
@@ -60,8 +62,9 @@ export default function SettingsPage() {
setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false })
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to create setting")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to create setting")
},
})
@@ -112,8 +115,8 @@ export default function SettingsPage() {
{isLoading ? (
Loading settings...
) : settings && settings.length > 0 ? (
- settings.map((setting: any) => (
-
+ settings.map((setting: Setting) => (
+
{activity && activity.length > 0 ? (
- {activity.map((item: any, index: number) => (
+ {activity.map((item: ActivityItem, index: number) => (
@@ -44,7 +52,7 @@ export default function UserActivityPage() {
{item.description || item.message}
- {format(new Date(item.createdAt || item.timestamp), 'PPpp')}
+ {(item.createdAt || item.timestamp) && format(new Date(item.createdAt || item.timestamp!), 'PPpp')}
diff --git a/src/pages/admin/users/index.tsx b/src/pages/admin/users/index.tsx
index a9b6fd0..997e904 100644
--- a/src/pages/admin/users/index.tsx
+++ b/src/pages/admin/users/index.tsx
@@ -33,6 +33,17 @@ import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-rea
import { userService } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
+import type { ApiError } from "@/types/error.types"
+
+interface User {
+ id: string
+ email: string
+ firstName: string
+ lastName: string
+ role: string
+ isActive: boolean
+ createdAt: string
+}
export default function UsersPage() {
const navigate = useNavigate()
@@ -42,7 +53,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState
("all")
const [statusFilter, setStatusFilter] = useState("all")
- const [selectedUser, setSelectedUser] = useState(null)
+ const [selectedUser, setSelectedUser] = useState(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
@@ -51,7 +62,7 @@ export default function UsersPage() {
const { data: usersData, isLoading } = useQuery({
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
queryFn: async () => {
- const params: any = { page, limit }
+ const params: Record = { page, limit }
if (search) params.search = search
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
@@ -67,8 +78,9 @@ export default function UsersPage() {
toast.success("User deleted successfully")
setDeleteDialogOpen(false)
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to delete user")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to delete user")
},
})
@@ -78,8 +90,9 @@ export default function UsersPage() {
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
setResetPasswordDialogOpen(false)
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to reset password")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to reset password")
},
})
@@ -91,8 +104,9 @@ export default function UsersPage() {
setImportDialogOpen(false)
setImportFile(null)
},
- onError: (error: any) => {
- toast.error(error.response?.data?.message || "Failed to import users")
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to import users")
},
})
@@ -105,8 +119,9 @@ export default function UsersPage() {
a.download = `users-${new Date().toISOString()}.csv`
a.click()
toast.success("Users exported successfully")
- } catch (error: any) {
- toast.error(error.response?.data?.message || "Failed to export users")
+ } catch (error) {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to export users")
}
}
@@ -223,7 +238,7 @@ export default function UsersPage() {
- {usersData?.data?.map((user: any) => (
+ {usersData?.data?.map((user: User) => (
{user.email}
{user.firstName} {user.lastName}
diff --git a/src/pages/login/__tests__/index.test.tsx b/src/pages/login/__tests__/index.test.tsx
index 3c0afb2..f32ad71 100644
--- a/src/pages/login/__tests__/index.test.tsx
+++ b/src/pages/login/__tests__/index.test.tsx
@@ -11,16 +11,6 @@ vi.mock('@/services', () => ({
},
}))
-// Mock react-router-dom
-vi.mock('react-router-dom', async () => {
- const actual = await vi.importActual('react-router-dom')
- return {
- ...actual,
- useNavigate: () => vi.fn(),
- useLocation: () => ({ state: null }),
- }
-})
-
describe('LoginPage', () => {
it('should render login form', () => {
render()
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
index 27db5d1..e0f12d0 100644
--- a/src/pages/login/index.tsx
+++ b/src/pages/login/index.tsx
@@ -8,6 +8,7 @@ import { Eye, EyeOff } from "lucide-react"
import { toast } from "sonner"
import { authService } from "@/services"
import { errorTracker } from "@/lib/error-tracker"
+import type { ApiError, LocationState } from "@/types/error.types"
export default function LoginPage() {
const navigate = useNavigate()
@@ -17,7 +18,7 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
- const from = (location.state as any)?.from?.pathname || "/admin/dashboard"
+ const from = (location.state as LocationState)?.from?.pathname || "/admin/dashboard"
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
@@ -45,13 +46,14 @@ export default function LoginPage() {
// Navigate to dashboard
navigate(from, { replace: true })
- } catch (error: any) {
- console.error('Login error:', error)
- const message = error.response?.data?.message || "Invalid email or password"
+ } catch (error) {
+ const apiError = error as ApiError
+ console.error('Login error:', apiError)
+ const message = apiError.response?.data?.message || "Invalid email or password"
toast.error(message)
// Track login error
- errorTracker.trackError(error, {
+ errorTracker.trackError(apiError, {
extra: { email, action: 'login' }
})
diff --git a/src/services/__tests__/auth.service.test.ts b/src/services/__tests__/auth.service.test.ts
new file mode 100644
index 0000000..f240908
--- /dev/null
+++ b/src/services/__tests__/auth.service.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { authService } from '../auth.service'
+import apiClient from '../api/client'
+
+// Mock the API client
+vi.mock('../api/client', () => ({
+ default: {
+ post: vi.fn(),
+ },
+}))
+
+describe('AuthService', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ localStorage.clear()
+ })
+
+ describe('login', () => {
+ it('should login successfully and store tokens', async () => {
+ const mockResponse = {
+ data: {
+ accessToken: 'test-access-token',
+ refreshToken: 'test-refresh-token',
+ user: {
+ id: '1',
+ email: 'admin@example.com',
+ firstName: 'Admin',
+ lastName: 'User',
+ role: 'ADMIN',
+ },
+ },
+ }
+
+ vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
+
+ const result = await authService.login({
+ email: 'admin@example.com',
+ password: 'password123',
+ })
+
+ expect(apiClient.post).toHaveBeenCalledWith('/auth/login', {
+ email: 'admin@example.com',
+ password: 'password123',
+ })
+
+ expect(result).toEqual(mockResponse.data)
+ expect(localStorage.getItem('access_token')).toBe('test-access-token')
+ expect(localStorage.getItem('refresh_token')).toBe('test-refresh-token')
+ expect(localStorage.getItem('user')).toBe(JSON.stringify(mockResponse.data.user))
+ })
+
+ it('should handle login failure', async () => {
+ vi.mocked(apiClient.post).mockRejectedValue(new Error('Invalid credentials'))
+
+ await expect(
+ authService.login({
+ email: 'wrong@example.com',
+ password: 'wrongpassword',
+ })
+ ).rejects.toThrow('Invalid credentials')
+
+ expect(localStorage.getItem('access_token')).toBeNull()
+ })
+ })
+
+ describe('logout', () => {
+ it('should logout and clear tokens', async () => {
+ localStorage.setItem('access_token', 'test-token')
+ localStorage.setItem('refresh_token', 'test-refresh')
+ localStorage.setItem('user', JSON.stringify({ id: '1' }))
+
+ vi.mocked(apiClient.post).mockResolvedValue({ data: {} })
+
+ await authService.logout()
+
+ expect(apiClient.post).toHaveBeenCalledWith('/auth/logout')
+ expect(localStorage.getItem('access_token')).toBeNull()
+ expect(localStorage.getItem('refresh_token')).toBeNull()
+ expect(localStorage.getItem('user')).toBeNull()
+ })
+
+ it('should clear tokens even if API call fails', async () => {
+ localStorage.setItem('access_token', 'test-token')
+ vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error'))
+
+ // The logout method uses try/finally, so it will throw but still clear tokens
+ try {
+ await authService.logout()
+ } catch {
+ // Error is expected
+ }
+
+ // Tokens should still be cleared due to finally block
+ expect(localStorage.getItem('access_token')).toBeNull()
+ })
+ })
+
+ describe('refreshToken', () => {
+ it('should refresh access token', async () => {
+ localStorage.setItem('refresh_token', 'old-refresh-token')
+
+ const mockResponse = {
+ data: {
+ accessToken: 'new-access-token',
+ },
+ }
+
+ vi.mocked(apiClient.post).mockResolvedValue(mockResponse)
+
+ const result = await authService.refreshToken()
+
+ expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh', {
+ refreshToken: 'old-refresh-token',
+ })
+
+ expect(result).toEqual(mockResponse.data)
+ expect(localStorage.getItem('access_token')).toBe('new-access-token')
+ })
+ })
+
+ describe('getCurrentUser', () => {
+ it('should return current user from localStorage', () => {
+ const user = {
+ id: '1',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ role: 'ADMIN',
+ }
+
+ localStorage.setItem('user', JSON.stringify(user))
+
+ const result = authService.getCurrentUser()
+
+ expect(result).toEqual(user)
+ })
+
+ it('should return null if no user in localStorage', () => {
+ const result = authService.getCurrentUser()
+
+ expect(result).toBeNull()
+ })
+
+ it('should handle invalid JSON in localStorage', () => {
+ localStorage.setItem('user', 'invalid-json')
+
+ expect(() => authService.getCurrentUser()).toThrow()
+ })
+ })
+
+ describe('isAuthenticated', () => {
+ it('should return true if access token exists', () => {
+ localStorage.setItem('access_token', 'test-token')
+
+ expect(authService.isAuthenticated()).toBe(true)
+ })
+
+ it('should return false if no access token', () => {
+ expect(authService.isAuthenticated()).toBe(false)
+ })
+ })
+
+ describe('isAdmin', () => {
+ it('should return true if user is admin', () => {
+ const user = {
+ id: '1',
+ email: 'admin@example.com',
+ firstName: 'Admin',
+ lastName: 'User',
+ role: 'ADMIN',
+ }
+
+ localStorage.setItem('user', JSON.stringify(user))
+
+ expect(authService.isAdmin()).toBe(true)
+ })
+
+ it('should return false if user is not admin', () => {
+ const user = {
+ id: '1',
+ email: 'user@example.com',
+ firstName: 'Regular',
+ lastName: 'User',
+ role: 'USER',
+ }
+
+ localStorage.setItem('user', JSON.stringify(user))
+
+ expect(authService.isAdmin()).toBe(false)
+ })
+
+ it('should return false if no user in localStorage', () => {
+ expect(authService.isAdmin()).toBe(false)
+ })
+ })
+})
diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts
index 6d0f261..73b01de 100644
--- a/src/services/analytics.service.ts
+++ b/src/services/analytics.service.ts
@@ -1,4 +1,5 @@
import apiClient from './api/client'
+import type { ApiUsageData, ErrorRateSummary, StorageByUser, StorageAnalytics } from '@/types/analytics.types'
export interface OverviewStats {
users?: {
@@ -68,8 +69,8 @@ class AnalyticsService {
/**
* Get API usage statistics
*/
- async getApiUsage(days: number = 7): Promise {
- const response = await apiClient.get('/admin/analytics/api-usage', {
+ async getApiUsage(days: number = 7): Promise {
+ const response = await apiClient.get('/admin/analytics/api-usage', {
params: { days },
})
return response.data
@@ -78,8 +79,8 @@ class AnalyticsService {
/**
* Get error rate statistics
*/
- async getErrorRate(days: number = 7): Promise {
- const response = await apiClient.get('/admin/analytics/error-rate', {
+ async getErrorRate(days: number = 7): Promise {
+ const response = await apiClient.get('/admin/analytics/error-rate', {
params: { days },
})
return response.data
@@ -88,8 +89,8 @@ class AnalyticsService {
/**
* Get storage usage by user
*/
- async getStorageByUser(limit: number = 10): Promise {
- const response = await apiClient.get('/admin/analytics/storage/by-user', {
+ async getStorageByUser(limit: number = 10): Promise {
+ const response = await apiClient.get('/admin/analytics/storage/by-user', {
params: { limit },
})
return response.data
@@ -98,8 +99,8 @@ class AnalyticsService {
/**
* Get storage analytics
*/
- async getStorageAnalytics(): Promise {
- const response = await apiClient.get('/admin/analytics/storage')
+ async getStorageAnalytics(): Promise {
+ const response = await apiClient.get('/admin/analytics/storage')
return response.data
}
diff --git a/src/services/api/__tests__/client.test.ts b/src/services/api/__tests__/client.test.ts
new file mode 100644
index 0000000..2431ab0
--- /dev/null
+++ b/src/services/api/__tests__/client.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import apiClient from '../client'
+
+describe('API Client', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('should create axios instance with correct config', () => {
+ expect(apiClient.defaults.baseURL).toBeDefined()
+ expect(apiClient.defaults.headers['Content-Type']).toBe('application/json')
+ expect(apiClient.defaults.withCredentials).toBe(true)
+ expect(apiClient.defaults.timeout).toBe(30000)
+ })
+
+ it('should have request interceptor configured', () => {
+ expect(apiClient.interceptors.request.handlers?.length).toBeGreaterThan(0)
+ })
+
+ it('should have response interceptor configured', () => {
+ expect(apiClient.interceptors.response.handlers?.length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/services/api/client.ts b/src/services/api/client.ts
index 4de9c8f..5559da5 100644
--- a/src/services/api/client.ts
+++ b/src/services/api/client.ts
@@ -1,7 +1,11 @@
-import axios, { type AxiosInstance, type AxiosError } from 'axios'
+import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
+interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
+ _retry?: boolean
+}
+
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -43,7 +47,7 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
- const originalRequest = error.config as any
+ const originalRequest = error.config as RetryableAxiosRequestConfig
// Handle 401 Unauthorized - Try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
diff --git a/src/services/audit.service.ts b/src/services/audit.service.ts
index e08d122..1e359f8 100644
--- a/src/services/audit.service.ts
+++ b/src/services/audit.service.ts
@@ -6,7 +6,7 @@ export interface AuditLog {
action: string
resourceType: string
resourceId: string
- changes?: Record
+ changes?: Record
ipAddress: string
userAgent: string
timestamp: string
diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts
index cee7bae..023b631 100644
--- a/src/services/dashboard.service.ts
+++ b/src/services/dashboard.service.ts
@@ -1,4 +1,5 @@
import apiClient from './api/client'
+import type { ActivityLog } from '@/types/activity.types'
export interface UserDashboardStats {
totalInvoices: number
@@ -6,13 +7,7 @@ export interface UserDashboardStats {
totalRevenue: number
pendingInvoices: number
growthPercentage?: number
- recentActivity?: Array<{
- id: string
- type: string
- description: string
- date: string
- amount?: number
- }>
+ recentActivity?: ActivityLog[]
}
export interface UserProfile {
@@ -43,8 +38,8 @@ class DashboardService {
/**
* Get user recent activity
*/
- async getRecentActivity(limit: number = 10): Promise {
- const response = await apiClient.get('/user/activity', {
+ async getRecentActivity(limit: number = 10): Promise {
+ const response = await apiClient.get('/user/activity', {
params: { limit },
})
return response.data
diff --git a/src/services/index.ts b/src/services/index.ts
index 6aa04de..c6991e7 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -20,6 +20,4 @@ export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } fro
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
export type { UserDashboardStats, UserProfile } from './dashboard.service'
-export type { Notification } from './notification.service'
-export type { NotificationSettings } from './notification.service'
-export type { NotificationSettings } from './notification.service'
+export type { Notification, NotificationSettings } from './notification.service'
diff --git a/src/services/security.service.ts b/src/services/security.service.ts
index 27f72e3..e104dd6 100644
--- a/src/services/security.service.ts
+++ b/src/services/security.service.ts
@@ -1,4 +1,5 @@
import apiClient from './api/client'
+import type { SuspiciousIP, SuspiciousEmail, RateLimitViolation } from '@/types/security.types'
export interface SuspiciousActivity {
id: string
@@ -42,10 +43,13 @@ class SecurityService {
* Get suspicious activity logs
*/
async getSuspiciousActivity(): Promise<{
- suspiciousIPs?: any[]
- suspiciousEmails?: any[]
+ suspiciousIPs?: SuspiciousIP[]
+ suspiciousEmails?: SuspiciousEmail[]
}> {
- const response = await apiClient.get('/admin/security/suspicious')
+ const response = await apiClient.get<{
+ suspiciousIPs?: SuspiciousIP[]
+ suspiciousEmails?: SuspiciousEmail[]
+ }>('/admin/security/suspicious')
return response.data
}
@@ -72,15 +76,15 @@ class SecurityService {
limit?: number
email?: string
}): Promise<{ data: FailedLogin[]; total: number }> {
- const response = await apiClient.get('/admin/security/failed-logins', { params })
+ const response = await apiClient.get<{ data: FailedLogin[]; total: number }>('/admin/security/failed-logins', { params })
return response.data
}
/**
* Get rate limit violations
*/
- async getRateLimitViolations(days: number = 7): Promise {
- const response = await apiClient.get('/admin/security/rate-limits', {
+ async getRateLimitViolations(days: number = 7): Promise {
+ const response = await apiClient.get('/admin/security/rate-limits', {
params: { days },
})
return response.data
diff --git a/src/services/user.service.ts b/src/services/user.service.ts
index c3ed4e5..3a65a50 100644
--- a/src/services/user.service.ts
+++ b/src/services/user.service.ts
@@ -1,4 +1,5 @@
import apiClient from './api/client'
+import type { UserActivity } from '@/types/activity.types'
export interface User {
id: string
@@ -98,8 +99,8 @@ class UserService {
/**
* Get user activity logs
*/
- async getUserActivity(id: string, days: number = 30): Promise {
- const response = await apiClient.get(`/admin/users/${id}/activity`, {
+ async getUserActivity(id: string, days: number = 30): Promise {
+ const response = await apiClient.get(`/admin/users/${id}/activity`, {
params: { days },
})
return response.data
diff --git a/src/test/setup.ts b/src/test/setup.ts
index f48f826..b4eadac 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1,6 +1,30 @@
-import { expect, afterEach } from 'vitest'
+import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
+import React from 'react'
+
+// Mock react-router-dom before any imports
+vi.mock('react-router-dom', () => ({
+ BrowserRouter: ({ children }: { children: React.ReactNode }) => children,
+ MemoryRouter: ({ children }: { children: React.ReactNode }) => children,
+ Routes: ({ children }: { children: React.ReactNode }) => children,
+ Route: () => null,
+ Link: ({ children, to }: { children: React.ReactNode; to: string }) =>
+ React.createElement('a', { href: to }, children),
+ NavLink: ({ children, to }: { children: React.ReactNode; to: string }) =>
+ React.createElement('a', { href: to }, children),
+ Navigate: ({ to }: { to: string }) => `Navigate to ${to}`,
+ Outlet: () => null,
+ useNavigate: () => vi.fn(),
+ useLocation: () => ({ pathname: '/', search: '', hash: '', state: null, key: 'default' }),
+ useParams: () => ({}),
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
+ useMatch: () => null,
+ useResolvedPath: (to: string) => ({ pathname: to, search: '', hash: '' }),
+ useHref: (to: string) => to,
+ useOutlet: () => null,
+ useOutletContext: () => ({}),
+}))
// Extend Vitest's expect with jest-dom matchers
expect.extend(matchers)
@@ -26,7 +50,7 @@ Object.defineProperty(window, 'matchMedia', {
})
// Mock IntersectionObserver
-global.IntersectionObserver = class IntersectionObserver {
+globalThis.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
@@ -34,4 +58,5 @@ global.IntersectionObserver = class IntersectionObserver {
return []
}
unobserve() {}
+
} as any
diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx
index ca6f4b8..78ae2fb 100644
--- a/src/test/test-utils.tsx
+++ b/src/test/test-utils.tsx
@@ -1,7 +1,10 @@
-import { ReactElement } from 'react'
-import { render, RenderOptions } from '@testing-library/react'
-import { BrowserRouter } from 'react-router-dom'
+import type { ReactElement } from 'react'
+import { render, type RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import React from 'react'
+
+// Use the mocked MemoryRouter from our mock
+const MemoryRouter = ({ children }: { children: React.ReactNode }) => <>{children}>
// Create a custom render function that includes providers
const createTestQueryClient = () =>
@@ -22,7 +25,7 @@ const AllTheProviders = ({ children }: AllTheProvidersProps) => {
return (
- {children}
+ {children}
)
}
@@ -32,5 +35,7 @@ const customRender = (
options?: Omit
) => render(ui, { wrapper: AllTheProviders, ...options })
-export * from '@testing-library/react'
+// Export everything from @testing-library/react
+export { screen, waitFor, within, fireEvent } from '@testing-library/react'
+// Export our custom render
export { customRender as render }
diff --git a/src/test/vitest.d.ts b/src/test/vitest.d.ts
new file mode 100644
index 0000000..f6572a7
--- /dev/null
+++ b/src/test/vitest.d.ts
@@ -0,0 +1,9 @@
+import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
+import 'vitest'
+
+declare module 'vitest' {
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface Assertion extends TestingLibraryMatchers {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface AsymmetricMatchersContaining extends TestingLibraryMatchers, unknown> {}
+}
diff --git a/src/types/activity.types.ts b/src/types/activity.types.ts
new file mode 100644
index 0000000..91cd6c3
--- /dev/null
+++ b/src/types/activity.types.ts
@@ -0,0 +1,18 @@
+export interface ActivityLog {
+ id: string
+ type: string
+ description: string
+ date: string
+ amount?: number
+ userId?: string
+ metadata?: Record
+}
+
+export interface UserActivity extends ActivityLog {
+ userId: string
+ action: string
+ resourceType?: string
+ resourceId?: string
+ ipAddress?: string
+ userAgent?: string
+}
diff --git a/src/types/analytics.types.ts b/src/types/analytics.types.ts
new file mode 100644
index 0000000..fafe0bb
--- /dev/null
+++ b/src/types/analytics.types.ts
@@ -0,0 +1,46 @@
+import type { StorageCategory } from './common.types'
+
+export interface ApiUsageData {
+ date: string
+ requests: number
+ errors: number
+ avgResponseTime: number
+}
+
+export interface ErrorRateData {
+ date: string
+ errorRate: number
+ totalErrors: number
+ totalRequests: number
+}
+
+export interface ErrorRateSummary {
+ errorRate: number
+ errors: number
+ total: number
+}
+
+export interface StorageByUser {
+ userId: string
+ userName: string
+ email: string
+ storageUsed: number
+ documentCount: number
+}
+
+export interface StorageAnalytics {
+ totalStorage: number
+ usedStorage: number
+ availableStorage: number
+ byCategory?: StorageCategory[]
+ total?: {
+ size: number
+ files: number
+ }
+ storageByType: Array<{
+ type: string
+ size: number
+ count: number
+ }>
+ topUsers: StorageByUser[]
+}
diff --git a/src/types/common.types.ts b/src/types/common.types.ts
new file mode 100644
index 0000000..d0d6514
--- /dev/null
+++ b/src/types/common.types.ts
@@ -0,0 +1,63 @@
+// Common types used across the application
+
+export interface ApiError {
+ response?: {
+ data?: {
+ message?: string
+ }
+ }
+ message?: string
+}
+
+export interface QueryParams {
+ page?: number
+ limit?: number
+ search?: string
+ [key: string]: string | number | boolean | undefined
+}
+
+export interface Announcement {
+ id: string
+ title: string
+ content: string
+ type: 'info' | 'warning' | 'success' | 'error'
+ isActive: boolean
+ createdAt: string
+ updatedAt?: string
+}
+
+export interface Setting {
+ id: string
+ key: string
+ value: string
+ description?: string
+ isPublic: boolean
+ createdAt: string
+ updatedAt?: string
+}
+
+export interface ApiEndpointUsage {
+ endpoint: string
+ requests: number
+ avgResponseTime: number
+ errorRate: number
+}
+
+export interface StorageCategory {
+ category: string
+ size: number
+ count: number
+}
+
+export interface StorageData {
+ totalStorage: number
+ usedStorage: number
+ availableStorage: number
+ byCategory?: StorageCategory[]
+ topUsers: Array<{
+ userId: string
+ userName: string
+ email: string
+ storageUsed: number
+ }>
+}
diff --git a/src/types/error.types.ts b/src/types/error.types.ts
new file mode 100644
index 0000000..c4b1615
--- /dev/null
+++ b/src/types/error.types.ts
@@ -0,0 +1,15 @@
+import type { AxiosError } from 'axios'
+
+export interface ApiErrorResponse {
+ message: string
+ statusCode?: number
+ error?: string
+}
+
+export type ApiError = AxiosError
+
+export interface LocationState {
+ from?: {
+ pathname: string
+ }
+}
diff --git a/src/types/security.types.ts b/src/types/security.types.ts
new file mode 100644
index 0000000..7e11d0c
--- /dev/null
+++ b/src/types/security.types.ts
@@ -0,0 +1,66 @@
+export interface SuspiciousIP {
+ ipAddress: string
+ attempts: number
+ lastAttempt: string
+ severity: 'low' | 'medium' | 'high'
+}
+
+export interface SuspiciousEmail {
+ email: string
+ attempts: number
+ lastAttempt: string
+ severity: 'low' | 'medium' | 'high'
+}
+
+export interface RateLimitViolation {
+ id: string
+ ipAddress: string
+ endpoint: string
+ attempts: number
+ requests: number
+ period: string
+ timestamp: string
+ userId?: string
+}
+
+export interface Session {
+ id: string
+ userId: string
+ ipAddress: string
+ userAgent: string
+ createdAt: string
+ expiresAt: string
+}
+
+export interface RateViolation {
+ id: string
+ userId?: string
+ ipAddress: string
+ endpoint: string
+ attempts: number
+ requests: number
+ period: string
+ timestamp: string
+}
+
+export interface FailedLogin {
+ id: string
+ email: string
+ ipAddress: string
+ userAgent: string
+ reason?: string
+ attemptedAt: string
+ blocked: boolean
+}
+
+export interface ApiKey {
+ id: string
+ name: string
+ key: string
+ userId: string
+ createdAt: string
+ expiresAt?: string
+ lastUsedAt?: string
+ revoked?: boolean
+ isActive: boolean
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 819b042..337ac58 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,6 +9,12 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
+ pool: 'vmThreads',
+ testTimeout: 10000,
+ hookTimeout: 10000,
+ deps: {
+ inline: [/react-router/, /react-router-dom/],
+ },
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],