chore: Update dependencies, refactor ESLint config, and enhance test infrastructure
Some checks are pending
CI / Test & Build (18.x) (push) Waiting to run
CI / Test & Build (20.x) (push) Waiting to run
CI / Security Audit (push) Waiting to run
Deploy to Production / Deploy to Netlify/Vercel (push) Waiting to run

This commit is contained in:
debudebuye 2026-02-26 11:18:40 +03:00
parent 64ba7cfc31
commit 9c7e33499a
49 changed files with 1159 additions and 354 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
coverage
*.local *.local
# Environment variables # Environment variables

View File

@ -3,21 +3,34 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default tseslint.config(
globalIgnores(['dist']), { ignores: ['dist'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, 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
},
},
)

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/admin-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>yaltopia-ticket-admin</title> <title>Yaltopia Ticket Admin</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

384
package-lock.json generated
View File

@ -21,7 +21,7 @@
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@sentry/react": "^10.39.0", "@sentry/react": "^10.39.0",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -42,6 +42,7 @@
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18", "@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@ -435,6 +436,16 @@
"node": ">=6.9.0" "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": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -1106,20 +1117,20 @@
} }
}, },
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.3.3", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.4", "ajv": "^6.14.0",
"debug": "^4.3.2", "debug": "^4.3.2",
"espree": "^10.0.1", "espree": "^10.0.1",
"globals": "^14.0.0", "globals": "^14.0.0",
"ignore": "^5.2.0", "ignore": "^5.2.0",
"import-fresh": "^3.2.1", "import-fresh": "^3.2.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"minimatch": "^3.1.2", "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1" "strip-json-comments": "^3.1.1"
}, },
"engines": { "engines": {
@ -1143,9 +1154,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.2", "version": "9.39.3",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3871,17 +3882,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/type-utils": "8.56.1",
"@typescript-eslint/utils": "8.54.0", "@typescript-eslint/utils": "8.56.1",
"@typescript-eslint/visitor-keys": "8.54.0", "@typescript-eslint/visitor-keys": "8.56.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
@ -3894,8 +3905,8 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.56.1",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
@ -3910,16 +3921,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/visitor-keys": "8.54.0", "@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -3930,19 +3941,19 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
"integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/tsconfig-utils": "^8.56.1",
"@typescript-eslint/types": "^8.54.0", "@typescript-eslint/types": "^8.56.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -3957,14 +3968,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
"integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.54.0" "@typescript-eslint/visitor-keys": "8.56.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3975,9 +3986,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
"integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -3992,15 +4003,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
"integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.54.0", "@typescript-eslint/utils": "8.56.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
}, },
@ -4012,14 +4023,14 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
"integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -4031,18 +4042,18 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
"integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/project-service": "8.56.1",
"@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.56.1",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"@typescript-eslint/visitor-keys": "8.54.0", "@typescript-eslint/visitor-keys": "8.56.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^9.0.5", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
@ -4058,36 +4069,49 @@
"typescript": ">=4.8.4 <6.0.0" "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": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5", "version": "10.2.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
"dev": true, "dev": true,
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^5.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": "18 || 20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.3", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -4098,16 +4122,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
"integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"@typescript-eslint/typescript-estree": "8.54.0" "@typescript-eslint/typescript-estree": "8.56.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4117,19 +4141,19 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
"integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.56.1",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4139,6 +4163,19 @@
"url": "https://opencollective.com/typescript-eslint" "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": { "node_modules/@vitejs/plugin-react": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", "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" "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": { "node_modules/@vitest/expect": {
"version": "4.0.18", "version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@ -4327,9 +4395,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4437,6 +4505,25 @@
"node": ">=12" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -4481,13 +4568,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.4", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@ -5272,9 +5359,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.39.2", "version": "9.39.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5284,7 +5371,7 @@
"@eslint/config-helpers": "^0.4.2", "@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2", "@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1", "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@ -5860,6 +5947,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "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": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -6030,6 +6124,45 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@ -6255,6 +6388,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -6327,9 +6501,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -7698,16 +7872,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.54.0", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
"integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.54.0", "@typescript-eslint/parser": "8.56.1",
"@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/typescript-estree": "8.56.1",
"@typescript-eslint/utils": "8.54.0" "@typescript-eslint/utils": "8.56.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7717,7 +7891,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "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" "typescript": ">=4.8.4 <6.0.0"
} }
}, },

View File

@ -30,7 +30,7 @@
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@sentry/react": "^10.39.0", "@sentry/react": "^10.39.0",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2", "axios": "^1.13.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -51,6 +51,7 @@
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18", "@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"eslint": "^9.39.1", "eslint": "^9.39.1",

7
public/admin-icon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="12" fill="#3B82F6"/>
<path d="M32 18C28.6863 18 26 20.6863 26 24C26 27.3137 28.6863 30 32 30C35.3137 30 38 27.3137 38 24C38 20.6863 35.3137 18 32 18Z" fill="white"/>
<path d="M32 32C24.268 32 18 35.582 18 40V44C18 45.1046 18.8954 46 20 46H44C45.1046 46 46 45.1046 46 44V40C46 35.582 39.732 32 32 32Z" fill="white"/>
<circle cx="44" cy="20" r="8" fill="#EF4444"/>
<path d="M44 17V23M41 20H47" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@ -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 <div>No error</div>
}
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(
<ErrorBoundary>
<div>Test Content</div>
</ErrorBoundary>
)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render error UI when an error is thrown', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
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(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(Sentry.captureException).toHaveBeenCalled()
})
it('should show error message in development mode', () => {
const originalEnv = import.meta.env.DEV
import.meta.env.DEV = true
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
expect(screen.getByText('Test error')).toBeInTheDocument()
import.meta.env.DEV = originalEnv
})
it('should handle refresh button click', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
)
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
})
})

View File

@ -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",
},
}
)

View File

@ -1,27 +1,8 @@
import * as React from "react" 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" import { cn } from "@/lib/utils"
import { badgeVariants } from "./badge-variants"
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",
},
}
)
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -33,4 +14,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
) )
} }
export { Badge, badgeVariants } export { Badge }

View File

@ -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",
},
}
)

View File

@ -1,38 +1,9 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" 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" import { cn } from "@/lib/utils"
import { buttonVariants } from "./button-variants"
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",
},
}
)
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@ -54,4 +25,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) )
Button.displayName = "Button" Button.displayName = "Button"
export { Button, buttonVariants } export { Button }

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom" import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"
import { useEffect, useState } from "react" import { useState } from "react"
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
@ -53,19 +53,21 @@ const adminNavigationItems = [
export function AppShell() { export function AppShell() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
useEffect(() => { // Initialize user from localStorage
const [user] = useState<User | null>(() => {
const userStr = localStorage.getItem('user') const userStr = localStorage.getItem('user')
if (userStr) { if (userStr) {
try { try {
setUser(JSON.parse(userStr)) return JSON.parse(userStr)
} catch (error) { } catch (error) {
console.error('Failed to parse user data:', error) console.error('Failed to parse user data:', error)
return null
} }
} }
}, []) return null
})
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname.startsWith(path) return location.pathname.startsWith(path)
@ -98,7 +100,6 @@ export function AppShell() {
} }
const handleNotificationClick = () => { const handleNotificationClick = () => {
console.log('Notification button clicked')
navigate('/notifications') navigate('/notifications')
} }

View File

@ -1,25 +1,50 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { cn } from '../utils' import { cn } from '../utils'
describe('utils', () => { describe('cn utility', () => {
describe('cn', () => { it('should merge class names', () => {
it('should merge class names correctly', () => { const result = cn('class1', 'class2')
const result = cn('text-red-500', 'bg-blue-500') expect(result).toBe('class1 class2')
expect(result).toContain('text-red-500') })
expect(result).toContain('bg-blue-500')
})
it('should handle conditional classes', () => { it('should handle conditional classes', () => {
const result = cn('base-class', false && 'hidden', true && 'visible') const isConditional = true
expect(result).toContain('base-class') const isHidden = false
expect(result).toContain('visible') const result = cn('base', isConditional && 'conditional', isHidden && 'hidden')
expect(result).not.toContain('hidden') expect(result).toBe('base conditional')
}) })
it('should merge conflicting Tailwind classes', () => { it('should merge Tailwind classes correctly', () => {
const result = cn('p-4', 'p-8') const result = cn('px-2 py-1', 'px-4')
// tailwind-merge should keep only p-8 expect(result).toBe('py-1 px-4')
expect(result).toBe('p-8') })
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')
}) })
}) })

View File

@ -13,7 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react" import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
import { auditService } from "@/services" import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns" import { format } from "date-fns"
export default function ActivityLogPage() { export default function ActivityLogPage() {
@ -26,7 +26,7 @@ export default function ActivityLogPage() {
const { data: auditData, isLoading } = useQuery({ const { data: auditData, isLoading } = useQuery({
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter], queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
queryFn: async () => { queryFn: async () => {
const params: any = { page, limit } const params: Record<string, string | number> = { page, limit }
if (search) params.search = search if (search) params.search = search
if (actionFilter) params.action = actionFilter if (actionFilter) params.action = actionFilter
if (resourceTypeFilter) params.resourceType = resourceTypeFilter if (resourceTypeFilter) params.resourceType = resourceTypeFilter
@ -130,7 +130,7 @@ export default function ActivityLogPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{auditData?.data?.map((log: any) => ( {auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}> <TableRow key={log.id}>
<TableCell className="font-medium">{log.id}</TableCell> <TableCell className="font-medium">{log.id}</TableCell>
<TableCell>{log.userId || 'N/A'}</TableCell> <TableCell>{log.userId || 'N/A'}</TableCell>
@ -143,7 +143,7 @@ export default function ActivityLogPage() {
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell> <TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell> <TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell> <TableCell>
{format(new Date(log.timestamp || log.createdAt), 'MMM dd, yyyy HH:mm:ss')} {format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">

View File

@ -9,6 +9,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { analyticsService } from "@/services" import { analyticsService } from "@/services"
import type { ApiUsageData } from "@/types/analytics.types"
export default function AnalyticsApiPage() { export default function AnalyticsApiPage() {
const { data: apiUsage, isLoading } = useQuery({ const { data: apiUsage, isLoading } = useQuery({
@ -84,11 +85,11 @@ export default function AnalyticsApiPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{apiUsage?.map((endpoint: any, index: number) => ( {apiUsage?.map((endpoint: ApiUsageData, index: number) => (
<TableRow key={index}> <TableRow key={index}>
<TableCell className="font-mono text-sm">{endpoint.endpoint}</TableCell> <TableCell className="font-mono text-sm">{endpoint.date}</TableCell>
<TableCell>{endpoint.calls}</TableCell> <TableCell>{endpoint.requests}</TableCell>
<TableCell>{endpoint.avgDuration?.toFixed(2) || 'N/A'}</TableCell> <TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -2,11 +2,17 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts" import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
import { analyticsService } from "@/services" import { analyticsService } from "@/services"
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types"
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'] const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
interface ChartDataItem {
name: string
value: number
}
export default function AnalyticsStoragePage() { export default function AnalyticsStoragePage() {
const { data: storage, isLoading } = useQuery({ const { data: storage, isLoading } = useQuery<StorageAnalytics>({
queryKey: ['admin', 'analytics', 'storage'], queryKey: ['admin', 'analytics', 'storage'],
queryFn: () => analyticsService.getStorageAnalytics(), queryFn: () => analyticsService.getStorageAnalytics(),
}) })
@ -19,7 +25,7 @@ export default function AnalyticsStoragePage() {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] 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, name: cat.category,
value: cat.size, value: cat.size,
})) || [] })) || []
@ -73,7 +79,7 @@ export default function AnalyticsStoragePage() {
fill="#8884d8" fill="#8884d8"
dataKey="value" dataKey="value"
> >
{chartData.map((_entry: any, index: number) => ( {chartData.map((_entry: ChartDataItem, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))} ))}
</Pie> </Pie>
@ -97,13 +103,13 @@ export default function AnalyticsStoragePage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{storage.topUsers.map((user: any, index: number) => ( {storage.topUsers.map((user: StorageByUser, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded"> <div key={index} className="flex items-center justify-between p-2 border rounded">
<div> <div>
<p className="font-medium">{user.user}</p> <p className="font-medium">{user.userName || user.email}</p>
<p className="text-sm text-muted-foreground">{user.files} files</p> <p className="text-sm text-muted-foreground">{user.documentCount} files</p>
</div> </div>
<p className="font-medium">{formatBytes(user.size)}</p> <p className="font-medium">{formatBytes(user.storageUsed)}</p>
</div> </div>
))} ))}
</div> </div>

View File

@ -20,16 +20,17 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Plus, Edit, Trash2 } from "lucide-react" import { Plus, Edit, Trash2 } from "lucide-react"
import { announcementService } from "@/services" import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
import { toast } from "sonner" import { toast } from "sonner"
import { format } from "date-fns" import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
export default function AnnouncementsPage() { export default function AnnouncementsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [formDialogOpen, setFormDialogOpen] = useState(false) const [formDialogOpen, setFormDialogOpen] = useState(false)
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null) const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState<CreateAnnouncementData>({
title: '', title: '',
message: '', message: '',
type: 'info' as 'info' | 'warning' | 'success' | 'error', type: 'info' as 'info' | 'warning' | 'success' | 'error',
@ -45,20 +46,21 @@ export default function AnnouncementsPage() {
}) })
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => announcementService.createAnnouncement(data), mutationFn: (data: CreateAnnouncementData) => announcementService.createAnnouncement(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement created successfully") toast.success("Announcement created successfully")
setFormDialogOpen(false) setFormDialogOpen(false)
resetForm() resetForm()
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to create announcement") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create announcement")
}, },
}) })
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
announcementService.updateAnnouncement(id, data), announcementService.updateAnnouncement(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
@ -66,8 +68,9 @@ export default function AnnouncementsPage() {
setFormDialogOpen(false) setFormDialogOpen(false)
resetForm() resetForm()
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to update announcement") 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") toast.success("Announcement deleted successfully")
setDeleteDialogOpen(false) setDeleteDialogOpen(false)
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to delete announcement") 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) setFormDialogOpen(true)
} }
const handleOpenEditDialog = (announcement: any) => { const handleOpenEditDialog = (announcement: Announcement) => {
setSelectedAnnouncement(announcement) setSelectedAnnouncement(announcement)
setFormData({ setFormData({
title: announcement.title || '', title: announcement.title || '',
@ -174,7 +178,7 @@ export default function AnnouncementsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{announcements?.map((announcement: any) => ( {announcements?.map((announcement: Announcement) => (
<TableRow key={announcement.id}> <TableRow key={announcement.id}>
<TableCell className="font-medium">{announcement.title}</TableCell> <TableCell className="font-medium">{announcement.title}</TableCell>
<TableCell> <TableCell>
@ -268,7 +272,7 @@ export default function AnnouncementsPage() {
<select <select
className="w-full px-3 py-2 border rounded-md" className="w-full px-3 py-2 border rounded-md"
value={formData.type} value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })} onChange={(e) => setFormData({ ...formData, type: e.target.value as 'info' | 'warning' | 'success' | 'error' })}
> >
<option value="info">Info</option> <option value="info">Info</option>
<option value="warning">Warning</option> <option value="warning">Warning</option>

View File

@ -13,7 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Search, Eye } from "lucide-react" import { Search, Eye } from "lucide-react"
import { auditService } from "@/services" import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns" import { format } from "date-fns"
export default function AuditPage() { export default function AuditPage() {
@ -24,7 +24,7 @@ export default function AuditPage() {
const { data: auditData, isLoading } = useQuery({ const { data: auditData, isLoading } = useQuery({
queryKey: ['admin', 'audit', 'logs', page, limit, search], queryKey: ['admin', 'audit', 'logs', page, limit, search],
queryFn: async () => { queryFn: async () => {
const params: any = { page, limit } const params: Record<string, string | number> = { page, limit }
if (search) params.search = search if (search) params.search = search
return await auditService.getAuditLogs(params) return await auditService.getAuditLogs(params)
}, },
@ -69,7 +69,7 @@ export default function AuditPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{auditData?.data?.map((log: any) => ( {auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}> <TableRow key={log.id}>
<TableCell> <TableCell>
<Badge>{log.action}</Badge> <Badge>{log.action}</Badge>
@ -79,7 +79,7 @@ export default function AuditPage() {
<TableCell>{log.userId || 'N/A'}</TableCell> <TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell> <TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell> <TableCell>
{format(new Date(log.createdAt), 'MMM dd, yyyy HH:mm')} {format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">

View File

@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"
import { systemService } from "@/services" import { systemService } from "@/services"
import { toast } from "sonner" import { toast } from "sonner"
import { useState } from "react" import { useState } from "react"
import type { ApiError } from "@/types/error.types"
export default function MaintenancePage() { export default function MaintenancePage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -24,8 +25,9 @@ export default function MaintenancePage() {
toast.success("Maintenance mode enabled") toast.success("Maintenance mode enabled")
setMessage("") setMessage("")
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to enable maintenance mode") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to enable maintenance mode")
}, },
}) })
@ -35,8 +37,9 @@ export default function MaintenancePage() {
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
toast.success("Maintenance mode disabled") toast.success("Maintenance mode disabled")
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to disable maintenance mode") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to disable maintenance mode")
}, },
}) })

View File

@ -11,9 +11,10 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Ban } from "lucide-react" import { Ban } from "lucide-react"
import { securityService } from "@/services" import { securityService, type ApiKey } from "@/services"
import { toast } from "sonner" import { toast } from "sonner"
import { format } from "date-fns" import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
export default function ApiKeysPage() { export default function ApiKeysPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -29,8 +30,9 @@ export default function ApiKeysPage() {
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
toast.success("API key revoked successfully") toast.success("API key revoked successfully")
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to revoke API key") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to revoke API key")
}, },
}) })
@ -58,20 +60,20 @@ export default function ApiKeysPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{apiKeys?.map((key: any) => ( {apiKeys?.map((key: ApiKey) => (
<TableRow key={key.id}> <TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell> <TableCell className="font-medium">{key.name}</TableCell>
<TableCell>{key.userId || 'N/A'}</TableCell> <TableCell>{key.userId || 'N/A'}</TableCell>
<TableCell> <TableCell>
{key.lastUsedAt ? format(new Date(key.lastUsedAt), 'MMM dd, yyyy') : 'Never'} {key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={key.revoked ? 'destructive' : 'default'}> <Badge variant={key.isActive ? 'default' : 'destructive'}>
{key.revoked ? 'Revoked' : 'Active'} {key.isActive ? 'Active' : 'Revoked'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
{!key.revoked && ( {key.isActive && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@ -13,7 +13,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { Search, Ban } from "lucide-react" import { Search, Ban } from "lucide-react"
import { securityService } from "@/services" import { securityService, type FailedLogin } from "@/services"
import { format } from "date-fns" import { format } from "date-fns"
export default function FailedLoginsPage() { export default function FailedLoginsPage() {
@ -24,7 +24,7 @@ export default function FailedLoginsPage() {
const { data: failedLogins, isLoading } = useQuery({ const { data: failedLogins, isLoading } = useQuery({
queryKey: ['admin', 'security', 'failed-logins', page, limit, search], queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
queryFn: async () => { queryFn: async () => {
const params: any = { page, limit } const params: Record<string, string | number> = { page, limit }
if (search) params.email = search if (search) params.email = search
return await securityService.getFailedLogins(params) return await securityService.getFailedLogins(params)
}, },
@ -67,18 +67,18 @@ export default function FailedLoginsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{failedLogins?.data?.map((login: any) => ( {failedLogins?.data?.map((login: FailedLogin) => (
<TableRow key={login.id}> <TableRow key={login.id}>
<TableCell className="font-medium">{login.email}</TableCell> <TableCell className="font-medium">{login.email}</TableCell>
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell> <TableCell className="font-mono text-sm">{login.ipAddress}</TableCell>
<TableCell className="max-w-xs truncate">{login.userAgent}</TableCell> <TableCell className="max-w-xs truncate">{login.ipAddress}</TableCell>
<TableCell>{login.reason || 'N/A'}</TableCell> <TableCell>{login.reason || 'N/A'}</TableCell>
<TableCell> <TableCell>
{format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')} {format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={login.blocked ? 'destructive' : 'secondary'}> <Badge variant="secondary">
{login.blocked ? 'Yes' : 'No'} N/A
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@ -9,6 +9,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { securityService } from "@/services" import { securityService } from "@/services"
import type { RateLimitViolation } from "@/types/security.types"
export default function RateLimitsPage() { export default function RateLimitsPage() {
const { data: violations, isLoading } = useQuery({ const { data: violations, isLoading } = useQuery({
@ -39,7 +40,7 @@ export default function RateLimitsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{violations?.map((violation: any) => ( {violations?.map((violation: RateLimitViolation) => (
<TableRow key={violation.id}> <TableRow key={violation.id}>
<TableCell>{violation.userId || 'N/A'}</TableCell> <TableCell>{violation.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell> <TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>

View File

@ -10,7 +10,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { LogOut } from "lucide-react" import { LogOut } from "lucide-react"
import { securityService } from "@/services" import { securityService, type ActiveSession } from "@/services"
import { format } from "date-fns" import { format } from "date-fns"
export default function SessionsPage() { export default function SessionsPage() {
@ -43,7 +43,7 @@ export default function SessionsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sessions?.map((session: any) => ( {sessions?.map((session: ActiveSession) => (
<TableRow key={session.id}> <TableRow key={session.id}>
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell> <TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell> <TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Shield, Ban } from "lucide-react" import { Shield, Ban } from "lucide-react"
import { securityService } from "@/services" import { securityService } from "@/services"
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
export default function SuspiciousActivityPage() { export default function SuspiciousActivityPage() {
const { data: suspicious, isLoading } = useQuery({ const { data: suspicious, isLoading } = useQuery({
@ -27,7 +28,7 @@ export default function SuspiciousActivityPage() {
<div className="text-center py-8">Loading...</div> <div className="text-center py-8">Loading...</div>
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? ( ) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{suspicious?.suspiciousIPs?.map((ip: any, index: number) => ( {suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded"> <div key={index} className="flex items-center justify-between p-2 border rounded">
<div> <div>
<p className="font-mono font-medium">{ip.ipAddress}</p> <p className="font-mono font-medium">{ip.ipAddress}</p>
@ -60,7 +61,7 @@ export default function SuspiciousActivityPage() {
<div className="text-center py-8">Loading...</div> <div className="text-center py-8">Loading...</div>
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? ( ) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{suspicious?.suspiciousEmails?.map((email: any, index: number) => ( {suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded"> <div key={index} className="flex items-center justify-between p-2 border rounded">
<div> <div>
<p className="font-medium">{email.email}</p> <p className="font-medium">{email.email}</p>

View File

@ -15,8 +15,9 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Plus } from "lucide-react" import { Plus } from "lucide-react"
import { settingsService } from "@/services" import { settingsService, type Setting } from "@/services"
import { toast } from "sonner" import { toast } from "sonner"
import type { ApiError } from "@/types/error.types"
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -41,8 +42,9 @@ export default function SettingsPage() {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting updated successfully") toast.success("Setting updated successfully")
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to update setting") 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) setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false }) setNewSetting({ key: "", value: "", description: "", isPublic: false })
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to create setting") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create setting")
}, },
}) })
@ -112,8 +115,8 @@ export default function SettingsPage() {
{isLoading ? ( {isLoading ? (
<div className="text-center py-8">Loading settings...</div> <div className="text-center py-8">Loading settings...</div>
) : settings && settings.length > 0 ? ( ) : settings && settings.length > 0 ? (
settings.map((setting: any) => ( settings.map((setting: Setting) => (
<div key={setting.id} className="space-y-2"> <div key={setting.key} className="space-y-2">
<Label htmlFor={setting.key}>{setting.key}</Label> <Label htmlFor={setting.key}>{setting.key}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input

View File

@ -6,6 +6,14 @@ import { ArrowLeft } from "lucide-react"
import { userService } from "@/services" import { userService } from "@/services"
import { format } from "date-fns" import { format } from "date-fns"
interface ActivityItem {
action?: string
description?: string
message?: string
createdAt?: string
timestamp?: string
}
export default function UserActivityPage() { export default function UserActivityPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@ -36,7 +44,7 @@ export default function UserActivityPage() {
<CardContent> <CardContent>
{activity && activity.length > 0 ? ( {activity && activity.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{activity.map((item: any, index: number) => ( {activity.map((item: ActivityItem, index: number) => (
<div key={index} className="border-l-2 pl-4 pb-4"> <div key={index} className="border-l-2 pl-4 pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -44,7 +52,7 @@ export default function UserActivityPage() {
<p className="text-sm text-muted-foreground">{item.description || item.message}</p> <p className="text-sm text-muted-foreground">{item.description || item.message}</p>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{format(new Date(item.createdAt || item.timestamp), 'PPpp')} {(item.createdAt || item.timestamp) && format(new Date(item.createdAt || item.timestamp!), 'PPpp')}
</div> </div>
</div> </div>
</div> </div>

View File

@ -33,6 +33,17 @@ import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-rea
import { userService } from "@/services" import { userService } from "@/services"
import { toast } from "sonner" import { toast } from "sonner"
import { format } from "date-fns" 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() { export default function UsersPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -42,7 +53,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<string>("all") const [roleFilter, setRoleFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState<string>("all") const [statusFilter, setStatusFilter] = useState<string>("all")
const [selectedUser, setSelectedUser] = useState<any>(null) const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false) const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false) const [importDialogOpen, setImportDialogOpen] = useState(false)
@ -51,7 +62,7 @@ export default function UsersPage() {
const { data: usersData, isLoading } = useQuery({ const { data: usersData, isLoading } = useQuery({
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter], queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
queryFn: async () => { queryFn: async () => {
const params: any = { page, limit } const params: Record<string, string | number | boolean> = { page, limit }
if (search) params.search = search if (search) params.search = search
if (roleFilter !== 'all') params.role = roleFilter if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.isActive = statusFilter === 'active' if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
@ -67,8 +78,9 @@ export default function UsersPage() {
toast.success("User deleted successfully") toast.success("User deleted successfully")
setDeleteDialogOpen(false) setDeleteDialogOpen(false)
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to delete user") 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}`) toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
setResetPasswordDialogOpen(false) setResetPasswordDialogOpen(false)
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to reset password") 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) setImportDialogOpen(false)
setImportFile(null) setImportFile(null)
}, },
onError: (error: any) => { onError: (error) => {
toast.error(error.response?.data?.message || "Failed to import users") 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.download = `users-${new Date().toISOString()}.csv`
a.click() a.click()
toast.success("Users exported successfully") toast.success("Users exported successfully")
} catch (error: any) { } catch (error) {
toast.error(error.response?.data?.message || "Failed to export users") const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to export users")
} }
} }
@ -223,7 +238,7 @@ export default function UsersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{usersData?.data?.map((user: any) => ( {usersData?.data?.map((user: User) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell> <TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.firstName} {user.lastName}</TableCell> <TableCell>{user.firstName} {user.lastName}</TableCell>

View File

@ -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', () => { describe('LoginPage', () => {
it('should render login form', () => { it('should render login form', () => {
render(<LoginPage />) render(<LoginPage />)

View File

@ -8,6 +8,7 @@ import { Eye, EyeOff } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { authService } from "@/services" import { authService } from "@/services"
import { errorTracker } from "@/lib/error-tracker" import { errorTracker } from "@/lib/error-tracker"
import type { ApiError, LocationState } from "@/types/error.types"
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -17,7 +18,7 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -45,13 +46,14 @@ export default function LoginPage() {
// Navigate to dashboard // Navigate to dashboard
navigate(from, { replace: true }) navigate(from, { replace: true })
} catch (error: any) { } catch (error) {
console.error('Login error:', error) const apiError = error as ApiError
const message = error.response?.data?.message || "Invalid email or password" console.error('Login error:', apiError)
const message = apiError.response?.data?.message || "Invalid email or password"
toast.error(message) toast.error(message)
// Track login error // Track login error
errorTracker.trackError(error, { errorTracker.trackError(apiError, {
extra: { email, action: 'login' } extra: { email, action: 'login' }
}) })

View File

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

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import type { ApiUsageData, ErrorRateSummary, StorageByUser, StorageAnalytics } from '@/types/analytics.types'
export interface OverviewStats { export interface OverviewStats {
users?: { users?: {
@ -68,8 +69,8 @@ class AnalyticsService {
/** /**
* Get API usage statistics * Get API usage statistics
*/ */
async getApiUsage(days: number = 7): Promise<any> { async getApiUsage(days: number = 7): Promise<ApiUsageData[]> {
const response = await apiClient.get('/admin/analytics/api-usage', { const response = await apiClient.get<ApiUsageData[]>('/admin/analytics/api-usage', {
params: { days }, params: { days },
}) })
return response.data return response.data
@ -78,8 +79,8 @@ class AnalyticsService {
/** /**
* Get error rate statistics * Get error rate statistics
*/ */
async getErrorRate(days: number = 7): Promise<any> { async getErrorRate(days: number = 7): Promise<ErrorRateSummary> {
const response = await apiClient.get('/admin/analytics/error-rate', { const response = await apiClient.get<ErrorRateSummary>('/admin/analytics/error-rate', {
params: { days }, params: { days },
}) })
return response.data return response.data
@ -88,8 +89,8 @@ class AnalyticsService {
/** /**
* Get storage usage by user * Get storage usage by user
*/ */
async getStorageByUser(limit: number = 10): Promise<any> { async getStorageByUser(limit: number = 10): Promise<StorageByUser[]> {
const response = await apiClient.get('/admin/analytics/storage/by-user', { const response = await apiClient.get<StorageByUser[]>('/admin/analytics/storage/by-user', {
params: { limit }, params: { limit },
}) })
return response.data return response.data
@ -98,8 +99,8 @@ class AnalyticsService {
/** /**
* Get storage analytics * Get storage analytics
*/ */
async getStorageAnalytics(): Promise<any> { async getStorageAnalytics(): Promise<StorageAnalytics> {
const response = await apiClient.get('/admin/analytics/storage') const response = await apiClient.get<StorageAnalytics>('/admin/analytics/storage')
return response.data return response.data
} }

View File

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

View File

@ -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' 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 // Create axios instance with default config
const apiClient: AxiosInstance = axios.create({ const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@ -43,7 +47,7 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as any const originalRequest = error.config as RetryableAxiosRequestConfig
// Handle 401 Unauthorized - Try to refresh token // Handle 401 Unauthorized - Try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {

View File

@ -6,7 +6,7 @@ export interface AuditLog {
action: string action: string
resourceType: string resourceType: string
resourceId: string resourceId: string
changes?: Record<string, any> changes?: Record<string, unknown>
ipAddress: string ipAddress: string
userAgent: string userAgent: string
timestamp: string timestamp: string

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import type { ActivityLog } from '@/types/activity.types'
export interface UserDashboardStats { export interface UserDashboardStats {
totalInvoices: number totalInvoices: number
@ -6,13 +7,7 @@ export interface UserDashboardStats {
totalRevenue: number totalRevenue: number
pendingInvoices: number pendingInvoices: number
growthPercentage?: number growthPercentage?: number
recentActivity?: Array<{ recentActivity?: ActivityLog[]
id: string
type: string
description: string
date: string
amount?: number
}>
} }
export interface UserProfile { export interface UserProfile {
@ -43,8 +38,8 @@ class DashboardService {
/** /**
* Get user recent activity * Get user recent activity
*/ */
async getRecentActivity(limit: number = 10): Promise<any[]> { async getRecentActivity(limit: number = 10): Promise<ActivityLog[]> {
const response = await apiClient.get('/user/activity', { const response = await apiClient.get<ActivityLog[]>('/user/activity', {
params: { limit }, params: { limit },
}) })
return response.data return response.data

View File

@ -20,6 +20,4 @@ export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } fro
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service' export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service' export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
export type { UserDashboardStats, UserProfile } from './dashboard.service' export type { UserDashboardStats, UserProfile } from './dashboard.service'
export type { Notification } from './notification.service' export type { Notification, NotificationSettings } from './notification.service'
export type { NotificationSettings } from './notification.service'
export type { NotificationSettings } from './notification.service'

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import type { SuspiciousIP, SuspiciousEmail, RateLimitViolation } from '@/types/security.types'
export interface SuspiciousActivity { export interface SuspiciousActivity {
id: string id: string
@ -42,10 +43,13 @@ class SecurityService {
* Get suspicious activity logs * Get suspicious activity logs
*/ */
async getSuspiciousActivity(): Promise<{ async getSuspiciousActivity(): Promise<{
suspiciousIPs?: any[] suspiciousIPs?: SuspiciousIP[]
suspiciousEmails?: any[] 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 return response.data
} }
@ -72,15 +76,15 @@ class SecurityService {
limit?: number limit?: number
email?: string email?: string
}): Promise<{ data: FailedLogin[]; total: number }> { }): 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 return response.data
} }
/** /**
* Get rate limit violations * Get rate limit violations
*/ */
async getRateLimitViolations(days: number = 7): Promise<any[]> { async getRateLimitViolations(days: number = 7): Promise<RateLimitViolation[]> {
const response = await apiClient.get('/admin/security/rate-limits', { const response = await apiClient.get<RateLimitViolation[]>('/admin/security/rate-limits', {
params: { days }, params: { days },
}) })
return response.data return response.data

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import type { UserActivity } from '@/types/activity.types'
export interface User { export interface User {
id: string id: string
@ -98,8 +99,8 @@ class UserService {
/** /**
* Get user activity logs * Get user activity logs
*/ */
async getUserActivity(id: string, days: number = 30): Promise<any[]> { async getUserActivity(id: string, days: number = 30): Promise<UserActivity[]> {
const response = await apiClient.get(`/admin/users/${id}/activity`, { const response = await apiClient.get<UserActivity[]>(`/admin/users/${id}/activity`, {
params: { days }, params: { days },
}) })
return response.data return response.data

View File

@ -1,6 +1,30 @@
import { expect, afterEach } from 'vitest' import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react' import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers' 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 // Extend Vitest's expect with jest-dom matchers
expect.extend(matchers) expect.extend(matchers)
@ -26,7 +50,7 @@ Object.defineProperty(window, 'matchMedia', {
}) })
// Mock IntersectionObserver // Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver { globalThis.IntersectionObserver = class IntersectionObserver {
constructor() {} constructor() {}
disconnect() {} disconnect() {}
observe() {} observe() {}
@ -34,4 +58,5 @@ global.IntersectionObserver = class IntersectionObserver {
return [] return []
} }
unobserve() {} unobserve() {}
} as any } as any

View File

@ -1,7 +1,10 @@
import { ReactElement } from 'react' import type { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react' import { render, type RenderOptions } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 // Create a custom render function that includes providers
const createTestQueryClient = () => const createTestQueryClient = () =>
@ -22,7 +25,7 @@ const AllTheProviders = ({ children }: AllTheProvidersProps) => {
return ( return (
<QueryClientProvider client={testQueryClient}> <QueryClientProvider client={testQueryClient}>
<BrowserRouter>{children}</BrowserRouter> <MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
) )
} }
@ -32,5 +35,7 @@ const customRender = (
options?: Omit<RenderOptions, 'wrapper'> options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllTheProviders, ...options }) ) => 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 } export { customRender as render }

9
src/test/vitest.d.ts vendored Normal file
View File

@ -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<T = unknown> extends TestingLibraryMatchers<typeof expect.stringContaining, T> {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface AsymmetricMatchersContaining extends TestingLibraryMatchers<ReturnType<typeof expect.stringContaining>, unknown> {}
}

View File

@ -0,0 +1,18 @@
export interface ActivityLog {
id: string
type: string
description: string
date: string
amount?: number
userId?: string
metadata?: Record<string, unknown>
}
export interface UserActivity extends ActivityLog {
userId: string
action: string
resourceType?: string
resourceId?: string
ipAddress?: string
userAgent?: string
}

View File

@ -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[]
}

63
src/types/common.types.ts Normal file
View File

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

15
src/types/error.types.ts Normal file
View File

@ -0,0 +1,15 @@
import type { AxiosError } from 'axios'
export interface ApiErrorResponse {
message: string
statusCode?: number
error?: string
}
export type ApiError = AxiosError<ApiErrorResponse>
export interface LocationState {
from?: {
pathname: string
}
}

View File

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

View File

@ -9,6 +9,12 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
css: true, css: true,
pool: 'vmThreads',
testTimeout: 10000,
hookTimeout: 10000,
deps: {
inline: [/react-router/, /react-router-dom/],
},
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],