diff --git a/.gitignore b/.gitignore index 7be007d..c9dc84c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,8 @@ photos/ *.webp dlib/ *.dat -*.model \ No newline at end of file +*.model +# Node.js +node_modules/ +frontend/node_modules/ +frontend/.parcel-cache/ diff --git a/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG b/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG new file mode 100644 index 0000000..476a875 Binary files /dev/null and b/data/uploads/9dc697df-1ad3-4dd6-b734-841f07f7a1e6.JPG differ diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0ca6462 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,49 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true, + node: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json'], + }, + plugins: ['@typescript-eslint', 'react', 'react-hooks'], + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:@typescript-eslint/recommended', + ], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'max-len': [ + 'error', + { + code: 100, + tabWidth: 2, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + }, + ], + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, +} + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9846564..bc09d11 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.53.0", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", "postcss": "^8.4.31", @@ -1679,6 +1680,46 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1688,6 +1729,114 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1730,6 +1879,22 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", @@ -1821,6 +1986,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1833,6 +2017,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2005,6 +2206,60 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2028,6 +2283,42 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2103,6 +2394,75 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2119,6 +2479,34 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2144,6 +2532,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2259,6 +2678,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", @@ -2280,6 +2732,71 @@ "eslint": ">=8.40" } }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -2523,6 +3040,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2595,6 +3128,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2639,6 +3213,24 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2709,6 +3301,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -2746,6 +3355,19 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2755,6 +3377,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2842,6 +3493,75 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2854,6 +3574,36 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2869,6 +3619,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2878,6 +3663,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2887,6 +3688,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2899,6 +3720,32 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2908,6 +3755,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -2917,12 +3781,182 @@ "node": ">=8" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3006,6 +4040,22 @@ "node": ">=6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3243,6 +4293,104 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3269,6 +4417,24 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3417,6 +4583,16 @@ "node": ">= 6" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3582,6 +4758,18 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3639,6 +4827,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3699,6 +4894,50 @@ "node": ">=8.10.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3818,6 +5057,61 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3838,6 +5132,55 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3859,6 +5202,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3889,6 +5308,20 @@ "node": ">=0.10.0" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3954,6 +5387,104 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4190,6 +5721,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4203,6 +5812,25 @@ "node": ">=14.17" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -4322,6 +5950,95 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index c645196..b22c5f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,11 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@tanstack/react-query": "^5.8.4", + "axios": "^1.6.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.20.0", - "@tanstack/react-query": "^5.8.4", - "axios": "^1.6.2" + "react-router-dom": "^6.20.0" }, "devDependencies": { "@types/react": "^18.2.37", @@ -24,6 +24,7 @@ "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.16", "eslint": "^8.53.0", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", "postcss": "^8.4.31", @@ -32,4 +33,3 @@ "vite": "^5.0.0" } } - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a06bf35..3862a95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -80,7 +80,7 @@ function AppRoutes() { + } @@ -88,7 +88,7 @@ function AppRoutes() { + } @@ -96,7 +96,7 @@ function AppRoutes() { + } @@ -104,7 +104,7 @@ function AppRoutes() { + } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 6a21436..a4b2c14 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,4 +1,5 @@ import apiClient from './client' +import { UserRoleValue } from './users' export interface LoginRequest { username: string @@ -25,6 +26,8 @@ export interface PasswordChangeResponse { export interface UserResponse { username: string is_admin?: boolean + role?: UserRoleValue + permissions?: Record } export const authApi = { diff --git a/frontend/src/api/rolePermissions.ts b/frontend/src/api/rolePermissions.ts new file mode 100644 index 0000000..b707b85 --- /dev/null +++ b/frontend/src/api/rolePermissions.ts @@ -0,0 +1,36 @@ +import apiClient from './client' +import { UserRoleValue } from './users' + +export interface RoleFeature { + key: string + label: string +} + +export type RolePermissionsMap = Record> + +export interface RolePermissionsResponse { + features: RoleFeature[] + permissions: RolePermissionsMap +} + +export interface RolePermissionsUpdateRequest { + permissions: RolePermissionsMap +} + +export const rolePermissionsApi = { + async listPermissions(): Promise { + const { data } = await apiClient.get('/api/v1/role-permissions') + return data + }, + + async updatePermissions( + request: RolePermissionsUpdateRequest + ): Promise { + const { data } = await apiClient.put( + '/api/v1/role-permissions', + request + ) + return data + }, +} + diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index d0f8e3c..6cc1d5a 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -1,5 +1,14 @@ import apiClient from './client' +export type UserRoleValue = + | 'admin' + | 'manager' + | 'moderator' + | 'reviewer' + | 'editor' + | 'importer' + | 'viewer' + export interface UserResponse { id: number username: string @@ -7,6 +16,7 @@ export interface UserResponse { full_name: string | null is_active: boolean is_admin: boolean + role?: UserRoleValue | null created_date: string last_login: string | null } @@ -18,6 +28,7 @@ export interface UserCreateRequest { full_name: string is_active?: boolean is_admin?: boolean + role: UserRoleValue give_frontend_permission?: boolean } @@ -27,6 +38,7 @@ export interface UserUpdateRequest { full_name: string is_active?: boolean is_admin?: boolean + role?: UserRoleValue give_frontend_permission?: boolean } diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx index 5628fb3..474bffe 100644 --- a/frontend/src/components/AdminRoute.tsx +++ b/frontend/src/components/AdminRoute.tsx @@ -3,10 +3,11 @@ import { useAuth } from '../context/AuthContext' interface AdminRouteProps { children: React.ReactNode + featureKey?: string } -export default function AdminRoute({ children }: AdminRouteProps) { - const { isAuthenticated, isLoading, isAdmin } = useAuth() +export default function AdminRoute({ children, featureKey }: AdminRouteProps) { + const { isAuthenticated, isLoading, isAdmin, hasPermission } = useAuth() if (isLoading) { return
Loading...
@@ -16,7 +17,11 @@ export default function AdminRoute({ children }: AdminRouteProps) { return } - if (!isAdmin) { + if (featureKey) { + if (!hasPermission(featureKey)) { + return + } + } else if (!isAdmin) { return } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b1731d8..dfe5d04 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,9 +5,16 @@ import { useInactivityTimeout } from '../hooks/useInactivityTimeout' const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000 +type NavItem = { + path: string + label: string + icon: string + featureKey?: string +} + export default function Layout() { const location = useLocation() - const { username, logout, isAdmin, isAuthenticated } = useAuth() + const { username, logout, isAuthenticated, hasPermission } = useAuth() const [maintenanceExpanded, setMaintenanceExpanded] = useState(true) const handleInactivityLogout = useCallback(() => { @@ -20,31 +27,31 @@ export default function Layout() { isEnabled: isAuthenticated, }) - const primaryNavItems = [ - { path: '/scan', label: 'Scan', icon: '🗂️', adminOnly: false }, - { path: '/process', label: 'Process', icon: '⚙️', adminOnly: false }, - { path: '/search', label: 'Search Photos', icon: '🔍', adminOnly: false }, - { path: '/identify', label: 'Identify People', icon: '👤', adminOnly: false }, - { path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false }, - { path: '/modify', label: 'Modify People', icon: '✏️', adminOnly: false }, - { path: '/tags', label: 'Tag Photos', icon: '🏷️', adminOnly: false }, + const primaryNavItems: NavItem[] = [ + { path: '/scan', label: 'Scan', icon: '🗂️', featureKey: 'scan' }, + { path: '/process', label: 'Process', icon: '⚙️', featureKey: 'process' }, + { path: '/search', label: 'Search Photos', icon: '🔍', featureKey: 'search_photos' }, + { path: '/identify', label: 'Identify People', icon: '👤', featureKey: 'identify_people' }, + { path: '/auto-match', label: 'Auto-Match', icon: '🤖', featureKey: 'auto_match' }, + { path: '/modify', label: 'Modify People', icon: '✏️', featureKey: 'modify_people' }, + { path: '/tags', label: 'Tag Photos', icon: '🏷️', featureKey: 'tag_photos' }, ] - const maintenanceNavItems = [ - { path: '/faces-maintenance', label: 'Faces', icon: '🔧', adminOnly: false }, - { path: '/approve-identified', label: 'User Identified Faces', icon: '✅', adminOnly: true }, - { path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', adminOnly: true }, - { path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', adminOnly: true }, - { path: '/manage-users', label: 'Users', icon: '👥', adminOnly: true }, + const maintenanceNavItems: NavItem[] = [ + { path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' }, + { path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' }, + { path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' }, + { path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' }, + { path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' }, ] - const footerNavItems = [ - { path: '/settings', label: 'Settings', icon: '⚙️', adminOnly: false }, - { path: '/help', label: 'Help', icon: '📚', adminOnly: false }, + const footerNavItems: NavItem[] = [ + { path: '/settings', label: 'Settings', icon: '⚙️' }, + { path: '/help', label: 'Help', icon: '📚' }, ] - const filterNavItems = (items: typeof primaryNavItems) => - items.filter((item) => !item.adminOnly || isAdmin) + const filterNavItems = (items: NavItem[]) => + items.filter((item) => !item.featureKey || hasPermission(item.featureKey)) const renderNavLink = ( item: { path: string; label: string; icon: string }, @@ -100,7 +107,7 @@ export default function Layout() { @@ -671,21 +853,33 @@ export default function ManageUsers() { + {error && ( +
+ {error} +
+ )} + {/* Users Table */}
{loading ? ( @@ -802,7 +996,7 @@ export default function ManageUsers() { : 'bg-gray-100 text-gray-800' }`} > - {user.is_admin ? 'Admin' : 'User'} + {getDisplayRoleLabel(user)} @@ -971,6 +1165,93 @@ export default function ManageUsers() {
)} + {/* Manage Roles Tab */} + {activeTab === 'roles' && ( +
+ {rolePermissionsError && ( +
+ {rolePermissionsError} +
+ )} + {rolePermissionsLoading ? ( +
Loading role permissions...
+ ) : ( + <> +
+ + + + + {roleFeatures.map((feature) => ( + + ))} + + + + {ROLE_OPTIONS.map((role) => ( + + + {roleFeatures.map((feature) => { + const allowed = + rolePermissions[role.value]?.[feature.key] ?? false + return ( + + ) + })} + + ))} + +
+ Role + + {feature.label} +
+ {role.label} + + toggleRolePermission(role.value, feature.key)} + className="h-4 w-4 text-blue-600 border-gray-300 rounded" + /> +
+
+
+ + +
+ + )} +
+ )} + {/* Backend Create Modal */} {showCreateModal && (
@@ -1058,15 +1339,16 @@ export default function ManageUsers() { Role *
@@ -1094,8 +1376,10 @@ export default function ManageUsers() { full_name: '', is_active: true, is_admin: false, + role: DEFAULT_USER_ROLE, give_frontend_permission: false, }) + setCreateRole(DEFAULT_USER_ROLE) setShowCreateModal(false) }} className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200" @@ -1186,15 +1470,16 @@ export default function ManageUsers() { Role *
{canGrantFrontendPermission && ( @@ -1222,6 +1507,7 @@ export default function ManageUsers() { onClick={() => { setEditingUser(null) setGrantFrontendPermission(false) + setEditRole(DEFAULT_USER_ROLE) }} className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200" > diff --git a/frontend/src/pages/PendingPhotos.tsx b/frontend/src/pages/PendingPhotos.tsx index a9e3de1..320afce 100644 --- a/frontend/src/pages/PendingPhotos.tsx +++ b/frontend/src/pages/PendingPhotos.tsx @@ -9,7 +9,9 @@ import { apiClient } from '../api/client' import { useAuth } from '../context/AuthContext' export default function PendingPhotos() { - const { isAdmin } = useAuth() + const { hasPermission, isAdmin } = useAuth() + const canManageUploads = hasPermission('user_uploaded') + const canRunCleanup = isAdmin const [pendingPhotos, setPendingPhotos] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -444,19 +446,39 @@ export default function PendingPhotos() { )} - {isAdmin && ( + {canManageUploads && ( <> diff --git a/frontend/src/pages/ReportedPhotos.tsx b/frontend/src/pages/ReportedPhotos.tsx index 3b33bf4..4f1fadd 100644 --- a/frontend/src/pages/ReportedPhotos.tsx +++ b/frontend/src/pages/ReportedPhotos.tsx @@ -5,8 +5,10 @@ import { ReviewDecision, } from '../api/reportedPhotos' import { apiClient } from '../api/client' +import { useAuth } from '../context/AuthContext' export default function ReportedPhotos() { + const { isAdmin } = useAuth() const [reportedPhotos, setReportedPhotos] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -263,9 +265,19 @@ export default function ReportedPhotos() {
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 0f98bfb..9525a44 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -3,6 +3,7 @@ import { photosApi, PhotoSearchResult } from '../api/photos' import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags' import { apiClient } from '../api/client' import PhotoViewer from '../components/PhotoViewer' +import { useAuth } from '../context/AuthContext' type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites' @@ -21,10 +22,11 @@ type SortColumn = 'person' | 'tags' | 'processed' | 'path' | 'date_taken' type SortDir = 'asc' | 'desc' export default function Search() { + const { hasPermission } = useAuth() + const canTagPhotos = hasPermission('tag_photos') + const canDeletePhotos = hasPermission('faces_maintenance') const [searchType, setSearchType] = useState('name') - const [filtersExpanded, setFiltersExpanded] = useState(false) const [tagsExpanded, setTagsExpanded] = useState(true) // Default to expanded - const [folderPath, setFolderPath] = useState('') // Search inputs const [personName, setPersonName] = useState('') @@ -80,12 +82,11 @@ export default function Search() { loadTags() }, []) - const performSearch = async (pageNum: number = page, folderPathOverride?: string) => { + const performSearch = async (pageNum: number = page) => { setLoading(true) try { const params: any = { search_type: searchType, - folder_path: folderPathOverride || folderPath || undefined, page: pageNum, page_size: pageSize, } @@ -516,7 +517,6 @@ export default function Search() { // Build search params (same as current search) const baseParams: any = { search_type: searchType, - folder_path: folderPath || undefined, page_size: maxPageSize, } @@ -592,49 +592,7 @@ export default function Search() {
- {/* Filters */} -
-
-

Filters

- -
- {filtersExpanded && ( -
-
- -
- setFolderPath(e.target.value)} - placeholder="(optional - filter by folder path)" - className="flex-1 border rounded px-3 py-2" - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - /> - -
-
-
- )} -
+ {/* Filters removed: folder location filter is no longer supported */} {/* Search Inputs */} {(searchType !== 'no_faces' && searchType !== 'no_tags' && searchType !== 'processed' && searchType !== 'unprocessed') && ( @@ -777,17 +735,38 @@ export default function Search() { {loadingFavorites ? '...' : '⭐'} diff --git a/src/web/api/auth.py b/src/web/api/auth.py index ce8b443..93c6d6e 100644 --- a/src/web/api/auth.py +++ b/src/web/api/auth.py @@ -10,6 +10,11 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt from sqlalchemy.orm import Session +from src.web.constants.roles import ( + DEFAULT_ADMIN_ROLE, + DEFAULT_USER_ROLE, + ROLE_VALUES, +) from src.web.db.session import get_db from src.web.db.models import User from src.web.utils.password import verify_password, hash_password @@ -21,6 +26,7 @@ from src.web.schemas.auth import ( PasswordChangeRequest, PasswordChangeResponse, ) +from src.web.services.role_permissions import fetch_role_permissions_map router = APIRouter(prefix="/auth", tags=["auth"]) security = HTTPBearer() @@ -110,6 +116,7 @@ def get_current_user_with_id( full_name=username, is_active=True, is_admin=False, + role=DEFAULT_USER_ROLE, ) db.add(user) db.commit() @@ -118,6 +125,13 @@ def get_current_user_with_id( return {"username": username, "user_id": user.id} +def _resolve_user_role(user: User | None, is_admin_flag: bool) -> str: + """Determine the role value for a user, ensuring it is valid.""" + if user and user.role in ROLE_VALUES: + return user.role + return DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE + + @router.post("/login", response_model=TokenResponse) def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse: """Authenticate user and return tokens. @@ -262,6 +276,7 @@ def get_current_user_info( full_name=username, is_active=True, is_admin=True, + role=DEFAULT_ADMIN_ROLE, ) db.add(user) db.commit() @@ -275,6 +290,7 @@ def get_current_user_info( # Update existing user to be admin if no admins exist if not user.is_admin: user.is_admin = True + user.role = DEFAULT_ADMIN_ROLE db.commit() db.refresh(user) is_admin = user.is_admin @@ -285,7 +301,16 @@ def get_current_user_info( else: is_admin = user.is_admin if user else False - return UserResponse(username=username, is_admin=is_admin) + role_value = _resolve_user_role(user, is_admin) + permissions_map = fetch_role_permissions_map(db) + permissions = permissions_map.get(role_value, {}) + + return UserResponse( + username=username, + is_admin=is_admin, + role=role_value, + permissions=permissions, + ) @router.post("/change-password", response_model=PasswordChangeResponse) diff --git a/src/web/api/pending_identifications.py b/src/web/api/pending_identifications.py index 4b8712a..5c34dd9 100644 --- a/src/web/api/pending_identifications.py +++ b/src/web/api/pending_identifications.py @@ -10,9 +10,10 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy import text, func from sqlalchemy.orm import Session +from src.web.constants.roles import DEFAULT_USER_ROLE from src.web.db.session import get_auth_db, get_db from src.web.db.models import Face, Person, PersonEncoding, User -from src.web.api.users import get_current_admin_user +from src.web.api.users import get_current_admin_user, require_feature_permission from src.web.utils.password import hash_password router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"]) @@ -43,6 +44,7 @@ def get_or_create_frontend_user(db: Session) -> User: full_name="Frontend System User", is_active=False, # Not an active user, just a system marker is_admin=False, + role=DEFAULT_USER_ROLE, password_change_required=False, ) db.add(user) @@ -144,7 +146,9 @@ class ClearDatabaseResponse(BaseModel): @router.get("", response_model=PendingIdentificationsListResponse) def list_pending_identifications( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_identified")) + ], include_denied: bool = False, db: Session = Depends(get_auth_db), main_db: Session = Depends(get_db), @@ -240,7 +244,9 @@ def list_pending_identifications( @router.post("/approve-deny", response_model=ApproveDenyResponse) def approve_deny_pending_identifications( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_identified")) + ], request: ApproveDenyRequest, auth_db: Session = Depends(get_auth_db), main_db: Session = Depends(get_db), @@ -401,7 +407,9 @@ def approve_deny_pending_identifications( @router.get("/report", response_model=IdentificationReportResponse) def get_identification_report( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_identified")) + ], date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"), date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"), main_db: Session = Depends(get_db), @@ -484,7 +492,7 @@ def get_identification_report( @router.post("/clear-denied", response_model=ClearDatabaseResponse) def clear_denied_identifications( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_admin: dict = Depends(get_current_admin_user), auth_db: Session = Depends(get_auth_db), ) -> ClearDatabaseResponse: """Delete all denied pending identifications from the database. diff --git a/src/web/api/pending_photos.py b/src/web/api/pending_photos.py index bbb56c8..f4569f7 100644 --- a/src/web/api/pending_photos.py +++ b/src/web/api/pending_photos.py @@ -14,7 +14,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from src.web.db.session import get_auth_db, get_db -from src.web.api.users import get_current_admin_user +from src.web.api.users import get_current_admin_user, require_feature_permission from src.web.api.auth import get_current_user from src.web.services.photo_service import import_photo_from_path, calculate_file_hash from src.web.settings import PHOTO_STORAGE_DIR @@ -83,7 +83,9 @@ class ReviewResponse(BaseModel): @router.get("", response_model=PendingPhotosListResponse) def list_pending_photos( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_uploaded")) + ], status_filter: Optional[str] = None, auth_db: Session = Depends(get_auth_db), ) -> PendingPhotosListResponse: @@ -244,7 +246,9 @@ def get_pending_photo_image( @router.post("/review", response_model=ReviewResponse) def review_pending_photos( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_uploaded")) + ], request: ReviewRequest, auth_db: Session = Depends(get_auth_db), main_db: Session = Depends(get_db), @@ -267,7 +271,7 @@ def review_pending_photos( rejected_count = 0 duplicate_count = 0 errors = [] - admin_user_id = current_admin.get("user_id") + admin_user_id = current_user.get("user_id") now = datetime.utcnow() # Base directories @@ -453,7 +457,7 @@ class CleanupResponse(BaseModel): @router.post("/cleanup-files", response_model=CleanupResponse) def cleanup_shared_files( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_admin: dict = Depends(get_current_admin_user), status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"), auth_db: Session = Depends(get_auth_db), ) -> CleanupResponse: @@ -526,7 +530,7 @@ def cleanup_shared_files( @router.post("/cleanup-database", response_model=CleanupResponse) def cleanup_pending_photos_database( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_admin: dict = Depends(get_current_admin_user), status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for all"), auth_db: Session = Depends(get_auth_db), ) -> CleanupResponse: diff --git a/src/web/api/reported_photos.py b/src/web/api/reported_photos.py index a1b9233..8fd31a7 100644 --- a/src/web/api/reported_photos.py +++ b/src/web/api/reported_photos.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from src.web.db.session import get_auth_db, get_db from src.web.db.models import Photo, PhotoTagLinkage -from src.web.api.users import get_current_admin_user +from src.web.api.users import get_current_admin_user, require_feature_permission router = APIRouter(prefix="/reported-photos", tags=["reported-photos"]) @@ -87,7 +87,9 @@ class CleanupResponse(BaseModel): @router.get("", response_model=ReportedPhotosListResponse) def list_reported_photos( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_reported")) + ], status_filter: Optional[str] = None, auth_db: Session = Depends(get_auth_db), main_db: Session = Depends(get_db), @@ -176,7 +178,9 @@ def list_reported_photos( @router.post("/review", response_model=ReviewResponse) def review_reported_photos( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_user: Annotated[ + dict, Depends(require_feature_permission("user_reported")) + ], request: ReviewRequest, auth_db: Session = Depends(get_auth_db), main_db: Session = Depends(get_db), @@ -194,7 +198,7 @@ def review_reported_photos( kept_count = 0 removed_count = 0 errors = [] - admin_user_id = current_admin.get("user_id") + admin_user_id = current_user.get("user_id") now = datetime.utcnow() for decision in request.decisions: @@ -299,7 +303,7 @@ def review_reported_photos( @router.post("/cleanup", response_model=CleanupResponse) def cleanup_reported_photos( - current_admin: Annotated[dict, Depends(get_current_admin_user)], + current_admin: dict = Depends(get_current_admin_user), status_filter: Annotated[ Optional[str], Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.") diff --git a/src/web/api/role_permissions.py b/src/web/api/role_permissions.py new file mode 100644 index 0000000..61b21d9 --- /dev/null +++ b/src/web/api/role_permissions.py @@ -0,0 +1,68 @@ +"""Manage role-to-feature permissions.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from src.web.api.users import get_current_admin_user +from src.web.constants.role_features import ROLE_FEATURES, ROLE_FEATURE_KEYS +from src.web.constants.roles import ROLE_VALUES +from src.web.db.session import get_db +from src.web.schemas.role_permissions import ( + RoleFeatureSchema, + RolePermissionsResponse, + RolePermissionsUpdateRequest, +) +from src.web.services.role_permissions import ( + ensure_role_permissions_initialized, + fetch_role_permissions_map, + set_role_permissions, +) + +router = APIRouter(prefix="/role-permissions", tags=["role-permissions"]) + + +@router.get("", response_model=RolePermissionsResponse) +def list_role_permissions( + current_admin: Annotated[dict, Depends(get_current_admin_user)], + db: Session = Depends(get_db), +) -> RolePermissionsResponse: + """Return the current role/feature permission matrix.""" + + ensure_role_permissions_initialized(db) + permissions = fetch_role_permissions_map(db) + features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES] + return RolePermissionsResponse(features=features, permissions=permissions) + + +@router.put("", response_model=RolePermissionsResponse) +def update_role_permissions( + current_admin: Annotated[dict, Depends(get_current_admin_user)], + request: RolePermissionsUpdateRequest, + db: Session = Depends(get_db), +) -> RolePermissionsResponse: + """Update permissions for the provided matrix.""" + + invalid_roles = set(request.permissions.keys()) - set(ROLE_VALUES) + if invalid_roles: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role(s): {', '.join(sorted(invalid_roles))}", + ) + + for feature_map in request.permissions.values(): + invalid_features = set(feature_map.keys()) - set(ROLE_FEATURE_KEYS) + if invalid_features: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid feature(s): {', '.join(sorted(invalid_features))}", + ) + + set_role_permissions(db, request.permissions) + permissions = fetch_role_permissions_map(db) + features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES] + return RolePermissionsResponse(features=features, permissions=permissions) + diff --git a/src/web/api/users.py b/src/web/api/users.py index 8b1c30c..538cd06 100644 --- a/src/web/api/users.py +++ b/src/web/api/users.py @@ -2,13 +2,22 @@ from __future__ import annotations +import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from src.web.api.auth import get_current_user +from src.web.constants.roles import ( + DEFAULT_ADMIN_ROLE, + DEFAULT_USER_ROLE, + ROLE_VALUES, + UserRole, + is_admin_role, +) from src.web.db.session import get_auth_db, get_db from src.web.db.models import User from src.web.schemas.users import ( @@ -18,8 +27,38 @@ from src.web.schemas.users import ( UsersListResponse, ) from src.web.utils.password import hash_password +from src.web.services.role_permissions import fetch_role_permissions_map router = APIRouter(prefix="/users", tags=["users"]) +logger = logging.getLogger(__name__) + + +def _normalize_role_and_admin( + role: str | None, + is_admin_flag: bool | None, +) -> tuple[str, bool]: + """Normalize requested role/is_admin values into a consistent pair.""" + selected_role = role or (DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE) + if selected_role not in ROLE_VALUES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid role '{selected_role}'", + ) + derived_is_admin = is_admin_role(selected_role) + if is_admin_flag is not None and is_admin_flag != derived_is_admin: + logger.warning( + "Role/is_admin mismatch detected. Using role-derived admin flag.", + extra={"role": selected_role, "is_admin_flag": is_admin_flag}, + ) + return selected_role, derived_is_admin + + +def _ensure_role_set(user: User) -> None: + """Guarantee that a User instance has a valid role value.""" + if user.role in ROLE_VALUES: + return + fallback_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE + user.role = fallback_role def get_auth_db_optional() -> Session | None: @@ -137,6 +176,7 @@ def get_current_admin_user( password_hash=default_password_hash, is_active=True, is_admin=True, + role=DEFAULT_ADMIN_ROLE, ) db.add(main_user) db.commit() @@ -144,6 +184,7 @@ def get_current_admin_user( elif not main_user.is_admin: # User exists but is not admin - make them admin for bootstrap main_user.is_admin = True + main_user.role = DEFAULT_ADMIN_ROLE db.add(main_user) db.commit() db.refresh(main_user) @@ -162,6 +203,53 @@ def get_current_admin_user( return {"username": username, "user_id": main_user.id} +def require_feature_permission(feature_key: str): + """Return a dependency that enforces feature-level access via role permissions.""" + + def dependency( + current_user: Annotated[dict, Depends(get_current_user)], + db: Session = Depends(get_db), + ) -> dict: + username = current_user["username"] + + user = db.query(User).filter(User.username == username).first() + if not user: + default_password_hash = hash_password("changeme") + user = User( + username=username, + password_hash=default_password_hash, + is_active=True, + is_admin=False, + role=DEFAULT_USER_ROLE, + ) + db.add(user) + db.commit() + db.refresh(user) + + _ensure_role_set(user) + + has_access = user.is_admin or is_admin_role(user.role) + if not has_access: + permissions_map = fetch_role_permissions_map(db) + role_permissions = permissions_map.get(user.role, {}) + has_access = bool(role_permissions.get(feature_key)) + + if not has_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied for this feature", + ) + + return { + "username": username, + "user_id": user.id, + "role": user.role, + "is_admin": user.is_admin, + } + + return dependency + + @router.get("", response_model=UsersListResponse) def list_users( current_admin: Annotated[dict, Depends(get_current_admin_user)], @@ -182,6 +270,8 @@ def list_users( query = query.filter(User.is_admin == is_admin) users = query.order_by(User.username.asc()).all() + for user in users: + _ensure_role_set(user) items = [UserResponse.model_validate(u) for u in users] return UsersListResponse(items=items, total=len(items)) @@ -215,6 +305,16 @@ def create_user( # Hash the password before storing password_hash = hash_password(request.password) + if request.role is None: + requested_role = None + elif isinstance(request.role, UserRole): + requested_role = request.role.value + else: + requested_role = str(request.role) + normalized_role, normalized_is_admin = _normalize_role_and_admin( + requested_role, + request.is_admin, + ) user = User( username=request.username, @@ -222,7 +322,8 @@ def create_user( email=request.email, full_name=request.full_name, is_active=request.is_active, - is_admin=request.is_admin, + is_admin=normalized_is_admin, + role=normalized_role, password_change_required=True, # Force password change on first login ) db.add(user) @@ -234,7 +335,7 @@ def create_user( email=request.email, full_name=request.full_name, password_hash=password_hash, - is_admin=request.is_admin, + is_admin=normalized_is_admin, ) return UserResponse.model_validate(user) @@ -253,6 +354,7 @@ def get_user( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {user_id} not found", ) + _ensure_role_set(user) return UserResponse.model_validate(user) @@ -271,12 +373,26 @@ def update_user( detail=f"User with ID {user_id} not found", ) + if request.role is None: + desired_role = None + elif isinstance(request.role, UserRole): + desired_role = request.role.value + else: + desired_role = str(request.role) + if desired_role is None: + if request.is_admin is not None: + desired_role = DEFAULT_ADMIN_ROLE if request.is_admin else DEFAULT_USER_ROLE + elif user.role: + desired_role = user.role + else: + desired_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE + normalized_role, normalized_is_admin = _normalize_role_and_admin( + desired_role, + request.is_admin, + ) + # Prevent admin from removing their own admin status - if ( - current_admin["username"] == user.username - and request.is_admin is not None - and not request.is_admin - ): + if current_admin["username"] == user.username and not normalized_is_admin: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove your own admin status", @@ -300,8 +416,8 @@ def update_user( user.full_name = request.full_name if request.is_active is not None: user.is_active = request.is_active - if request.is_admin is not None: - user.is_admin = request.is_admin + user.is_admin = normalized_is_admin + user.role = normalized_role db.add(user) db.commit() @@ -342,8 +458,21 @@ def delete_user( detail="Cannot delete your own account", ) - db.delete(user) - db.commit() + try: + db.delete(user) + db.commit() + except IntegrityError as exc: + db.rollback() + constraint_name = "faces_identified_by_user_id_fkey" + if exc.orig and constraint_name in str(exc.orig): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "This user has identified faces and cannot be deleted. " + "Set the user inactive instead." + ), + ) from exc + raise return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/web/app.py b/src/web/app.py index 99800a2..e4d2f1a 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -23,12 +23,15 @@ from src.web.api.pending_photos import router as pending_photos_router from src.web.api.tags import router as tags_router from src.web.api.users import router as users_router from src.web.api.auth_users import router as auth_users_router +from src.web.api.role_permissions import router as role_permissions_router from src.web.api.version import router as version_router from src.web.settings import APP_TITLE, APP_VERSION +from src.web.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES from src.web.db.base import Base, engine from src.web.db.session import database_url # Import models to ensure they're registered with Base.metadata from src.web.db import models # noqa: F401 +from src.web.db.models import RolePermission from src.web.utils.password import hash_password # Global worker process (will be set in lifespan) @@ -262,6 +265,77 @@ def ensure_face_identified_by_user_id_column(inspector) -> None: print("✅ Added identified_by_user_id column to faces table") +def ensure_user_role_column(inspector) -> None: + """Ensure users table has a role column with valid values.""" + if "users" not in inspector.get_table_names(): + return + + columns = {column["name"] for column in inspector.get_columns("users")} + dialect = engine.dialect.name + role_values = sorted(ROLE_VALUES) + placeholder_parts = ", ".join( + f":role_value_{index}" for index, _ in enumerate(role_values) + ) + where_clause = ( + "role IS NULL OR role = ''" + if not placeholder_parts + else f"role IS NULL OR role = '' OR role NOT IN ({placeholder_parts})" + ) + params = { + f"role_value_{index}": value for index, value in enumerate(role_values) + } + params["admin_role"] = DEFAULT_ADMIN_ROLE + params["default_role"] = DEFAULT_USER_ROLE + + with engine.connect() as connection: + with connection.begin(): + if "role" not in columns: + if dialect == "postgresql": + connection.execute( + text( + f"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT " + f"NOT NULL DEFAULT '{DEFAULT_USER_ROLE}'" + ) + ) + else: + connection.execute( + text( + f"ALTER TABLE users ADD COLUMN role TEXT " + f"DEFAULT '{DEFAULT_USER_ROLE}'" + ) + ) + connection.execute( + text( + f""" + UPDATE users + SET role = CASE + WHEN is_admin THEN :admin_role + ELSE :default_role + END + WHERE {where_clause} + """ + ), + params, + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)") + ) + print("✅ Ensured users.role column exists and is populated") + + +def ensure_role_permissions_table(inspector) -> None: + """Ensure the role_permissions table exists for permission matrix.""" + if "role_permissions" in inspector.get_table_names(): + return + + try: + print("🔄 Creating role_permissions table...") + RolePermission.__table__.create(bind=engine, checkfirst=True) + print("✅ Created role_permissions table") + except Exception as exc: + print(f"⚠️ Failed to create role_permissions table: {exc}") + + @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown events.""" @@ -297,6 +371,8 @@ async def lifespan(app: FastAPI): ensure_user_password_change_required_column(inspector) ensure_user_email_unique_constraint(inspector) ensure_face_identified_by_user_id_column(inspector) + ensure_user_role_column(inspector) + ensure_role_permissions_table(inspector) except Exception as exc: print(f"❌ Database initialization failed: {exc}") raise @@ -337,6 +413,7 @@ def create_app() -> FastAPI: app.include_router(tags_router, prefix="/api/v1") app.include_router(users_router, prefix="/api/v1") app.include_router(auth_users_router, prefix="/api/v1") + app.include_router(role_permissions_router, prefix="/api/v1") return app diff --git a/src/web/constants/role_features.py b/src/web/constants/role_features.py new file mode 100644 index 0000000..5b6f937 --- /dev/null +++ b/src/web/constants/role_features.py @@ -0,0 +1,42 @@ +"""Feature definitions and default role permissions.""" + +from __future__ import annotations + +from typing import Dict, Final, List, Set + +from src.web.constants.roles import UserRole + +ROLE_FEATURES: Final[List[dict[str, str]]] = [ + {"key": "scan", "label": "Scan"}, + {"key": "process", "label": "Process"}, + {"key": "search_photos", "label": "Search Photos"}, + {"key": "identify_people", "label": "Identify People"}, + {"key": "auto_match", "label": "Auto-Match"}, + {"key": "modify_people", "label": "Modify People"}, + {"key": "tag_photos", "label": "Tag Photos"}, + {"key": "faces_maintenance", "label": "Faces Maintenance"}, + {"key": "user_identified", "label": "User Identified"}, + {"key": "user_reported", "label": "User Reported"}, + {"key": "user_uploaded", "label": "User Uploaded"}, + {"key": "manage_users", "label": "Manage Users"}, + {"key": "manage_roles", "label": "Manage Roles"}, +] + +ROLE_FEATURE_KEYS: Final[List[str]] = [feature["key"] for feature in ROLE_FEATURES] + +DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = { + UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS), + UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS), + UserRole.MODERATOR.value: {"scan", "process", "manage_users"}, + UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded"}, + UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users"}, + UserRole.IMPORTER.value: {"user_uploaded"}, + UserRole.VIEWER.value: {"user_identified", "user_reported"}, +} + + +def get_default_permission(role: str, feature_key: str) -> bool: + """Return the default allowed value for a role/feature pair.""" + allowed_features = DEFAULT_ROLE_FEATURE_MATRIX.get(role, set()) + return feature_key in allowed_features + diff --git a/src/web/constants/roles.py b/src/web/constants/roles.py new file mode 100644 index 0000000..1db2ccb --- /dev/null +++ b/src/web/constants/roles.py @@ -0,0 +1,32 @@ +"""Shared role definitions for backend user management.""" + +from __future__ import annotations + +from enum import Enum +from typing import Final, Set + + +class UserRole(str, Enum): + """Enumerated set of supported user roles.""" + + ADMIN = "admin" + MANAGER = "manager" + MODERATOR = "moderator" + REVIEWER = "reviewer" + EDITOR = "editor" + IMPORTER = "importer" + VIEWER = "viewer" + + +ROLE_VALUES: Final[Set[str]] = {role.value for role in UserRole} +ADMIN_ROLE_VALUES: Final[Set[str]] = { + UserRole.ADMIN.value, +} +DEFAULT_ADMIN_ROLE: Final[str] = UserRole.ADMIN.value +DEFAULT_USER_ROLE: Final[str] = UserRole.VIEWER.value + + +def is_admin_role(role: str) -> bool: + """Return True when the provided role is considered an admin role.""" + return role in ADMIN_ROLE_VALUES + diff --git a/src/web/db/models.py b/src/web/db/models.py index 1751d52..4d00c36 100644 --- a/src/web/db/models.py +++ b/src/web/db/models.py @@ -21,6 +21,8 @@ from sqlalchemy import ( ) from sqlalchemy.orm import declarative_base, relationship +from src.web.constants.roles import DEFAULT_USER_ROLE + if TYPE_CHECKING: pass @@ -212,6 +214,13 @@ class User(Base): full_name = Column(Text, nullable=False) is_active = Column(Boolean, default=True, nullable=False) is_admin = Column(Boolean, default=False, nullable=False, index=True) + role = Column( + Text, + nullable=False, + default=DEFAULT_USER_ROLE, + server_default=DEFAULT_USER_ROLE, + index=True, + ) password_change_required = Column(Boolean, default=True, nullable=False, index=True) created_date = Column(DateTime, default=datetime.utcnow, nullable=False) last_login = Column(DateTime, nullable=True) @@ -221,5 +230,22 @@ class User(Base): Index("idx_users_email", "email"), Index("idx_users_is_admin", "is_admin"), Index("idx_users_password_change_required", "password_change_required"), + Index("idx_users_role", "role"), + ) + + +class RolePermission(Base): + """Role-to-feature permission matrix.""" + + __tablename__ = "role_permissions" + + id = Column(Integer, primary_key=True, autoincrement=True) + role = Column(Text, nullable=False, index=True) + feature_key = Column(Text, nullable=False, index=True) + allowed = Column(Boolean, nullable=False, default=False, server_default="0") + + __table_args__ = ( + UniqueConstraint("role", "feature_key", name="uq_role_feature"), + Index("idx_role_permissions_role_feature", "role", "feature_key"), ) diff --git a/src/web/schemas/auth.py b/src/web/schemas/auth.py index 3755321..368909f 100644 --- a/src/web/schemas/auth.py +++ b/src/web/schemas/auth.py @@ -2,8 +2,12 @@ from __future__ import annotations +from typing import Dict + from pydantic import BaseModel, ConfigDict +from src.web.constants.roles import DEFAULT_USER_ROLE, UserRole + class LoginRequest(BaseModel): """Login request payload.""" @@ -39,6 +43,8 @@ class UserResponse(BaseModel): username: str is_admin: bool = False + role: UserRole = DEFAULT_USER_ROLE + permissions: Dict[str, bool] = {} class PasswordChangeRequest(BaseModel): diff --git a/src/web/schemas/role_permissions.py b/src/web/schemas/role_permissions.py new file mode 100644 index 0000000..4a05624 --- /dev/null +++ b/src/web/schemas/role_permissions.py @@ -0,0 +1,42 @@ +"""Schemas for role permissions management.""" + +from __future__ import annotations + +from typing import Dict + +from pydantic import BaseModel, ConfigDict, Field + +from src.web.constants.role_features import ROLE_FEATURES +from src.web.constants.roles import UserRole + + +class RoleFeatureSchema(BaseModel): + """Feature metadata visible in the UI.""" + + key: str + label: str + + +class RolePermissionsResponse(BaseModel): + """Payload returned when listing role permissions.""" + + model_config = ConfigDict(protected_namespaces=()) + + features: list[RoleFeatureSchema] + permissions: Dict[UserRole, Dict[str, bool]] + + +class RolePermissionsUpdateRequest(BaseModel): + """Payload for updating role permissions.""" + + model_config = ConfigDict(protected_namespaces=()) + + permissions: Dict[UserRole, Dict[str, bool]] = Field( + ..., + description="Map of role -> {feature_key: allowed}", + ) + + @staticmethod + def build_feature_list() -> list[RoleFeatureSchema]: + return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES] + diff --git a/src/web/schemas/users.py b/src/web/schemas/users.py index 450cdce..5547e34 100644 --- a/src/web/schemas/users.py +++ b/src/web/schemas/users.py @@ -7,6 +7,8 @@ from typing import Optional from pydantic import BaseModel, ConfigDict, EmailStr, Field +from src.web.constants.roles import DEFAULT_USER_ROLE, UserRole + class UserResponse(BaseModel): """User DTO returned from API.""" @@ -19,6 +21,7 @@ class UserResponse(BaseModel): full_name: Optional[str] = None is_active: bool is_admin: bool + role: UserRole password_change_required: bool created_date: datetime last_login: Optional[datetime] = None @@ -35,6 +38,10 @@ class UserCreateRequest(BaseModel): full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)") is_active: bool = True is_admin: bool = False + role: UserRole = Field( + DEFAULT_USER_ROLE, + description="Role for feature-level access; also controls admin status where applicable", + ) give_frontend_permission: bool = Field(False, description="Create user in auth database for frontend access") @@ -48,6 +55,10 @@ class UserUpdateRequest(BaseModel): full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)") is_active: Optional[bool] = None is_admin: Optional[bool] = None + role: Optional[UserRole] = Field( + None, + description="Updated role; determines admin status when provided", + ) give_frontend_permission: Optional[bool] = Field( None, description="Create user in auth database for frontend access if True", diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 0f6a6a3..fd6032c 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1248,11 +1248,11 @@ def list_unidentified_faces( ) else: # Photos that have ANY of the specified tags - query = ( - query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id) + tagged_photo_ids_subquery = ( + db.query(PhotoTagLinkage.photo_id) .filter(PhotoTagLinkage.tag_id.in_(tag_ids)) - .distinct() ) + query = query.filter(Face.photo_id.in_(tagged_photo_ids_subquery)) else: # No matching tags found - return empty result return [], 0 diff --git a/src/web/services/role_permissions.py b/src/web/services/role_permissions.py new file mode 100644 index 0000000..d705592 --- /dev/null +++ b/src/web/services/role_permissions.py @@ -0,0 +1,73 @@ +"""Role permission helpers for ensuring and updating access matrix.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Dict + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.web.constants.role_features import ( + ROLE_FEATURE_KEYS, + get_default_permission, +) +from src.web.constants.roles import ROLE_VALUES +from src.web.db.models import RolePermission + + +def ensure_role_permissions_initialized(session: Session) -> None: + """Seed permissions table once using default matrix if table is empty.""" + + has_permissions = session.execute(select(RolePermission.id)).first() + if has_permissions: + return + + for role in ROLE_VALUES: + for feature_key in ROLE_FEATURE_KEYS: + permission = RolePermission( + role=role, + feature_key=feature_key, + allowed=get_default_permission(role, feature_key), + ) + session.add(permission) + + session.commit() + + +def fetch_role_permissions_map(session: Session) -> Dict[str, Dict[str, bool]]: + """Return permissions map keyed by role then feature.""" + + ensure_role_permissions_initialized(session) + permissions = defaultdict(dict) + results = session.execute(select(RolePermission)).scalars().all() + for perm in results: + permissions[perm.role][perm.feature_key] = bool(perm.allowed) + return dict(permissions) + + +def set_role_permissions(session: Session, permissions: Dict[str, Dict[str, bool]]) -> None: + """Update permissions based on provided map.""" + + ensure_role_permissions_initialized(session) + existing = { + (perm.role, perm.feature_key): perm + for perm in session.execute(select(RolePermission)).scalars().all() + } + + updated = False + for role, feature_map in permissions.items(): + for feature_key, allowed in feature_map.items(): + key = (role, feature_key) + perm = existing.get(key) + if perm is None: + perm = RolePermission(role=role, feature_key=feature_key) + session.add(perm) + existing[key] = perm + if perm.allowed != bool(allowed): + perm.allowed = bool(allowed) + updated = True + + if updated: + session.commit() +