diff --git a/eslint.config.js b/eslint.config.js index 092408a9..c17615ca 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,6 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' - export default tseslint.config( { ignores: ['dist'] }, { @@ -19,10 +18,11 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + // TODO[cfviotti]: Check if removing this rule is the right path to mitigate these warnings. + // 'react-refresh/only-export-components': [ + // 'warn', + // { allowConstantExport: true }, + // ], }, }, ) diff --git a/package-lock.json b/package-lock.json index 152866f4..4fa93f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,10 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-visually-hidden": "^1.1.0", + "@react-oauth/google": "^0.12.1", + "@stepperize/react": "^5.1.5", "@tanstack/react-query": "^5.59.15", + "@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-table": "^8.20.5", "@uiw/react-textarea-code-editor": "^3.1.0", "@xyflow/react": "^12.3.6", @@ -71,7 +74,6 @@ "@storybook/react-vite": "^8.6.9", "@storybook/testing-library": "^0.2.1", "@storybook/types": "^8.6.9", - "@tanstack/react-query-devtools": "^5.74.4", "@types/dagre": "^0.7.52", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", @@ -87,6 +89,7 @@ "autoprefixer": "^10.4.20", "chromatic": "^11.27.0", "eslint": "^9.9.0", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", @@ -3494,6 +3497,16 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@react-oauth/google": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", + "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@remix-run/router": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", @@ -3763,6 +3776,34 @@ "win32" ] }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stepperize/core": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@stepperize/core/-/core-1.2.5.tgz", + "integrity": "sha512-2jk/yG4aZla+i67zcsXrTfzBbBx/ILtuaafsBpwZCnfVhPcvoKAPQvSm55p0Q6nQPcRVJ3nKexcR8/k5V+XQBA==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.0.2" + } + }, + "node_modules/@stepperize/react": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@stepperize/react/-/react-5.1.5.tgz", + "integrity": "sha512-2g3rWY9j4aViH3ppVHxnGHhSOy3TSUlj/F0h9bG6PKcrmmes85mMHMB1lMjA7vUtkdedczZSn90ybrwv4LT8lw==", + "license": "MIT", + "dependencies": { + "@stepperize/core": "1.2.5" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@storybook/addon-actions": { "version": "8.6.9", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.9.tgz", @@ -4864,10 +4905,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.73.3", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.73.3.tgz", - "integrity": "sha512-hBQyYwsOuO7QOprK75NzfrWs/EQYjgFA0yykmcvsV62q0t6Ua97CU3sYgjHx0ZvxkXSOMkY24VRJ5uv9f5Ik4w==", - "dev": true, + "version": "5.74.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.74.6.tgz", + "integrity": "sha512-djaFT11mVCOW3e0Ezfyiq7T6OoHy2LRI1fUFQvj+G6+/4A1FkuRMNUhQkdP1GXlx8id0f1/zd5fgDpIy5SU/Iw==", "license": "MIT", "funding": { "type": "github", @@ -4891,13 +4931,12 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.74.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.4.tgz", - "integrity": "sha512-PGCAcytQMmeagoeGG45ccBhrC1x0/5OlNjsM1FAb9OfsQZIhPzjwjhGcwmMu6TbT4RIHgvjxLwC5NHgkUwJQzw==", - "dev": true, + "version": "5.74.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.74.6.tgz", + "integrity": "sha512-vlsDwz4/FsblK0h7VAlXUdJ+9OV+i1n8OLb8CLLAZqu0M9GCnbajytZwsRmns33PXBZ6wQBJ859kg6aajx+e9Q==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.73.3" + "@tanstack/query-devtools": "5.74.6" }, "funding": { "type": "github", @@ -5224,6 +5263,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -5838,6 +5884,109 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "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", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "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/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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5862,6 +6011,16 @@ "node": ">=4" } }, + "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", @@ -6715,6 +6874,60 @@ "node": ">=12" } }, + "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/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -7033,6 +7246,72 @@ "dev": true, "license": "MIT" }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "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.3", + "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.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "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-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.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.18" + }, + "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", @@ -7084,6 +7363,53 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "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", @@ -7221,62 +7547,229 @@ } } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0-rc-fb9a90fa48-20240614", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", - "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.13.tgz", - "integrity": "sha512-f1EppwrpJRWmqDTyvAyomFVDYRtrS7iTEqv3nokETnMiMzs2SSTmKRTACce4O2p4jYyowiSMvpdwC/RLcMFhuQ==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "eslint": ">=7" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/eslint-scope": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", - "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "debug": "^3.2.7" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=4" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/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-import/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/eslint-plugin-import/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-import/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-plugin-import/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0-rc-fb9a90fa48-20240614", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.13.tgz", + "integrity": "sha512-f1EppwrpJRWmqDTyvAyomFVDYRtrS7iTEqv3nokETnMiMzs2SSTmKRTACce4O2p4jYyowiSMvpdwC/RLcMFhuQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "license": "MIT", @@ -7667,6 +8160,27 @@ "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", @@ -7733,6 +8247,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": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -7780,6 +8312,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/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7835,6 +8384,22 @@ "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", @@ -8167,6 +8732,26 @@ "optional": true, "peer": true }, + "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", @@ -8242,6 +8827,24 @@ "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", @@ -8296,6 +8899,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", @@ -8312,7 +8931,6 @@ "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", @@ -8490,7 +9108,6 @@ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "which-typed-array": "^1.1.16" }, @@ -8514,6 +9131,22 @@ "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", @@ -9321,6 +9954,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -9358,6 +10044,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", @@ -10233,6 +10937,29 @@ "node": ">=8" } }, + "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/refractor": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.8.1.tgz", @@ -10489,6 +11216,43 @@ "tslib": "^2.1.0" } }, + "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", @@ -10563,6 +11327,21 @@ "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/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -10898,6 +11677,65 @@ "node": ">=8" } }, + "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/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -11282,11 +12120,88 @@ "node": ">= 0.8.0" } }, + "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.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11320,6 +12235,25 @@ } } }, + "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/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -11760,6 +12694,34 @@ "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", diff --git a/package.json b/package.json index cb800c9a..f5b929e8 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,10 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-visually-hidden": "^1.1.0", + "@react-oauth/google": "^0.12.1", + "@stepperize/react": "^5.1.5", "@tanstack/react-query": "^5.59.15", + "@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-table": "^8.20.5", "@uiw/react-textarea-code-editor": "^3.1.0", "@xyflow/react": "^12.3.6", @@ -78,7 +81,6 @@ "@storybook/react-vite": "^8.6.9", "@storybook/testing-library": "^0.2.1", "@storybook/types": "^8.6.9", - "@tanstack/react-query-devtools": "^5.74.4", "@types/dagre": "^0.7.52", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", @@ -94,6 +96,7 @@ "autoprefixer": "^10.4.20", "chromatic": "^11.27.0", "eslint": "^9.9.0", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", diff --git a/src/components/Auth/Auth.tsx b/src/components/Auth/AuthLayout.tsx similarity index 53% rename from src/components/Auth/Auth.tsx rename to src/components/Auth/AuthLayout.tsx index aaa9d4da..6eea89f0 100644 --- a/src/components/Auth/Auth.tsx +++ b/src/components/Auth/AuthLayout.tsx @@ -1,40 +1,20 @@ -import { useEffect, useState } from 'react'; import BGAuthHero from "@/assets/bg-auth-hero.jpg"; import BGNoise from "@/assets/bg-noise.png"; import logoESIFullWhite from "@/assets/logo-esi-full-white.svg"; -import { Button } from "@/components/ui/button"; -import { LucideArrowLeft } from "lucide-react"; -import { WEBSITE_DOMAIN } from '@/constants'; -import AuthSignupSection from '@/components/Auth/AuthSignupSection'; -import AuthLoginSection from '@/components/Auth/AuthLoginSection'; - -interface AuthProps { - variant: 'login' | 'signup'; -} - -function Auth({ variant }: AuthProps) { - const [tenantId, setTenantId] = useState(''); - const [currentStep, setCurrentStep] = useState<"organizationURL" | "credentials">("organizationURL"); - - useEffect(() => { - setCurrentStep("organizationURL"); - setTenantId(''); - }, [variant]); - - const handleStepChange = (step: "organizationURL" | "credentials") => { - setCurrentStep(step); - }; - - const handleOrganizationURLChange = (tenantId: string) => { - setTenantId(tenantId); - }; - +import { WEBSITE_DOMAIN } from "@/constants"; +import { Link, Outlet } from "react-router-dom"; + +/** + * Provides the common layout structure for authentication pages (Login, Signup, etc.). + * Includes background elements, branding, and a placeholder for page-specific content. + */ +export function AuthLayout() { return (
-
- + @@ -43,7 +23,7 @@ function Auth({ variant }: AuthProps) { src={logoESIFullWhite} alt="External Secrets" /> - +

Your seamless secrets management journey starts here

@@ -51,34 +31,16 @@ function Auth({ variant }: AuthProps) { className="absolute inset-0 bg-[100%_auto] bg-center animate-bg-auth-hero-scroll motion-reduce:animate-none -z-10 border-transparent border-8 bg-clip-padding rounded-[inherit] opacity-95 dark:opacity-75" style={{ backgroundImage: `url('${BGAuthHero}')` }} /> -
-
-
- {variant === 'login' ? - - : - - } + - -
+
+
+ +
-
+ +
@@ -91,5 +53,3 @@ function Auth({ variant }: AuthProps) {
); } - -export default Auth; \ No newline at end of file diff --git a/src/components/Auth/AuthLoginSection.tsx b/src/components/Auth/AuthLoginSection.tsx deleted file mode 100644 index d88f732c..00000000 --- a/src/components/Auth/AuthLoginSection.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { APP_DOMAIN_STRIPPED } from '@/constants'; -import LoginForm from './LoginForm'; - -interface AuthLoginSectionProps { - currentStep: "organizationURL" | "credentials"; - organizationUrl: string; - handleStepChange: (step: "organizationURL" | "credentials") => void; - handleOrganizationURLChange: (organizationUrl: string) => void; -} - -const AuthLoginSection: React.FC = ({ currentStep, organizationUrl, handleStepChange, handleOrganizationURLChange }) => ( -
-
-

- {currentStep === "credentials" && organizationUrl ? ( - <>You're logging in on - ) : ( - <>Log in to an Organization - )} -

- {currentStep === "credentials" && organizationUrl ? ( -

- {APP_DOMAIN_STRIPPED}/{organizationUrl} -

- ) : ( -

- Welcome back! -

- )} -
- -
-); - -export default AuthLoginSection; \ No newline at end of file diff --git a/src/components/Auth/AuthRedirectGuard.tsx b/src/components/Auth/AuthRedirectGuard.tsx new file mode 100644 index 00000000..b1ad97b4 --- /dev/null +++ b/src/components/Auth/AuthRedirectGuard.tsx @@ -0,0 +1,46 @@ +import { IUserData } from "@/types"; +import React, { useEffect, useState } from "react"; +import useAuthUser from "react-auth-kit/hooks/useAuthUser"; +import useIsAuthenticated from "react-auth-kit/hooks/useIsAuthenticated"; +import { Navigate, Outlet } from "react-router-dom"; + +/** + * A layout guard component specifically for auth routes (/login, /signup). + * If the user is authenticated (and has an org), it redirects them to the main app (/org/agents). + * If the user is not authenticated, it renders an to display the nested route (AuthLayout). + * Returns null during the loading state to prevent content flash. + */ +export const AuthRedirectGuard: React.FC = () => { + const isAuthenticated = useIsAuthenticated(); + const authUser = useAuthUser(); + const [redirectTo, setRedirectTo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let targetPath: string | null = null; + if (isAuthenticated) { + const user = authUser; + const organizationURL = user?.tenant; + if (organizationURL) { + targetPath = `/${organizationURL}/agents`; + } else { + console.error( + "AuthRedirectGuard: User is authenticated but tenant information is missing." + ); + } + } + + setRedirectTo(targetPath); + setLoading(false); + }, [isAuthenticated, authUser]); + + if (loading) { + return null; + } + + if (redirectTo) { + return ; + } + + return ; +}; diff --git a/src/components/Auth/AuthSignupSection.tsx b/src/components/Auth/AuthSignupSection.tsx deleted file mode 100644 index 8da5fb96..00000000 --- a/src/components/Auth/AuthSignupSection.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import SignupForm from './SignupForm'; - -const AuthSignupSection: React.FC = () => ( -
-
-

Create an Organization

-

- Unlock the full potential of External Secrets in your Kubernetes cluster -

-
- - -
-); - -export default AuthSignupSection; \ No newline at end of file diff --git a/src/components/Auth/ForgotPassword.tsx b/src/components/Auth/ForgotPassword.tsx deleted file mode 100644 index e2c6e3b9..00000000 --- a/src/components/Auth/ForgotPassword.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { LucideLoader } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { Link, useLocation } from "react-router-dom"; -import { toast } from "sonner"; -import { z } from "zod"; -import zValidations from "./fields/zValidations"; -import AppLogo from "@/components/AppLogo"; -import Cookies from 'js-cookie'; -import { APP_DOMAIN_STRIPPED, ONE_MINUTE_IN_SECONDS, ONE_SECOND_IN_MILLISECONDS } from "@/constants"; -import useForgotPassword from "@/services/forgotPassword/mutations/useForgotPassword"; - -const ForgotPasswordSchema = z.object({ - tenant: zValidations.organizationURL, - email: zValidations.email, -}); - -type ForgotPasswordData = z.infer; - -function ForgotPassword() { - const [formError, setFormError] = useState("") - const location = useLocation() - const toastIdRef = useRef(null); - - const state = location.state as { organizationURL?: string; email?: string }; - const defaultTenant = state?.organizationURL || ""; - const defaultEmail = state?.email || ""; - - const { mutate: forgotPassword, isPending: loading } = useForgotPassword({ - onSuccess: (_, variables: ForgotPasswordData) => { - // TODO: Remove these cookies when we are sending the necessary data from the token within the reset password email link - const tenMinutesFromNow = new Date(new Date().getTime() + 10 * ONE_MINUTE_IN_SECONDS * ONE_SECOND_IN_MILLISECONDS); - Cookies.set("forgotPasswordHelperOrganizationURL", variables.tenant, { expires: tenMinutesFromNow }); - Cookies.set("forgotPasswordHelperEmail", variables.email, { expires: tenMinutesFromNow }); - - toastIdRef.current = toast.success('Check your email', { - description: 'We sent instructions to reset your password', - duration: Infinity, - cancel: { - label: 'Dismiss', - onClick: () => {}, - }, - }); - }, - onError: () => { - setFormError("Failed to start the reset password flow"); - } - }); - - const form = useForm({ - resolver: zodResolver(ForgotPasswordSchema), - defaultValues: { - email: defaultEmail, - tenant: defaultTenant, - }, - }); - - const emailInputRef = useRef(null); - const tenantInputRef = useRef(null); - const submitButtonRef = useRef(null); - - useEffect(() => { - if (toastIdRef.current) { - toast.dismiss(toastIdRef.current); - } - - if (defaultTenant) { - if (!defaultEmail || !zValidations.email.safeParse(defaultEmail).success) { - emailInputRef.current?.focus(); - } else { - submitButtonRef.current?.focus(); - } - } else { - tenantInputRef.current?.focus(); - } - }, [location, defaultTenant, defaultEmail]) - - async function onSubmit(values: ForgotPasswordData) { - forgotPassword(values); - } - - return ( -
-
-
- -

- Reset Password -

-

- Include the Organization URL and email address associated with your account and we'll send you an email with instructions to reset your password -

-
- -
- - { - const { ref, ...restField } = field; // eslint-disable-line @typescript-eslint/no-unused-vars - return ( - - Enter your Organization URL - -
tenantInputRef.current?.focus()} - className="border-input border rounded-md flex items-baseline focus-within:ring-ring focus-within:ring-1" - > - - {APP_DOMAIN_STRIPPED}/ - - -
-
- -
- ); - }} - /> - { - const { ref, ...restField } = field; - return ( - - Email - - { - ref(e); // Assign to react-hook-form ref - emailInputRef.current = e; // Assign to local ref - }} - id="email" - placeholder="you@yourcompany.com" - {...restField} - /> - - - - ); - }} - /> - -
- - -
- - - {formError &&
{formError}
} -
-
- ); -} - -export default ForgotPassword; diff --git a/src/components/Auth/LoginCredentialsStep.tsx b/src/components/Auth/LoginCredentialsStep.tsx deleted file mode 100644 index ee5902c3..00000000 --- a/src/components/Auth/LoginCredentialsStep.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useFormContext } from "react-hook-form"; -import { Link } from "react-router-dom"; -import { LucideEye, LucideEyeOff, LucideLoader } from "lucide-react"; - -interface LoginCredentialsStepProps { - onSubmit: () => void; - onBack: () => void; - loading: boolean; -} - -export function LoginCredentialsStep({ onSubmit, onBack, loading }: LoginCredentialsStepProps) { - const { control, handleSubmit, getValues } = useFormContext(); - const [passwordVisible, setPasswordVisible] = useState(false); - - const togglePasswordVisibility = () => { - setPasswordVisible(!passwordVisible); - }; - - return ( -
- ( - - Email - - - - - - )} - /> - ( - -
- Password - - Forgot your password? - -
- -
- - -
-
- -
- )} - /> -
- - -
- - ); -} \ No newline at end of file diff --git a/src/components/Auth/LoginForm.tsx b/src/components/Auth/LoginForm.tsx deleted file mode 100644 index 14e1f068..00000000 --- a/src/components/Auth/LoginForm.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { trackLoginStepCompleted, trackLoginStepMovedBack, trackSignedIn } from "@/analytics"; -import { FormProvider, useForm } from "react-hook-form"; -import { useState } from "react"; -import useSignIn from "react-auth-kit/hooks/useSignIn"; -import { Link, useNavigate } from "react-router-dom"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { AxiosError, isAxiosError } from "axios"; -import { LoginOrganizationURLStep } from "./LoginOrganizationURLStep"; -import { LoginCredentialsStep } from "./LoginCredentialsStep"; -import zValidations from "./fields/zValidations"; -import { ApiHttpError } from "@/types"; -import { LoginAndIdentifyParams } from "@/services/auth/Auth.interfaces"; -import useLoginAndIdentifyUser from "@/services/auth/mutations/useLoginAndIdentifyUser"; - -const LoginOrganizationURLSchema = z.object({ - organizationURL: zValidations.organizationURL -}); - -const LoginCredentialsSchema = z.object({ - email: zValidations.email, - password: zValidations.existingPassword, -}); - -type LoginOrganizationURLData = z.infer; -type LoginCredentialsData = z.infer; -type LoginData = LoginOrganizationURLData & LoginCredentialsData; -type Step = "organizationURL" | "credentials"; - -interface LoginFormProps { - onStepChange: (step: Step) => void; - onOrganizationURLChange: (tenantId: string) => void; -} - -function LoginForm({ onStepChange, onOrganizationURLChange }: LoginFormProps) { - const [step, setStep] = useState("organizationURL"); - const [formError, setFormError] = useState(null); - const authKitSignIn = useSignIn(); - const navigate = useNavigate(); - - const { mutate: loginAndIdentifyUser, isPending: isLoginPending } = useLoginAndIdentifyUser({ - onError: (error: AxiosError) => { - const stockError = "Login failed. Please try again."; - if (isAxiosError(error)) { - const responseError = error.response?.data?.errors?.error; - if (responseError?.includes("invalid username/password")) { - return setFormError("Invalid login credentials"); - } - - if (responseError?.includes("invalid tenant")) { - setStep("organizationURL"); - onStepChange("organizationURL"); - return formMethods.setError("organizationURL", { type: "manual", message: "Invalid Organization URL" }); - } - } - - console.error("Non-Axios error:", error); - setFormError(stockError); - }, - onSuccess: (_, variables: LoginAndIdentifyParams) => { - trackSignedIn(variables.tenantSlug); - return navigate(`/${variables.tenantSlug}/agents`); - }, - }) - - const formMethods = useForm({ - resolver: zodResolver(step === "organizationURL" ? LoginOrganizationURLSchema : LoginCredentialsSchema), - defaultValues: { - organizationURL: "", - email: "", - password: "", - }, - }); - - const handleOrganizationURLStepSubmit = () => { - const formData = formMethods.getValues(); - onOrganizationURLChange(formData.organizationURL); - trackLoginStepCompleted(1); - setStep("credentials"); - onStepChange("credentials"); - }; - - const handleCredentialsStepSubmit = async (data: LoginCredentialsData) => { - formMethods.clearErrors(); - setFormError(null); - - const formData = formMethods.getValues(); - loginAndIdentifyUser({ - email: data.email!, - password: data.password!, - tenantSlug: formData.organizationURL!, - authKitSignIn, - }); - }; - - const handleBack = () => { - setStep("organizationURL"); - onStepChange("organizationURL"); - trackLoginStepMovedBack(); - }; - - return ( - <> - - {step === "organizationURL" ? ( - - ) : ( - - )} - {formError ?
{formError}
: null} -
- -
- Don't have an Organization yet?{" "} - - Sign up for one - -
- - ); -} - -export default LoginForm; diff --git a/src/components/Auth/LoginOrganizationURLStep.tsx b/src/components/Auth/LoginOrganizationURLStep.tsx deleted file mode 100644 index bd448d1f..00000000 --- a/src/components/Auth/LoginOrganizationURLStep.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useFormContext } from "react-hook-form"; -import { useRef } from "react"; -import { APP_DOMAIN_STRIPPED } from "@/constants"; - -interface LoginOrganizationURLStepProps { - onSubmit: () => void; -} - -export function LoginOrganizationURLStep({ onSubmit }: LoginOrganizationURLStepProps) { - const { control, handleSubmit } = useFormContext(); - const orgURLref = useRef(null); - - return ( -
- { - const { ref, ...restField } = field; // eslint-disable-line @typescript-eslint/no-unused-vars - return ( - - Enter your Organization URL - -
orgURLref.current?.focus()} - className="border-input border rounded-md flex items-baseline focus-within:ring-ring focus-within:ring-1" - > - - {APP_DOMAIN_STRIPPED}/ - - -
-
- -
- ); - }} - /> - - - ); -} diff --git a/src/components/Auth/ResetPassword.tsx b/src/components/Auth/ResetPassword.tsx deleted file mode 100644 index 68e8ea63..00000000 --- a/src/components/Auth/ResetPassword.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { LucideLoader } from "lucide-react"; -import { useRef, useState, useEffect } from "react"; -import { useForm, FormProvider } from "react-hook-form"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { toast } from "sonner"; -import { z } from "zod"; -import NewPasswordField from "./fields/NewPasswordField"; -import zValidations from "./fields/zValidations"; -import AppLogo from "@/components/AppLogo"; -import Cookies from 'js-cookie'; -import { APP_DOMAIN_STRIPPED } from "@/constants"; -import useResetPassword from "@/services/forgotPassword/mutations/useResetPassword"; - -const ResetPasswordSchema = z.object({ - tenant: zValidations.organizationURL, - email: zValidations.email, - token: z.string(), - password: zValidations.newPassword, -}); - -type ResetPasswordData = z.infer; - -function ResetPasswordForm() { - // eslint-disable-next-line prefer-const, @typescript-eslint/no-unused-vars - let [searchParams, _] = useSearchParams(); - - const navigate = useNavigate(); - const [formError, setFormError] = useState(""); - const [submittedWithErrors, setSubmittedWithErrors] = useState(false); - - const { mutate: resetPassword, isPending: loading} = useResetPassword({ - onSuccess: () => { - toast.success('Password updated successfully', { description: 'Please log in' }); - Cookies.remove("forgotPasswordHelperOrganizationURL"); - Cookies.remove("forgotPasswordHelperEmail"); - navigate('/login') - } - }); - - const newPasswordRef = useRef(null); - - const formMethods = useForm({ - resolver: zodResolver(ResetPasswordSchema), - defaultValues: { - // TODO: Remove these cookies when we are sending the necessary data from the token within the reset password email link - tenant: Cookies.get("forgotPasswordHelperOrganizationURL") || "", - email: Cookies.get("forgotPasswordHelperEmail") || "", - token: searchParams.get("token") || "", - password: "", - }, - }); - - const { setFocus } = formMethods; - - useEffect(() => { - // TODO: Remove these cookies when we are sending the necessary data from the token within the reset password email link - const tenant = Cookies.get("forgotPasswordHelperOrganizationURL"); - const email = Cookies.get("forgotPasswordHelperEmail"); - - if (tenant && email) { - newPasswordRef.current?.focus(); - } else { - setFocus("tenant"); - } - }, [setFocus]); - - const handleSubmit = (data: ResetPasswordData) => { - setSubmittedWithErrors(false); - onSubmit(data); - }; - - const handleError = () => { - setSubmittedWithErrors(true); - setFormError("Failed to start the reset password flow") - }; - - async function onSubmit(values: ResetPasswordData) { - resetPassword(values) - } - - return ( -
-
-
- -

- Set up a new password -

-

- Once it's set, you can use it to log in again -

-
- - -
- ( - - Enter your Organization URL - -
setFocus("tenant")} - className="border-input border rounded-md flex items-baseline focus-within:ring-ring focus-within:ring-1" - > - - {APP_DOMAIN_STRIPPED}/ - - -
-
- -
- )} - /> - ( - - Email - - - - - - )} - /> - - - -
- -
- -
- {formError &&
{formError}
} -
-
- ); -} - -export default ResetPasswordForm; diff --git a/src/components/Auth/SignupCredentialsStep.tsx b/src/components/Auth/SignupCredentialsStep.tsx deleted file mode 100644 index 46cc889f..00000000 --- a/src/components/Auth/SignupCredentialsStep.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from "react"; -import { useFormContext } from "react-hook-form"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { LucideLoader } from "lucide-react"; -import NewPasswordField from "./fields/NewPasswordField"; - -const SignupCredentialsStep = ({ onSubmit, onBack, loading }: { onSubmit: (data: any) => void, onBack: () => void, loading: boolean }) => { // eslint-disable-line @typescript-eslint/no-explicit-any - const { handleSubmit, control } = useFormContext(); - const [submittedWithErrors, setSubmittedWithErrors] = useState(false); - - const handleFormSubmit = (data: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any - setSubmittedWithErrors(false); - onSubmit(data); - }; - - const handleError = () => { - setSubmittedWithErrors(true); - }; - - return ( -
- ( - - Email - - - - - - )} - /> - - - -
- - - -
- - ); -}; - -export default SignupCredentialsStep; diff --git a/src/components/Auth/SignupForm.tsx b/src/components/Auth/SignupForm.tsx deleted file mode 100644 index 5a359a79..00000000 --- a/src/components/Auth/SignupForm.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { trackSignedIn, trackSignupStepCompleted, trackSignupStepMovedBack } from "@/analytics"; -import { useState } from "react"; -import useSignIn from "react-auth-kit/hooks/useSignIn"; -import { useForm, FormProvider } from "react-hook-form"; -import { Link, useNavigate } from "react-router-dom"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import SignupOrganizationInfoStep from "./SignupOrganizationInfoStep"; -import SignupCredentialsStep from "./SignupCredentialsStep"; -import zValidations from "./fields/zValidations"; -import { AxiosError, isAxiosError } from "axios"; -import { toast } from "sonner"; -import useSignup from "@/services/auth/mutations/useSignup"; -import { ApiHttpError } from "@/types"; -import useLoginAndIdentifyUser from "@/services/auth/mutations/useLoginAndIdentifyUser"; -import { LoginAndIdentifyParams, SignupPayload } from "@/services/auth/Auth.interfaces"; - -const MAX_LOGIN_RETRIES = 4; - -const OrganizationInfoSchema = z.object({ - organizationName: zValidations.organizationName, - name: zValidations.name, - organizationURL: zValidations.organizationURL, -}); - -const CredentialsSchema = z.object({ - email: zValidations.email, - password: zValidations.newPassword, -}); - -type SignupData = z.infer & z.infer; -type Step = "organizationInfo" | "credentials"; - -function SignupForm() { - const [step, setStep] = useState("organizationInfo"); - const [formError, setFormError] = useState(null); - const authKitSignIn = useSignIn(); - const navigate = useNavigate(); - - const { mutate: loginAndIdentifyUser, isPending: isLoginPending } = useLoginAndIdentifyUser({ - onSuccess: (_, variables: LoginAndIdentifyParams) => { - trackSignedIn(variables.tenantSlug); - return navigate(`/${variables.tenantSlug}/agents`); - }, - retry: (failureCount) => { - if (failureCount < MAX_LOGIN_RETRIES) return true - - // If the user has reached the maximum login retries, redirect to login screen - toast.success('Organization created successfully', { - description: 'You can now log in with your credentials', - }); - navigate('/login'); - return false - } - }) - - const { mutate: signup, isPending: isSignupPending } = useSignup({ - onError: (error: AxiosError) => { - const stockError = "Signup failed. Please try again."; - if (isAxiosError(error)) { - const responseError = error.response?.data?.errors?.error; - - if (responseError?.includes("tenant name already exists")) { - setStep("organizationInfo"); - formMethods.setError("organizationURL", { type: "manual", message: "This Organization URL is taken. Create a unique one or log in." }); - // setTimeout is used to ensure the focus is set after the step set is rendered. Not sure what is the Reacty way to do this. - return setTimeout(() => { - formMethods.setFocus("organizationURL"); - }, 0); - - } - - if (responseError?.includes("Field validation for 'Password' failed on the 'password_regex' tag")) { - formMethods.setError("password", { type: "manual", message: "Invalid special character. Use only: _ ! @ # $ % ^ & * ( ) -" }); - return formMethods.setFocus("password"); // TODO: This is not working, need to investigate. Maybe because of being inside it's own component? - } - } - - console.error("Non-Axios error:", error); - setFormError(stockError); - }, - onSuccess: (_, variables: SignupPayload) => { - trackSignupStepCompleted(2, variables.tenant); - loginAndIdentifyUser({ - email: variables.email, - password: variables.password, - tenantSlug: variables.tenant, - name: variables.name, - authKitSignIn, - }) - }, - }) - - - const formMethods = useForm({ - resolver: zodResolver(step === "organizationInfo" ? OrganizationInfoSchema : CredentialsSchema), - mode: "onSubmit", - defaultValues: { - organizationName: "", - name: "", - organizationURL: "", - email: "", - password: "", - }, - }); - - const handleOrganizationInfoSubmit = () => { - trackSignupStepCompleted(1, formMethods.getValues("organizationName")); - setStep("credentials"); - }; - - const handleCredentialsSubmit = async () => { - formMethods.clearErrors(); - setFormError(null); - const formData = formMethods.getValues(); - - signup({ - email: formData.email, - name: formData.name, - password: formData.password, - tenant: formData.organizationURL - }) - }; - - const handleBack = () => { - setStep("organizationInfo"); - trackSignupStepMovedBack(); - }; - - return ( - <> - - {step === "organizationInfo" ? ( - - ) : ( - - )} - {formError &&
{formError}
} -
- -
- Already a member of an Organization?{" "} - - Log in - -
- - ); -} - -export default SignupForm; diff --git a/src/components/Auth/SignupOrganizationInfoStep.tsx b/src/components/Auth/SignupOrganizationInfoStep.tsx deleted file mode 100644 index 076fcb34..00000000 --- a/src/components/Auth/SignupOrganizationInfoStep.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useRef, useState } from "react"; -import { useFormContext } from "react-hook-form"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { APP_DOMAIN_STRIPPED } from "@/constants"; -import { createSlug } from "@/utils/slugify"; - -const SignupOrganizationInfoStep = ({ onSubmit }: { onSubmit: () => void }) => { - const { handleSubmit, setValue, control } = useFormContext(); - const orgURLRef = useRef(null); - const [isURLManuallyEdited, setIsURLManuallyEdited] = useState(false); - - const handleOrganizationNameChange = ( - e: React.ChangeEvent - ) => { - const value = e.target.value; - setValue("organizationName", value); - if (!isURLManuallyEdited) { - setValue("organizationURL", createSlug(value)); - } - }; - - const handleOrganizationURLChange = ( - e: React.ChangeEvent - ) => { - setIsURLManuallyEdited(true); - setValue("organizationURL", e.target.value); - }; - - return ( -
- ( - - Your Full Name - - - - - - )} - /> - ( - - Organization Name - - { - handleOrganizationNameChange(e); - field.onChange(e); - }} - /> - - - - )} - /> - { - const { ref, onChange, ...restField } = field; - return ( - - Create an Organization URL - -
orgURLRef.current?.focus()} - className="border-input border rounded-md flex items-baseline focus-within:ring-ring focus-within:ring-1" - > - - {APP_DOMAIN_STRIPPED}/ - - { - ref(e); // Assign to react-hook-form ref - orgURLRef.current = e; // Assign to local ref - }} - className="border-none pl-0 focus-visible:ring-0" - id="organizationURL" - placeholder="acme-inc" - autoCapitalize="none" - autoComplete="off" - onChange={(e) => { - handleOrganizationURLChange(e); - onChange(e); - }} - {...restField} - /> -
-
- -
- ); - }} - /> - - - ); -}; - -export default SignupOrganizationInfoStep; \ No newline at end of file diff --git a/src/components/Auth/common/AuthCommonFieldEmail.tsx b/src/components/Auth/common/AuthCommonFieldEmail.tsx new file mode 100644 index 00000000..7b64a573 --- /dev/null +++ b/src/components/Auth/common/AuthCommonFieldEmail.tsx @@ -0,0 +1,46 @@ +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { InputHTMLAttributes, RefObject } from "react"; +import { Control, FieldValues, Path } from "react-hook-form"; + +interface AuthCommonFieldEmailProps extends Omit, 'name'> { + control: Control; + name: Path; + label?: string; + inputRef?: RefObject; +} + +export function AuthCommonFieldEmail({ + control, + name, + label = "Email", + inputRef, + ...props +}: AuthCommonFieldEmailProps) { + return ( + ( + + {label} + + + + + + )} + /> + ); +} diff --git a/src/components/Auth/common/AuthCommonFieldNewPassword.tsx b/src/components/Auth/common/AuthCommonFieldNewPassword.tsx new file mode 100644 index 00000000..f8626fb2 --- /dev/null +++ b/src/components/Auth/common/AuthCommonFieldNewPassword.tsx @@ -0,0 +1,54 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import InputPassword from "@/components/ui/InputPassword"; +import { InputHTMLAttributes, RefObject } from "react"; +import { Control, FieldValues, Path } from "react-hook-form"; + +interface AuthCommonFieldNewPasswordProps + extends Omit, "name" | "type"> { + control: Control; + name: Path; + label?: string; + inputRef?: RefObject; + submittedWithErrors?: boolean; +} + +export function AuthCommonFieldNewPassword({ + control, + name, + label = "Password", + inputRef, + submittedWithErrors = false, + ...props +}: AuthCommonFieldNewPasswordProps) { + return ( + ( + + {label} + + + + + + )} + /> + ); +} diff --git a/src/components/Auth/common/AuthCommonFieldOrganizationURL.tsx b/src/components/Auth/common/AuthCommonFieldOrganizationURL.tsx new file mode 100644 index 00000000..1f482e67 --- /dev/null +++ b/src/components/Auth/common/AuthCommonFieldOrganizationURL.tsx @@ -0,0 +1,84 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { APP_DOMAIN_STRIPPED } from "@/constants"; +import { cn } from "@/lib/utils"; +import mergeRefs from "merge-refs"; +import { InputHTMLAttributes, RefObject, useRef } from "react"; +import { Control, FieldValues, Path } from "react-hook-form"; + +interface AuthCommonFieldOrganizationURLProps + extends Omit, "name" | "type"> { + control: Control; + name: Path; + label?: string; + inputRef?: RefObject; + placeholder?: string; +} + +export function AuthCommonFieldOrganizationURL({ + control, + name, + label = "Enter your Organization URL", + inputRef, + placeholder = "acme-inc", + className = "", + ...props +}: AuthCommonFieldOrganizationURLProps) { + const internalRef = useRef(null); + const prefix = APP_DOMAIN_STRIPPED; + + return ( + ( + + {label} + +
internalRef.current?.focus()} + role="presentation" + > + {prefix && ( + + )} + +
+
+ +
+ )} + /> + ); +} diff --git a/src/components/Auth/common/AuthCommonFieldPassword.tsx b/src/components/Auth/common/AuthCommonFieldPassword.tsx new file mode 100644 index 00000000..836c8a6e --- /dev/null +++ b/src/components/Auth/common/AuthCommonFieldPassword.tsx @@ -0,0 +1,76 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import InputPassword from "@/components/ui/InputPassword"; +import { InputHTMLAttributes, RefObject } from "react"; +import { Control, FieldValues, Path, useFormContext } from "react-hook-form"; +import { Link } from "react-router-dom"; + +interface AuthCommonFieldPasswordProps + extends Omit, "name" | "type"> { + control: Control; + name: Path; + label?: string; + inputRef?: RefObject; + newPasswordChecks?: boolean; + showValidationErrors?: boolean; + withForgotPassword?: boolean; +} + +export function AuthCommonFieldPassword({ + control, + name, + label = "Password", + inputRef, + newPasswordChecks, + showValidationErrors, + withForgotPassword = false, + ...props +}: AuthCommonFieldPasswordProps) { + const { getValues } = useFormContext(); + + return ( + ( + +
+ {label} + {withForgotPassword && ( + + Forgot your password? + + )} +
+ + + + +
+ )} + /> + ); +} diff --git a/src/components/Auth/common/AuthCommonSection.tsx b/src/components/Auth/common/AuthCommonSection.tsx new file mode 100644 index 00000000..e21d6232 --- /dev/null +++ b/src/components/Auth/common/AuthCommonSection.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from "react"; + +interface AuthCommonSectionProps { + title: ReactNode; + description: ReactNode; + children: ReactNode; + ariaLabel?: string; +} + +export function AuthCommonSection({ + title, + description, + children, + ariaLabel, +}: AuthCommonSectionProps) { + return ( +
+
+

{title}

+

+ {description} +

+
+ {children} +
+ ); +} diff --git a/src/components/Auth/common/AuthCommonSubmitButton.tsx b/src/components/Auth/common/AuthCommonSubmitButton.tsx new file mode 100644 index 00000000..bd72d0ed --- /dev/null +++ b/src/components/Auth/common/AuthCommonSubmitButton.tsx @@ -0,0 +1,42 @@ +import { Button } from "@/components/ui/button"; +import { LucideLoader } from "lucide-react"; +import { ReactNode, RefObject } from "react"; + +interface AuthCommonSubmitButtonProps { + isLoading: boolean; + text: ReactNode; + className?: string; + buttonRef?: RefObject; + disabled?: boolean; + onClick?: () => void; + tabIndex?: number; +} + +export function AuthCommonSubmitButton({ + isLoading, + text, + className = "", + buttonRef, + disabled = false, + onClick, + tabIndex, +}: AuthCommonSubmitButtonProps) { + return ( + + ); +} diff --git a/src/components/Auth/common/authCommonErrorHandlers.ts b/src/components/Auth/common/authCommonErrorHandlers.ts new file mode 100644 index 00000000..f7ecc5d6 --- /dev/null +++ b/src/components/Auth/common/authCommonErrorHandlers.ts @@ -0,0 +1,112 @@ +import { AxiosError } from "axios"; +import { ApiHttpError } from "@/types"; + +/** + * Common error types that can occur during authentication flows + */ +type AuthErrorType = + 'validation' | // Field-specific validation failures + 'authentication' | // Auth-specific failures like invalid credentials + 'api' | // Other API-returned errors + 'network' | // Connection issues + 'unknown'; // Unrecognized errors + +/** + * Standardized error structure for auth flows + */ +interface AuthError { + type: AuthErrorType; + message: string; + field?: string; + originalError?: AxiosError; +} + +/** + * Common error messages used across auth flows + */ +export const AUTH_ERROR_MESSAGES = { + INVALID_CREDENTIALS: "Invalid email or password", + PASSWORD_RESET_REQUIRED: "You need to reset your password to continue", + NETWORK_ERROR: "Network error. Please check your connection", + UNEXPECTED_ERROR: "An unexpected error occurred", + TENANT_TAKEN: "Organization URL already taken. Choose another one.", + TENANT_NOT_FOUND: "Organization not found", + MISSING_REQUIRED_FIELDS: "Please fill in all required fields", + INVALID_PASSWORD_FORMAT: "Invalid special character. Use only: _!@#$%^&*()-.", +} as const; + +/** + * Detects specific known error patterns from API responses + */ +function identifyAuthErrorDetails(error: unknown): { + type: AuthErrorType; + message: string; + field?: string; +} { + if (!(error instanceof AxiosError)) { + return { + type: 'unknown', + message: AUTH_ERROR_MESSAGES.UNEXPECTED_ERROR + }; + } + + if (!error.response) { + return { + type: 'network', + message: AUTH_ERROR_MESSAGES.NETWORK_ERROR + }; + } + + if (error.response.status === 401) { + return { + type: 'authentication', + message: AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS + }; + } + + const apiErrorMessage = error.response?.data?.errors?.error; + if (typeof apiErrorMessage !== 'string') { + return { + type: 'unknown', + message: AUTH_ERROR_MESSAGES.UNEXPECTED_ERROR + }; + } + + if (apiErrorMessage.includes('tenant name already exists')) { + return { + type: 'validation', + message: AUTH_ERROR_MESSAGES.TENANT_TAKEN, + field: 'organizationURL' + }; + } + + if (apiErrorMessage.includes("Field validation for 'Password' failed on the 'password_regex' tag")) { + return { + type: 'validation', + message: AUTH_ERROR_MESSAGES.INVALID_PASSWORD_FORMAT, + field: 'password' + }; + } + + return { + type: 'api', + message: apiErrorMessage + }; +} + +/** + * Creates a standardized auth error object with intelligent error detection + */ +export function createAuthError( + error: unknown, + field?: string +): AuthError { + const errorDetails = identifyAuthErrorDetails(error); + + return { + type: errorDetails.type, + message: errorDetails.message, + field: field || errorDetails.field, + originalError: error instanceof AxiosError ? error : undefined + }; +} \ No newline at end of file diff --git a/src/components/Auth/common/authCommonZodSchemas.ts b/src/components/Auth/common/authCommonZodSchemas.ts new file mode 100644 index 00000000..ab8106f5 --- /dev/null +++ b/src/components/Auth/common/authCommonZodSchemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// Export each schema individually to avoid temporal dead zone issues +export const passwordMinLengthValue = 12; +export const regexIsUppercase = /(?=.*[A-Z])/; +export const regexIsNumber = /(?=.*[0-9])/; +export const regexIsSpecialCharacter = /(?=.*[_!@#$%^&*()-])/; +export const regexAllowedPasswordCharacters = /[a-zA-Z0-9_!@#$%^&*()-]/; + +export const regexPasswordPattern = new RegExp( + `^${regexIsUppercase.source}${regexIsNumber.source}${regexIsSpecialCharacter.source}${regexAllowedPasswordCharacters.source}{${passwordMinLengthValue},}$` +); + +// Export individual schemas directly to avoid initialization issues +export const organizationNameSchema = z + .string() + .min(1, "Cannot be empty"); + +export const nameSchema = z + .string() + .min(1, "Cannot be empty"); + +export const organizationURLSchema = z + .string() + .min(1, "Cannot be empty.") + .regex(/^[a-z0-9-]+$/, "Organization URL may only contain lowercase letters, numbers, and dashes."); + +export const emailSchema = z + .string() + .email("Invalid email address."); + +export const newPasswordSchema = z + .string() + .min(passwordMinLengthValue, `Password must be at least ${passwordMinLengthValue} characters.`) + .regex(regexIsUppercase, "Password must contain at least one uppercase letter.") + .regex(regexIsNumber, "Password must contain at least one number.") + .regex(regexIsSpecialCharacter, "Password must contain at least one special character (_!@#$%^&*()-)."); + +export const existingPasswordSchema = z + .string() + .min(1, "Cannot be empty."); + +// Export the object containing all schemas for backward compatibility +// This references the individually exported schemas to avoid duplication +export const authCommonZodSchemas = { + organizationName: organizationNameSchema, + name: nameSchema, + organizationURL: organizationURLSchema, + email: emailSchema, + newPassword: newPasswordSchema, + existingPassword: existingPasswordSchema, +}; diff --git a/src/components/Auth/fields/NewPasswordField.tsx b/src/components/Auth/fields/NewPasswordField.tsx deleted file mode 100644 index e5b32433..00000000 --- a/src/components/Auth/fields/NewPasswordField.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useState, forwardRef } from "react"; -import { useFormContext } from "react-hook-form"; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { LucideCheckSquare, LucideSquare, LucideEye, LucideEyeOff } from "lucide-react"; -import { regexPasswordPattern, passwordMinLengthValue, regexIsUppercase, regexIsNumber, regexIsSpecialCharacter } from "./zValidations"; -import { Button } from "@/components/ui/button"; - -interface NewPasswordFieldProps { - submittedWithErrors: boolean; -} - -const NewPasswordField = forwardRef( - ({ submittedWithErrors }, ref) => { - const { getValues, setValue, control } = useFormContext(); - const [password, setPassword] = useState(getValues("password")); - const [passwordVisible, setPasswordVisible] = useState(false); - const [passwordValidations, setPasswordValidations] = useState({ - length: password?.length >= passwordMinLengthValue, - uppercase: regexIsUppercase.test(password), - number: regexIsNumber.test(password), - specialChar: regexIsSpecialCharacter.test(password), - }); - - useEffect(() => { - setPasswordValidations({ - length: password?.length >= passwordMinLengthValue, - uppercase: regexIsUppercase.test(password), - number: regexIsNumber.test(password), - specialChar: regexIsSpecialCharacter.test(password), - }); - }, [password]); - - const handlePasswordChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setPassword(value); - setValue("password", value); - }; - - const togglePasswordVisibility = () => { - setPasswordVisible(!passwordVisible); - }; - - return ( - ( - - Password - -
- - -
-
-
    -
  • - {passwordValidations.uppercase ? ( - - ) : ( - - )} - At least one uppercase letter -
  • -
  • - {passwordValidations.number ? ( - - ) : ( - - )} - At least one number -
  • -
  • - {passwordValidations.specialChar ? ( - - ) : ( - - )} - At least one special character -
  • -
  • - {passwordValidations.length ? ( - - ) : ( - - )} - At least {passwordMinLengthValue} characters -
  • -
- -
- )} - /> - ); - } -); - -NewPasswordField.displayName = 'NewPasswordField'; - -export default NewPasswordField; diff --git a/src/components/Auth/fields/zValidations.ts b/src/components/Auth/fields/zValidations.ts deleted file mode 100644 index 478e4e1c..00000000 --- a/src/components/Auth/fields/zValidations.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod"; - -export const passwordMinLengthValue = 12; -export const regexIsUppercase = /(?=.*[A-Z])/; -export const regexIsNumber = /(?=.*[0-9])/; -export const regexIsSpecialCharacter = /(?=.*[_!@#$%^&*()-])/; -export const regexAllowedPasswordCharacters = /[a-zA-Z0-9_!@#$%^&*()-]/; - -export const regexPasswordPattern = new RegExp( - `^${regexIsUppercase.source}${regexIsNumber.source}${regexIsSpecialCharacter.source}${regexAllowedPasswordCharacters.source}{${passwordMinLengthValue},}$` -); - -const zValidations = { - organizationName: z - .string() - .min(1, "Cannot be empty"), - name: z.string().min(1, "Cannot be empty"), - organizationURL: z - .string() - .min(1, "Cannot be empty.") - .regex(/^[a-z0-9-]+$/, "Organization URL may only contain lowercase letters, numbers, and dashes."), - email: z.string().email("Invalid email address."), - newPassword: z - .string() - .min(passwordMinLengthValue, `Password must be at least ${passwordMinLengthValue} characters.`) - .regex(regexIsUppercase, "Password must contain at least one uppercase letter.") - .regex(regexIsNumber, "Password must contain at least one number.") - .regex(regexIsSpecialCharacter, "Password must contain at least one special character (_!@#$%^&*()-)."), - existingPassword: z.string().min(1, "Cannot be empty."), -} - -export default zValidations; \ No newline at end of file diff --git a/src/components/Auth/forgotPassword/AuthForgotPassword.tsx b/src/components/Auth/forgotPassword/AuthForgotPassword.tsx new file mode 100644 index 00000000..892ab00f --- /dev/null +++ b/src/components/Auth/forgotPassword/AuthForgotPassword.tsx @@ -0,0 +1,97 @@ +import { + AuthCommonFieldEmail, + AuthCommonFieldOrganizationURL, + AuthCommonSection, + AuthCommonSubmitButton, + useAuthForgotPasswordFlow, +} from "@/components/Auth"; +import { Button } from "@/components/ui/button"; +import { Form } from "@/components/ui/form"; +import { LucideArrowLeft } from "lucide-react"; +import { useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; + +export function AuthForgotPassword() { + const location = useLocation(); + const state = location.state as { organizationURL?: string; email?: string }; + const defaultTenant = state?.organizationURL || ""; + const defaultEmail = state?.email || ""; + + const { + form, + handleSubmit, + formError, + isLoading, + emailInputRef, + tenantInputRef, + submitButtonRef, + } = useAuthForgotPasswordFlow(defaultTenant, defaultEmail); + + useEffect( + function setInitialFormFieldFocus() { + if (!defaultTenant) return tenantInputRef.current?.focus(); + if (!defaultEmail) return emailInputRef.current?.focus(); + submitButtonRef.current?.focus(); + }, + [ + location, + defaultTenant, + defaultEmail, + emailInputRef, + tenantInputRef, + submitButtonRef, + ] + ); + + return ( + +
+ + + + + + + + {formError &&

{formError}

} + + +
+ ); +} diff --git a/src/components/Auth/forgotPassword/useAuthForgotPasswordFlow.ts b/src/components/Auth/forgotPassword/useAuthForgotPasswordFlow.ts new file mode 100644 index 00000000..2a581731 --- /dev/null +++ b/src/components/Auth/forgotPassword/useAuthForgotPasswordFlow.ts @@ -0,0 +1,98 @@ +import { authCommonZodSchemas, createAuthError } from "@/components/Auth"; +import useForgotPassword from "@/services/forgotPassword/mutations/useForgotPassword"; +import { ApiHttpError } from "@/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosError } from "axios"; +import { useCallback, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const ForgotPasswordSchema = z.object({ + tenant: authCommonZodSchemas.organizationURL, + email: authCommonZodSchemas.email, +}); + +type ForgotPasswordData = z.infer; + +export function useAuthForgotPasswordFlow(defaultTenant: string = "", defaultEmail: string = "") { + const [formError, setFormError] = useState(""); + const toastIdRef = useRef(null); + const emailInputRef = useRef(null); + const tenantInputRef = useRef(null); + const submitButtonRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(ForgotPasswordSchema), + defaultValues: { + email: defaultEmail, + tenant: defaultTenant, + }, + }); + + const { mutate: forgotPassword, isPending: isLoading } = useForgotPassword({ + onSuccess: () => { + + toastIdRef.current = toast.success('Check your email', { + description: 'If an account matching your details was found, instructions to reset your password have been sent.', + duration: Infinity, + cancel: { + label: 'Dismiss', + onClick: () => { }, + }, + }); + setFormError(""); + }, + onError: (error: AxiosError) => { + console.error("Forgot password request encountered an error:", error); + const authError = createAuthError(error); + + // Determine if this error signifies a "user/org not found" scenario or similar + // which should be masked with a generic success message for security reasons. + const responseData = error.response?.data as Partial | undefined; + const specificApiError = responseData?.errors?.error; + + const shouldMaskError = error instanceof AxiosError && + error.response && + ( + error.response.status === 401 || // Standard "Unauthorized" often implies not found here + error.response.status === 404 || // Standard "Not Found" + (error.response.status === 422 && specificApiError === 'invalid username/password') // Current backend specific case + ); + + if (shouldMaskError) { + // For "not found" type errors, show the generic success toast to prevent enumeration + toastIdRef.current = toast.success('Check your email', { + description: 'If an account matching your details was found, instructions to reset your password have been sent.', + duration: Infinity, + cancel: { label: 'Dismiss', onClick: () => { } }, + }); + setFormError(""); + } else { + setFormError(authError.message); + } + } + }); + + const handleSubmit = useCallback((values: ForgotPasswordData) => { + setFormError(""); + forgotPassword(values); + }, [forgotPassword]); + + const handleDismissToast = useCallback(() => { + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + }, []); + + return { + form, + handleSubmit, + formError, + isLoading, + emailInputRef, + tenantInputRef, + submitButtonRef, + handleDismissToast + }; +} \ No newline at end of file diff --git a/src/components/Auth/index.ts b/src/components/Auth/index.ts index 327e1055..8ba2d5df 100644 --- a/src/components/Auth/index.ts +++ b/src/components/Auth/index.ts @@ -1,2 +1,56 @@ -export * from './Auth'; -export { default } from './Auth'; \ No newline at end of file +// Common +export { + emailSchema, + existingPasswordSchema, + nameSchema, + newPasswordSchema, + organizationNameSchema, + organizationURLSchema, + passwordMinLengthValue, + regexAllowedPasswordCharacters, + regexIsNumber, + regexIsSpecialCharacter, + regexIsUppercase, + regexPasswordPattern, + authCommonZodSchemas, +} from './common/authCommonZodSchemas'; + +export { + AUTH_ERROR_MESSAGES, + createAuthError, +} from './common/authCommonErrorHandlers'; + +export { AuthCommonFieldEmail } from './common/AuthCommonFieldEmail'; +export { AuthCommonFieldNewPassword } from './common/AuthCommonFieldNewPassword'; +export { AuthCommonFieldOrganizationURL } from './common/AuthCommonFieldOrganizationURL'; +export { AuthCommonFieldPassword } from './common/AuthCommonFieldPassword'; +export { AuthCommonSection } from './common/AuthCommonSection'; +export { AuthCommonSubmitButton } from './common/AuthCommonSubmitButton'; + +// Root Level +export { AuthLayout } from './AuthLayout'; +export { AuthRedirectGuard } from './AuthRedirectGuard'; + +// Login +export { AuthLogin } from './login/AuthLogin'; +export { AuthLoginForm } from './login/AuthLoginForm'; +export { AuthLoginStepCredentials } from './login/AuthLoginStepCredentials'; +export { AuthLoginStepOrganizationURL } from './login/AuthLoginStepOrganizationURL'; +export { useAuthLoginFlow } from './login/useAuthLoginFlow'; +export { useAuthLoginFormContext } from './login/AuthLoginForm'; + +// Signup +export { AuthSignup } from './signup/AuthSignup'; +export { AuthSignupForm } from './signup/AuthSignupForm'; +export { AuthSignupStepCredentials } from './signup/AuthSignupStepCredentials'; +export { AuthSignupStepOrganizationInfo } from './signup/AuthSignupStepOrganizationInfo'; +export { useAuthSignupFlow } from './signup/useAuthSignupFlow'; +export { useAuthSignupFormContext } from './signup/AuthSignupForm'; + +// Forgot Password +export { AuthForgotPassword } from './forgotPassword/AuthForgotPassword'; +export { useAuthForgotPasswordFlow } from './forgotPassword/useAuthForgotPasswordFlow'; + +// Reset Password +export { AuthResetPassword } from './resetPassword/AuthResetPassword'; +export { useAuthResetPasswordFlow } from './resetPassword/useAuthResetPasswordFlow'; diff --git a/src/components/Auth/login/AuthLogin.tsx b/src/components/Auth/login/AuthLogin.tsx new file mode 100644 index 00000000..b40e44f8 --- /dev/null +++ b/src/components/Auth/login/AuthLogin.tsx @@ -0,0 +1,46 @@ +import { AuthCommonSection, AuthLoginForm } from "@/components/Auth"; +import { APP_DOMAIN_STRIPPED } from "@/constants"; +import { useState } from "react"; + +export function AuthLogin() { + const [stepId, setStepId] = useState("organizationURL"); + const [orgUrl, setOrgUrl] = useState(""); + + const handleStepChange = (newStepId: string) => { + setStepId(newStepId); + }; + + const handleOrganizationURLChange = (newOrgUrl: string) => { + setOrgUrl(newOrgUrl); + }; + + const title = + stepId === "credentials" && orgUrl ? ( + <>You're logging in on + ) : ( + <>Log in to an Organization + ); + + const description = + stepId === "credentials" && orgUrl ? ( + + {APP_DOMAIN_STRIPPED}/ + {orgUrl} + + ) : ( + <>Welcome back! + ); + + return ( + + + + ); +} diff --git a/src/components/Auth/login/AuthLoginForm.tsx b/src/components/Auth/login/AuthLoginForm.tsx new file mode 100644 index 00000000..0c7195ab --- /dev/null +++ b/src/components/Auth/login/AuthLoginForm.tsx @@ -0,0 +1,149 @@ +import { + authCommonZodSchemas, + AuthLoginStepCredentials, + AuthLoginStepOrganizationURL, + useAuthLoginFlow, +} from "@/components/Auth"; +import { Form } from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import { Step, defineStepper } from "@stepperize/react"; +import { createContext, FC, useContext } from "react"; +import { Link } from "react-router-dom"; +import { UseFormReturn } from "react-hook-form"; +import { z } from "zod"; + +const OrganizationURLSchema = z.object({ + organizationURL: authCommonZodSchemas.organizationURL, +}); +const CredentialsSchema = z.object({ + email: authCommonZodSchemas.email, + password: authCommonZodSchemas.existingPassword, +}); + +const step1 = { + id: "organizationURL", + label: "Organization URL", + schema: OrganizationURLSchema, +}; +const step2 = { + id: "credentials", + label: "Credentials", + schema: CredentialsSchema, +}; +const { Scoped } = defineStepper(step1, step2); + +type LoginFormShape = z.infer & + z.infer; + +interface AuthLoginFormContextType { + form: UseFormReturn; + currentStep: Step; + stepperMethods: { + prev: () => void; + next: () => void; + switch: (cases: Record T>) => T; + current: Step; + }; + isProcessing: boolean; + handleAttemptSubmit: (data: LoginFormShape) => void; + formError: string | null; +} + +const AuthLoginFormContext = createContext< + AuthLoginFormContextType | undefined +>(undefined); + +export function useAuthLoginFormContext() { + const context = useContext(AuthLoginFormContext); + if (!context) { + throw new Error( + "useAuthLoginFormContext must be used within a AuthLoginForm provider" + ); + } + return context; +} + +const AuthLoginFormContent: FC = () => { + const { form, stepperMethods, handleAttemptSubmit, formError } = + useAuthLoginFormContext(); + + return ( + <> +
+ + {stepperMethods.switch({ + organizationURL: () => , + credentials: () => , + })} + {formError &&

{formError}

} + + + +
+ Don't have an Organization yet?{" "} + + Sign up for one + +
+ + ); +}; + +interface AuthLoginFormProps { + /** + * Callback function invoked when the login flow progresses to a new step. + * This function is called by `AuthLoginForm` to notify its parent (`AuthLogin`) + * about changes to the current step, allowing the parent to update its UI accordingly. + * @param step The identifier of the new current step (e.g., "organizationURL", "credentials"). + */ + onStepChange: (step: string) => void; + /** + * Callback function invoked when the organization URL is successfully entered and validated. + * This function is called by `AuthLoginForm` to notify its parent (`AuthLogin`) + * of the validated organization URL, allowing the parent to update its UI or state. + * @param tenantId The validated organization URL (tenant identifier). + */ + onOrganizationURLChange: (tenantId: string) => void; +} + +// 9. Main Exported Component (which now also acts as the Provider) +export function AuthLoginForm(props: AuthLoginFormProps) { + const { + form, + stepperMethods, + handleAttemptSubmit, + formError, + isLoadingCoreFlow, + } = useAuthLoginFlow(props.onStepChange, props.onOrganizationURLChange); + + const currentStep = stepperMethods.current; + + const isProcessing = isLoadingCoreFlow; + + const contextValue = { + form, + currentStep, + stepperMethods, + isProcessing, + handleAttemptSubmit, + formError, + }; + + return ( + + + + + + ); +} diff --git a/src/components/Auth/login/AuthLoginStepCredentials.tsx b/src/components/Auth/login/AuthLoginStepCredentials.tsx new file mode 100644 index 00000000..37ab2c65 --- /dev/null +++ b/src/components/Auth/login/AuthLoginStepCredentials.tsx @@ -0,0 +1,58 @@ +import { trackLoginStepMovedBack } from "@/analytics"; +import { + AuthCommonFieldEmail, + AuthCommonFieldPassword, + AuthCommonSubmitButton, + useAuthLoginFormContext, +} from "@/components/Auth"; +import { Button } from "@/components/ui/button"; +import { LucideArrowLeft } from "lucide-react"; +import { useFormContext } from "react-hook-form"; + +export function AuthLoginStepCredentials() { + const { control } = useFormContext(); + const { + isProcessing, + stepperMethods, + } = useAuthLoginFormContext(); + const { prev } = stepperMethods; + + return ( + <> + + + + + + ); +} diff --git a/src/components/Auth/login/AuthLoginStepOrganizationURL.tsx b/src/components/Auth/login/AuthLoginStepOrganizationURL.tsx new file mode 100644 index 00000000..a87a66da --- /dev/null +++ b/src/components/Auth/login/AuthLoginStepOrganizationURL.tsx @@ -0,0 +1,29 @@ +import { + AuthCommonFieldOrganizationURL, + AuthCommonSubmitButton, + useAuthLoginFormContext, +} from "@/components/Auth"; +import { useFormContext } from "react-hook-form"; + +export function AuthLoginStepOrganizationURL() { + const { control } = useFormContext(); + const { isProcessing } = useAuthLoginFormContext(); + + return ( + <> + + + + ); +} diff --git a/src/components/Auth/login/useAuthLoginFlow.ts b/src/components/Auth/login/useAuthLoginFlow.ts new file mode 100644 index 00000000..87ebc719 --- /dev/null +++ b/src/components/Auth/login/useAuthLoginFlow.ts @@ -0,0 +1,208 @@ +import { trackLoginStepCompleted, trackSignedIn, trackLoginStepMovedBack } from "@/analytics"; +import { AUTH_ERROR_MESSAGES, authCommonZodSchemas, createAuthError } from "@/components/Auth"; +import type { LoginResult } from "@/services/auth/mutations/useLoginAndIdentifyUser"; +import useLoginAndIdentifyUser from "@/services/auth/mutations/useLoginAndIdentifyUser"; +import type { LoginAndIdentifyParams } from "@/services/auth/Auth.interfaces"; +import { isTenantRegistered, isTenantAvailable, useGetTenantByName } from "@/services/tenants/queries/useGetTenantByName"; +import { ApiHttpError } from "@/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { defineStepper, Step } from "@stepperize/react"; +import { AxiosError } from "axios"; +import { useCallback, useEffect, useState } from "react"; +import useSignIn from "react-auth-kit/hooks/useSignIn"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { toast } from "sonner"; + +const OrganizationURLSchema = z.object({ + organizationURL: authCommonZodSchemas.organizationURL, +}); + +const CredentialsSchema = z.object({ + email: authCommonZodSchemas.email, + password: authCommonZodSchemas.existingPassword, +}); + +type OrgURLData = z.infer; +type CredentialsData = z.infer; +type LoginData = OrgURLData & CredentialsData; + +const loginSteps = [ + { id: "organizationURL", label: "Organization URL", schema: OrganizationURLSchema }, + { id: "credentials", label: "Credentials", schema: CredentialsSchema } +]; + +const { useStepper } = defineStepper(...loginSteps); + +export function useAuthLoginFlow( + onStepChange: (stepId: string) => void, + onOrganizationURLChange: (tenantId: string) => void +) { + const [formError, setFormError] = useState(null); + const [shouldCheckTenant, setShouldCheckTenant] = useState(false); + const [tenantNameToCheckQuery, setTenantNameToCheckQuery] = useState(""); + + const authKitSignIn = useSignIn(); + const navigate = useNavigate(); + const stepperMethods = useStepper(); + const { next, isLast, current: currentStep, all: steps, prev } = stepperMethods; + + const form = useForm({ + resolver: zodResolver(currentStep.schema), + defaultValues: { + organizationURL: "", + email: "", + password: "", + }, + }); + + const { + data: tenantCheckResult, + error: tenantError, + status: tenantStatus, + isFetching: isCheckingTenant + } = useGetTenantByName( + tenantNameToCheckQuery, + { + enabled: shouldCheckTenant && !!tenantNameToCheckQuery, + throwOnError: false + } + ); + + const { mutate: loginAndIdentifyUser, isPending: isLoginPending } = useLoginAndIdentifyUser({ + onError: (error: AxiosError, variables: LoginAndIdentifyParams) => { + const authError = createAuthError(error); + + if (error.response?.status === 403 && authError.message?.toLowerCase() === "password reset required") { + toast.info(AUTH_ERROR_MESSAGES.PASSWORD_RESET_REQUIRED); + navigate('/reset-password', { + state: { + tenant: variables.tenantSlug, + email: variables.email + } + }); + return; + } + + if (authError.type === 'validation' && authError.field) { + if (authError.field === 'email') { + form.setError('email', { type: "manual", message: authError.message }); + setTimeout(() => { form.setFocus('email'); }, 0); + return; + } + if (authError.field === 'password') { + form.setError('password', { type: "manual", message: authError.message }); + setTimeout(() => { form.setFocus('password'); }, 0); + return; + } + } + + setFormError(authError.message); + }, + onSuccess: (data: LoginResult, variables: LoginAndIdentifyParams) => { + if (data.isSignedIn) { + trackSignedIn(variables.tenantSlug); + navigate('/'); + } else { + setFormError("Login failed after identification."); + } + }, + }); + + const handleStepChange = useCallback(() => { + onStepChange(currentStep.id); + + if (currentStep.id === 'credentials') { + const formData = form.getValues(); + if (typeof formData.organizationURL === 'string') { + onOrganizationURLChange(formData.organizationURL); + } + } + + setFormError(null); + + form.reset(form.getValues(), { + keepValues: true, + keepDirty: true, + keepErrors: false, + keepTouched: false, + keepIsSubmitted: false, + keepIsValid: false, + keepSubmitCount: false + }); + }, [currentStep.id, onStepChange, onOrganizationURLChange, form]); + + useEffect(() => { + handleStepChange(); + }, [handleStepChange]); + + useEffect(() => { + if (!shouldCheckTenant || isCheckingTenant) { + return; + } + + if (tenantStatus === 'success') { + if (isTenantRegistered(tenantCheckResult)) { + trackLoginStepCompleted(steps.findIndex((s: Step) => s.id === currentStep.id) + 1); + next(); + } else if (isTenantAvailable(tenantCheckResult)) { + form.setError("organizationURL", { + type: "manual", + message: AUTH_ERROR_MESSAGES.TENANT_NOT_FOUND.replace("Organization", `Organization '${tenantNameToCheckQuery}'`) + }); + setTimeout(() => { form.setFocus("organizationURL"); }, 0); + } + } else if (tenantStatus === 'error') { + const authError = createAuthError(tenantError); + setFormError(authError.message); + } + + setShouldCheckTenant(false); + }, [tenantStatus, tenantCheckResult, tenantError, shouldCheckTenant, steps, currentStep.id, next, form, tenantNameToCheckQuery, isCheckingTenant]); + + const handleAttemptSubmit = useCallback(async (data: Partial) => { + setFormError(null); + + if (currentStep.id === 'organizationURL') { + const tenantName = data.organizationURL; + if (!tenantName) { + form.trigger("organizationURL"); + return; + } + setTenantNameToCheckQuery(tenantName); + setShouldCheckTenant(true); + } else if (currentStep.id === 'credentials' && isLast) { + const formData = { ...form.getValues(), ...data }; + if (!formData.email || !formData.password || !formData.organizationURL) { + setFormError(AUTH_ERROR_MESSAGES.MISSING_REQUIRED_FIELDS); + return; + } + + loginAndIdentifyUser({ + email: formData.email, + password: formData.password, + tenantSlug: String(formData.organizationURL), + authKitSignIn, + }); + } else if (!isLast) { + next(); + } + }, [currentStep, isLast, next, form, loginAndIdentifyUser, authKitSignIn, setTenantNameToCheckQuery, setShouldCheckTenant]); + + const handleGoBack = useCallback(() => { + prev(); + trackLoginStepMovedBack(); + }, [prev]); + + const isLoadingCoreFlow = isLoginPending || isCheckingTenant; + + return { + form, + stepperMethods, + handleAttemptSubmit, + handleGoBack, + formError, + isLoadingCoreFlow, + }; +} \ No newline at end of file diff --git a/src/components/Auth/resetPassword/AuthResetPassword.tsx b/src/components/Auth/resetPassword/AuthResetPassword.tsx new file mode 100644 index 00000000..f12ed267 --- /dev/null +++ b/src/components/Auth/resetPassword/AuthResetPassword.tsx @@ -0,0 +1,111 @@ +import { + AuthCommonFieldEmail, + AuthCommonFieldNewPassword, + AuthCommonFieldOrganizationURL, + AuthCommonSection, + AuthCommonSubmitButton, + useAuthResetPasswordFlow, +} from "@/components/Auth"; +import { Form } from "@/components/ui/form"; +import { useEffect } from "react"; +import { useLocation, useSearchParams, Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { LucideArrowLeft } from "lucide-react"; + +export function AuthResetPassword() { + const [searchParams] = useSearchParams(); + const location = useLocation(); + + const token = searchParams.get("token") || ""; + + const stateData = location.state as + | { tenant?: string; email?: string } + | undefined; + const tenantFromState = stateData?.tenant; + const emailFromState = stateData?.email; + + const { + form, + handleSubmit, + handleError, + formError, + isLoading, + submittedWithErrors, + newPasswordRef, + tenantInputRef, + emailInputRef, + } = useAuthResetPasswordFlow({ + defaultTenant: tenantFromState || "", + defaultEmail: emailFromState || "", + token, + }); + + useEffect( + function setInitialFormFieldFocus() { + if (!tenantFromState && !emailFromState) + return tenantInputRef.current?.focus(); + newPasswordRef.current?.focus(); + }, + [tenantFromState, emailFromState, newPasswordRef, tenantInputRef] + ); + + return ( + +
+ + + + + + + + + {formError &&

{formError}

} + + +
+ ); +} diff --git a/src/components/Auth/resetPassword/useAuthResetPasswordFlow.ts b/src/components/Auth/resetPassword/useAuthResetPasswordFlow.ts new file mode 100644 index 00000000..6f7eb0ab --- /dev/null +++ b/src/components/Auth/resetPassword/useAuthResetPasswordFlow.ts @@ -0,0 +1,92 @@ +import { authCommonZodSchemas, createAuthError } from "@/components/Auth"; +import useResetPassword from "@/services/forgotPassword/mutations/useResetPassword"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AxiosError } from "axios"; +import { useCallback, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { z } from "zod"; + +const ResetPasswordSchema = z.object({ + tenant: authCommonZodSchemas.organizationURL, + email: authCommonZodSchemas.email, + token: z.string(), + password: authCommonZodSchemas.newPassword, +}); + +type ResetPasswordData = z.infer; +type ResetPasswordField = keyof ResetPasswordData; + +interface UseResetPasswordFlowProps { + defaultTenant?: string; + defaultEmail?: string; + token?: string; +} + +export function useAuthResetPasswordFlow({ + defaultTenant = "", + defaultEmail = "", + token = "" +}: UseResetPasswordFlowProps = {}) { + const navigate = useNavigate(); + const [formError, setFormError] = useState(""); + const [submittedWithErrors, setSubmittedWithErrors] = useState(false); + + const newPasswordRef = useRef(null); + const tenantInputRef = useRef(null); + const emailInputRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues: { + tenant: defaultTenant, + email: defaultEmail, + token: token, + password: "", + }, + }); + + const { mutate: resetPassword, isPending: isLoading } = useResetPassword({ + onSuccess: () => { + toast.success('Password updated successfully', { description: 'Please log in' }); + navigate('/login'); + }, + onError: (error: AxiosError) => { + const authError = createAuthError(error); + + if (authError.type === 'validation' && authError.field) { + form.setError(authError.field as ResetPasswordField, { + type: "manual", + message: authError.message + }); + return; + } + + setFormError(authError.message); + } + }); + + const handleSubmit = useCallback((data: ResetPasswordData) => { + setSubmittedWithErrors(false); + setFormError(""); + resetPassword(data); + }, [resetPassword]); + + const handleError = useCallback(() => { + setSubmittedWithErrors(true); + setFormError("Please check the form for errors"); + }, []); + + return { + form, + handleSubmit, + handleError, + formError, + isLoading, + submittedWithErrors, + newPasswordRef, + tenantInputRef, + emailInputRef + }; +} \ No newline at end of file diff --git a/src/components/Auth/signup/AuthSignup.tsx b/src/components/Auth/signup/AuthSignup.tsx new file mode 100644 index 00000000..dcf4d69e --- /dev/null +++ b/src/components/Auth/signup/AuthSignup.tsx @@ -0,0 +1,12 @@ +import { AuthCommonSection, AuthSignupForm } from "@/components/Auth"; +export function AuthSignup() { + return ( + + + + ); +} diff --git a/src/components/Auth/signup/AuthSignupForm.tsx b/src/components/Auth/signup/AuthSignupForm.tsx new file mode 100644 index 00000000..e4f0c897 --- /dev/null +++ b/src/components/Auth/signup/AuthSignupForm.tsx @@ -0,0 +1,137 @@ +import { + authCommonZodSchemas, + AuthSignupStepCredentials, + AuthSignupStepOrganizationInfo, + useAuthSignupFlow, +} from "@/components/Auth"; +import { Form } from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import { Step, defineStepper } from "@stepperize/react"; +import { createContext, FC, useContext } from "react"; +import { Link } from "react-router-dom"; +import { UseFormReturn } from "react-hook-form"; +import { z } from "zod"; + +const OrganizationInfoSchema = z.object({ + organizationName: authCommonZodSchemas.organizationName, + name: authCommonZodSchemas.name, + organizationURL: authCommonZodSchemas.organizationURL, +}); +const CredentialsSchema = z.object({ + email: authCommonZodSchemas.email, + password: authCommonZodSchemas.newPassword, +}); + +const step1 = { + id: "organizationInfo", + label: "Organization Info", + schema: OrganizationInfoSchema, +}; +const step2 = { + id: "credentials", + label: "Credentials", + schema: CredentialsSchema, +}; +const { Scoped } = defineStepper(step1, step2); + +type SignupFormShape = z.infer & + z.infer; + +interface AuthSignupFormContextType { + form: UseFormReturn; + currentStep: Step; + stepperMethods: { + prev: () => void; + next: () => void; + switch: (cases: Record T>) => T; + current: Step; + isLast: boolean; + isFirst: boolean; + }; + isProcessing: boolean; + handleAttemptSubmit: (data: SignupFormShape) => void; + handleGoBack: () => void; + formError: string | null; +} + +const AuthSignupFormContext = createContext< + AuthSignupFormContextType | undefined +>(undefined); + +export function useAuthSignupFormContext() { + const context = useContext(AuthSignupFormContext); + if (!context) { + throw new Error( + "useAuthSignupFormContext must be used within an AuthSignupForm provider" + ); + } + return context; +} + +const AuthSignupFormContent: FC = () => { + const { form, stepperMethods, handleAttemptSubmit, formError } = + useAuthSignupFormContext(); + + return ( + <> +
+ + {stepperMethods.switch({ + organizationInfo: () => , + credentials: () => , + })} + {formError &&

{formError}

} + + + +
+ Already a member of an Organization?{" "} + + Log in + +
+ + ); +}; + +export function AuthSignupForm() { + const { + form, + stepperMethods, + handleAttemptSubmit, + handleGoBack, + formError, + isLoadingCoreFlow, + } = useAuthSignupFlow(); + + const currentStep = stepperMethods.current; + const isProcessing = isLoadingCoreFlow; + + const contextValue = { + form, + currentStep, + stepperMethods, + isProcessing, + handleAttemptSubmit, + handleGoBack, + formError, + }; + + return ( + + + + + + ); +} diff --git a/src/components/Auth/signup/AuthSignupStepCredentials.tsx b/src/components/Auth/signup/AuthSignupStepCredentials.tsx new file mode 100644 index 00000000..99b5a1ca --- /dev/null +++ b/src/components/Auth/signup/AuthSignupStepCredentials.tsx @@ -0,0 +1,53 @@ +import { + AuthCommonFieldEmail, + AuthCommonFieldNewPassword, + AuthCommonSubmitButton, + useAuthSignupFormContext, +} from "@/components/Auth"; +import { Button } from "@/components/ui/button"; +import { LucideArrowLeft } from "lucide-react"; +import { useRef } from "react"; +import { useFormContext } from "react-hook-form"; + +export function AuthSignupStepCredentials() { + const { control } = useFormContext(); + const { isProcessing, handleGoBack } = useAuthSignupFormContext(); + const emailRef = useRef(null); + + return ( + <> + + + + + + + + + ); +} diff --git a/src/components/Auth/signup/AuthSignupStepOrganizationInfo.tsx b/src/components/Auth/signup/AuthSignupStepOrganizationInfo.tsx new file mode 100644 index 00000000..1ffb5561 --- /dev/null +++ b/src/components/Auth/signup/AuthSignupStepOrganizationInfo.tsx @@ -0,0 +1,102 @@ +import { + AuthCommonFieldOrganizationURL, + AuthCommonSubmitButton, + useAuthSignupFormContext, +} from "@/components/Auth"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { createSlug } from "@/utils/slugify"; +import { useRef, useState } from "react"; +import { useFormContext } from "react-hook-form"; + +export function AuthSignupStepOrganizationInfo() { + const { control, setValue } = useFormContext(); + const { isProcessing } = useAuthSignupFormContext(); + const orgURLRef = useRef(null); + const [isURLManuallyEdited, setIsURLManuallyEdited] = useState(false); + + const handleOrganizationNameChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + setValue("organizationName", value); + if (!isURLManuallyEdited) { + setValue("organizationURL", createSlug(value)); + } + }; + + const handleOrganizationURLFocus = () => { + setIsURLManuallyEdited(true); + }; + + return ( + <> + ( + + Your Full Name + + + + + + )} + /> + ( + + Organization Name + + { + handleOrganizationNameChange(e); + field.onChange(e); + }} + /> + + + + )} + /> + + + + + + ); +} diff --git a/src/components/Auth/signup/useAuthSignupFlow.ts b/src/components/Auth/signup/useAuthSignupFlow.ts new file mode 100644 index 00000000..cf83606f --- /dev/null +++ b/src/components/Auth/signup/useAuthSignupFlow.ts @@ -0,0 +1,214 @@ +import { trackSignedIn, trackSignupStepCompleted, trackSignupStepMovedBack } from "@/analytics"; +import { AUTH_ERROR_MESSAGES, authCommonZodSchemas, createAuthError } from "@/components/Auth"; +import { LoginAndIdentifyParams, SignupPayload } from "@/services/auth/Auth.interfaces"; +import type { LoginResult } from "@/services/auth/mutations/useLoginAndIdentifyUser"; +import useLoginAndIdentifyUser from "@/services/auth/mutations/useLoginAndIdentifyUser"; +import useSignup from "@/services/auth/mutations/useSignup"; +import { isTenantRegistered, isTenantAvailable, useGetTenantByName } from "@/services/tenants/queries/useGetTenantByName"; +import { ApiHttpError } from "@/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { defineStepper, Step } from "@stepperize/react"; +import { AxiosError } from "axios"; +import { useCallback, useEffect, useState } from "react"; +import useSignIn from "react-auth-kit/hooks/useSignIn"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { z } from "zod"; + +const MAX_LOGIN_RETRIES = 4; + +const OrganizationInfoSchema = z.object({ + organizationName: authCommonZodSchemas.organizationName, + name: authCommonZodSchemas.name, + organizationURL: authCommonZodSchemas.organizationURL, +}); + +const CredentialsSchema = z.object({ + email: authCommonZodSchemas.email, + password: authCommonZodSchemas.newPassword, +}); + +type OrgInfoData = z.infer; +type CredentialsData = z.infer; +type SignupData = OrgInfoData & CredentialsData; + +const signupSteps = [ + { id: "organizationInfo", label: "Organization Info", schema: OrganizationInfoSchema }, + { id: "credentials", label: "Credentials", schema: CredentialsSchema } +]; + +const { useStepper } = defineStepper(...signupSteps); + +export function useAuthSignupFlow() { + const [formError, setFormError] = useState(null); + const [shouldCheckTenant, setShouldCheckTenant] = useState(false); + const [tenantNameToCheckQuery, setTenantNameToCheckQuery] = useState(""); + const authKitSignIn = useSignIn(); + const navigate = useNavigate(); + const stepperMethods = useStepper(); + const { next, prev, isLast, current: currentStep, all: steps } = stepperMethods; + + const form = useForm({ + resolver: zodResolver(currentStep.schema), + defaultValues: { + organizationName: "", + name: "", + organizationURL: "", + email: "", + password: "", + }, + }); + + const { + data: tenantCheckResult, + error: tenantError, + status: tenantStatus, + isFetching: isCheckingTenant + } = useGetTenantByName( + tenantNameToCheckQuery, + { + enabled: shouldCheckTenant && !!tenantNameToCheckQuery, + throwOnError: false + } + ); + + const { mutate: loginAndIdentifyUser, isPending: isLoginPending } = useLoginAndIdentifyUser({ + onSuccess: (data: LoginResult, variables: LoginAndIdentifyParams) => { + if (data.isSignedIn) { + trackSignedIn(variables.tenantSlug); + navigate('/'); + } else { + toast.success('Organization created, but login failed. Please try logging in manually.'); + navigate('/login'); + } + }, + retry: (failureCount) => { + if (failureCount < MAX_LOGIN_RETRIES) return true; + + toast.success('Organization created successfully', { + description: 'You can now log in with your credentials', + }); + navigate('/login'); + return false; + } + }); + + const { mutate: signup, isPending: isSignupPending } = useSignup({ + onError: (error: AxiosError) => { + const authError = createAuthError(error); + + if (authError.type === 'validation' && authError.field) { + if (authError.field === 'password') { + form.setError('password', { + type: "manual", + message: authError.message + }); + setTimeout(() => { form.setFocus('password'); }, 0); + return; + } + } + + setFormError(authError.message); + }, + onSuccess: (_, variables: SignupPayload) => { + trackSignupStepCompleted(steps.findIndex((s: Step) => s.id === currentStep.id) + 1, variables.tenant); + loginAndIdentifyUser({ + email: variables.email, + password: variables.password, + tenantSlug: variables.tenant, + name: variables.name, + authKitSignIn, + }); + }, + }); + + const handleStepChange = useCallback(() => { + setFormError(null); + form.reset(form.getValues(), { + keepValues: true, + keepDirty: true, + keepErrors: false, + keepTouched: false, + keepIsSubmitted: false, + keepIsValid: false, + keepSubmitCount: false + }); + }, [form]); + + useEffect(() => { + handleStepChange(); + }, [handleStepChange]); + + useEffect(() => { + if (!shouldCheckTenant || isCheckingTenant) { + return; + } + + if (tenantStatus === 'success') { + if (isTenantRegistered(tenantCheckResult)) { + form.setError("organizationURL", { + type: "manual", + message: AUTH_ERROR_MESSAGES.TENANT_TAKEN + }); + setTimeout(() => { form.setFocus("organizationURL"); }, 0); + } else if (isTenantAvailable(tenantCheckResult)) { + trackSignupStepCompleted(steps.findIndex((s: Step) => s.id === currentStep.id) + 1, form.getValues("organizationName")); + next(); + } + } else if (tenantStatus === 'error') { + const authError = createAuthError(tenantError); + setFormError(authError.message); + } + + setShouldCheckTenant(false); + + }, [tenantStatus, tenantCheckResult, tenantError, shouldCheckTenant, steps, currentStep.id, next, form, tenantNameToCheckQuery, isCheckingTenant]); + + const handleAttemptSubmit = useCallback(async (data: Partial) => { + setFormError(null); + + if (currentStep.id === 'organizationInfo') { + const tenantName = form.getValues("organizationURL"); + + if (!tenantName) { + form.trigger("organizationURL"); + return; + } + + setTenantNameToCheckQuery(tenantName); + setShouldCheckTenant(true); + + } else if (currentStep.id === 'credentials' && isLast) { + const formData = { ...form.getValues(), ...data }; + + if (!formData.email || !formData.password || !formData.name || !formData.organizationName || !formData.organizationURL) { + setFormError(AUTH_ERROR_MESSAGES.MISSING_REQUIRED_FIELDS); + return; + } + + signup({ + email: formData.email, + name: formData.name, + password: formData.password, + tenant: formData.organizationURL + }); + } + }, [currentStep.id, isLast, form, signup]); + + const handleGoBack = useCallback(() => { + prev(); + trackSignupStepMovedBack(); + }, [prev]); + + const isLoadingCoreFlow = isLoginPending || isSignupPending || isCheckingTenant; + + return { + form, + stepperMethods, + handleAttemptSubmit, + handleGoBack, + formError, + isLoadingCoreFlow, + }; +} \ No newline at end of file diff --git a/src/components/NavigateWithOrg.tsx b/src/components/NavigateWithOrg.tsx index b440e14d..1b3be5f8 100644 --- a/src/components/NavigateWithOrg.tsx +++ b/src/components/NavigateWithOrg.tsx @@ -3,55 +3,49 @@ import { Navigate, NavigateProps } from 'react-router-dom'; import useIsAuthenticated from 'react-auth-kit/hooks/useIsAuthenticated'; import useAuthUser from 'react-auth-kit/hooks/useAuthUser'; import { IUserData } from '@/types'; -import Auth from '@/components/Auth'; interface NavigateWithOrgProps extends NavigateProps { children?: React.ReactNode; to: string; - fallbackToLogin?: boolean; - fallbackToSignup?: boolean; replace?: boolean; } -const NavigateWithOrg: React.FC = ({ children, to, fallbackToLogin = false, fallbackToSignup = false, replace = false, ...navigateProps }) => { +/** + * Redirects authenticated users to their org-specific path. + * Renders children if the user is not authenticated (though this component + * is typically used within routes already protected by authentication checks). + */ +const NavigateWithOrg: React.FC = ({ children, to, replace = false, ...navigateProps }) => { const isAuthenticated = useIsAuthenticated(); const authUser = useAuthUser(); const [redirectTo, setRedirectTo] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { + let targetPath: string | null = null; if (isAuthenticated) { const user = authUser; const organizationURL = user?.tenant; if (organizationURL) { - setRedirectTo(`/${organizationURL}${to}`); + targetPath = `/${organizationURL}${to.startsWith('/') ? to : `/${to}`}`; } else { - console.warn('Tenant information is missing.'); - setRedirectTo(null); + console.error('NavigateWithOrg: User is authenticated but tenant information is missing.'); } - } else { - setRedirectTo(null); } + + setRedirectTo(targetPath); setLoading(false); }, [isAuthenticated, authUser, to]); if (loading) { - return null; // TODO: Use a proper loader or nah? + return null; } if (redirectTo) { return ; } - if (!isAuthenticated && fallbackToLogin) { - return ; - } - - if (!isAuthenticated && fallbackToSignup) { - return ; - } - - return <>{children}; // Render children if no redirection is needed + return <>{children}; }; export default NavigateWithOrg; \ No newline at end of file diff --git a/src/components/Settings/UserDialogForm.tsx b/src/components/Settings/UserDialogForm.tsx index 7aa3eb9d..e42aa64f 100644 --- a/src/components/Settings/UserDialogForm.tsx +++ b/src/components/Settings/UserDialogForm.tsx @@ -21,7 +21,7 @@ import { FormMessage, } from "@/components/ui/form"; import { MultiSelect } from "@/components/ui/MultiSelect"; -import NewPasswordField from "../Auth/fields/NewPasswordField"; +import InputPassword from "@/components/ui/InputPassword"; const getSchema = (selectedUserId: string | null) => { return z.object({ @@ -102,9 +102,27 @@ const UserDialogForm = ({ selectedUserId, userForm, onSubmit, onCancel }: { )} /> - {!selectedUserId && - - } + {!selectedUserId && ( + ( + + Password + + + + + + )} + /> + )} {Boolean(selectedUserId) && , 'type'> { + /** + * Enable strong password validation with visual feedback + */ + newPasswordChecks?: boolean; + /** + * Whether the form has been submitted with errors + */ + showValidationErrors?: boolean; +} + +const InputPassword = forwardRef(({ + newPasswordChecks, + showValidationErrors = false, + className = "", + value = "", + onChange, + ...props +}, ref) => { + const [isVisible, setIsVisible] = useState(false); + const [validations, setValidations] = useState({ + hasUppercase: regexIsUppercase.test(value as string), + hasNumber: regexIsNumber.test(value as string), + hasSpecialChar: regexIsSpecialCharacter.test(value as string), + meetsMinLength: (value as string)?.length >= passwordMinLengthValue + }); + + useEffect(() => { + if (newPasswordChecks) { + setValidations({ + hasUppercase: regexIsUppercase.test(value as string), + hasNumber: regexIsNumber.test(value as string), + hasSpecialChar: regexIsSpecialCharacter.test(value as string), + meetsMinLength: (value as string)?.length >= passwordMinLengthValue + }); + } + }, [value, newPasswordChecks]); + + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e); + }; + + return ( +
+
+ + +
+ + {newPasswordChecks && ( +
    + + + + +
+ )} +
+ ); +}); + +interface ValidationRequirementProps { + isValid: boolean; + showError: boolean; + text: string; +} + +function ValidationRequirement({ isValid, showError, text }: ValidationRequirementProps) { + return ( +
  • + {isValid ? ( + + ) : ( + + )} + {text} +
  • + ); +} + +InputPassword.displayName = "InputPassword"; + +export default InputPassword; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 07c1bb17..32a4e3b5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,72 +1,78 @@ import ListAgents from "@/components/agents/ListAgents"; import AppPageHeader from "@/components/AppPageHeader"; -import ForgotPassword from "@/components/Auth/ForgotPassword"; -import ResetPassword from "@/components/Auth/ResetPassword"; +import AuditWrapper from "@/components/audit/AuditWrapper"; +import { + AuthForgotPassword, + AuthLayout, + AuthLogin, + AuthRedirectGuard, + AuthResetPassword, + AuthSignup, +} from "@/components/Auth"; import AxiosInterceptor from "@/components/AxiosInterceptor"; +import BodyPortal from "@/components/BodyPortal"; import NavigateWithOrg from "@/components/NavigateWithOrg"; import { NotFound } from "@/components/NotFound"; import RequireActiveUser from "@/components/RequireActiveUser"; +import ListRotators from "@/components/rotators/ListRotators"; import Settings from "@/components/Settings"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { Loader } from "@/components/ui/Loader"; import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { Verify } from "@/components/Verify"; -import authStore from "@/services/auth/authStore"; -import RequireAuth from '@auth-kit/react-router/RequireAuth'; -import { useEffect, StrictMode, Suspense } from 'react'; -import AuthProvider from 'react-auth-kit'; -import { createRoot } from 'react-dom/client'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { load, page } from './analytics'; -import App from './App'; -import './index.css'; import { DOCS_DOMAIN, IS_PROD } from "@/constants"; -import ListRotators from "@/components/rotators/ListRotators"; +import { FeatureFlagProvider } from "@/context/FeatureFlagContext"; +import { LayoutProvider } from "@/context/LayoutContext"; +import { SubscriptionProvider } from "@/context/SubscriptionContext"; +import authStore from "@/services/auth/authStore"; +import RequireAuth from "@auth-kit/react-router/RequireAuth"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import AuditWrapper from "@/components/audit/AuditWrapper"; +import { StrictMode, Suspense, useEffect } from "react"; +import AuthProvider from "react-auth-kit"; +import { createRoot } from "react-dom/client"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { load, page } from "./analytics"; +import App from "./App"; import OrgRedirector from "./components/OrgRedirector"; -import { Loader } from "@/components/ui/Loader"; -import { SubscriptionProvider } from '@/context/SubscriptionContext'; -import { FeatureFlagProvider } from '@/context/FeatureFlagContext'; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { LayoutProvider } from '@/context/LayoutContext'; -import BodyPortal from '@/components/BodyPortal'; +import "./index.css"; -const queryClient = new QueryClient() +const queryClient = new QueryClient(); const router = createBrowserRouter([ { - path: '/', + path: "/", element: ( - + ), }, { path: "/verify", - element: - - - }, - { - path: '/signup', - element: , - }, - { - path: '/login', - element: , - }, - { - path: '/forgot-password', - element: , + element: ( + + + + ), }, { - path: '/reset-password', - element: , + element: , + children: [ + { + element: , + children: [ + { path: "/signup", element: }, + { path: "/login", element: }, + ], + }, + { path: "/forgot-password", element: }, + { path: "/reset-password", element: }, + ], }, { - path: '/:org', + path: "/:org", element: ( @@ -82,11 +88,11 @@ const router = createBrowserRouter([ ), children: [ { - path: '', + path: "", element: , }, { - path: 'agents', + path: "agents", element: ( <> - ) + ), }, { - path: 'rotators', + path: "rotators", element: ( <> - ) + ), }, // TODO: Remove mock variable when audit is ready https://github.com/external-secrets-inc/web-ui/issues/124 import.meta.env.VITE_MOCK_AUDIT_ROUTE ? { @@ -140,7 +146,7 @@ const router = createBrowserRouter([ ) } : {}, { - path: 'settings', + path: "settings", element: ( <> - ) + ), }, ], }, { - path: '*', + path: "*", element: , }, ]); @@ -180,7 +186,7 @@ const Main = () => { return ; }; -const rootElement = document.getElementById('root'); +const rootElement = document.getElementById("root"); if (rootElement) { createRoot(rootElement).render( diff --git a/src/services/auth/mutations/useLoginAndIdentifyUser.ts b/src/services/auth/mutations/useLoginAndIdentifyUser.ts index 20e7b848..cb61e03f 100644 --- a/src/services/auth/mutations/useLoginAndIdentifyUser.ts +++ b/src/services/auth/mutations/useLoginAndIdentifyUser.ts @@ -1,53 +1,63 @@ import { LoginAndIdentifyParams } from "@/services/auth/Auth.interfaces"; import { performLogin } from "@/services/auth/mutations/usePerformLogin"; import { getUserData } from "@/services/users/queries/useGetUserData"; -import { ApiHttpError } from "@/types"; +import { ApiHttpError, IUserData } from "@/types"; import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { AxiosError } from "axios"; +export interface LoginResult { + isSignedIn: boolean; + userState: IUserData; +} + const buildUserState = ({ email, name, userDetails, tenantId, - tenant, + tenantSlug, userId, }: { email: string; name?: string; userDetails: Awaited>; tenantId: string; - tenant: unknown; + tenantSlug: string; userId: string; -}) => { - return { - email, - name: name || userDetails.name, - isActive: userDetails.is_active, - tenantId, - tenant, - userId, - }; -}; +}): IUserData => ({ + email, + name: name || userDetails.name, + isActive: userDetails.is_active, + tenantId, + tenant: tenantSlug, + organizationURL: tenantSlug, + userId, +}); const signInUser = ( authKitSignIn: LoginAndIdentifyParams["authKitSignIn"], token: string, - userState: Record -) => { - return authKitSignIn({ - auth: { - token, - type: "Bearer", - }, - userState, - }); -}; + userState: IUserData +): boolean => authKitSignIn({ + auth: { + token, + type: "Bearer", + }, + userState, +}); -const identifyUserWithAnalytics = (userState: Record) => { - const { userId, email, name, ...segmentUserState } = userState; // eslint-disable-line @typescript-eslint/no-unused-vars +const identifyUserWithAnalytics = (userState: IUserData): void => { + const { userId, organizationURL, tenantId } = userState; + const traits = { + organizationURL, + tenantId, + }; try { - analytics.identify(userId as string, segmentUserState); + if (typeof analytics !== 'undefined' && analytics.identify) { + analytics.identify(userId, traits); + } else { + console.warn("Segment analytics.identify not available."); + } } catch (error) { console.error("Segment identify call failed:", error); } @@ -59,39 +69,38 @@ const loginAndIdentifyUser = async ({ tenantSlug, name, authKitSignIn, -}: LoginAndIdentifyParams): Promise => { - const { token, tenantId, tenant, userId } = await performLogin({email, password, tenant: tenantSlug}); - const userDetails = await getUserData(userId!, token); - +}: LoginAndIdentifyParams): Promise => { + const { token, tenantId, tenant: returnedTenantSlug, userId } = await performLogin({ email, password, tenant: tenantSlug }); + + if (!userId) { + throw new Error("Login failed: No user ID returned"); + } + + const userDetails = await getUserData(userId, token); const userState = buildUserState({ email, name, userDetails, tenantId, - tenant, - userId: userId!, + tenantSlug: returnedTenantSlug, + userId, }); - + const isSignedIn = signInUser(authKitSignIn, token, userState); - + if (isSignedIn) { identifyUserWithAnalytics(userState); - return true; } - - return false; -}; + return { isSignedIn, userState }; +}; const useLoginAndIdentifyUser = ( - options?: Omit, LoginAndIdentifyParams>, 'mutationKey' | 'mutationFn'> -) => { - - return useMutation({ - mutationKey: ["useLoginAndIdentifyUser"], - mutationFn: (variables: LoginAndIdentifyParams) => loginAndIdentifyUser(variables), - ...options, - }); -}; + options?: Omit, LoginAndIdentifyParams>, 'mutationKey' | 'mutationFn'> +) => useMutation({ + mutationKey: ["auth", "useLoginAndIdentifyUser"], + mutationFn: loginAndIdentifyUser, + ...options, +}); export default useLoginAndIdentifyUser; diff --git a/src/services/tenants/queries/useGetTenantByName.ts b/src/services/tenants/queries/useGetTenantByName.ts new file mode 100644 index 00000000..307dddb5 --- /dev/null +++ b/src/services/tenants/queries/useGetTenantByName.ts @@ -0,0 +1,53 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import axiosInstance from "@/services/axiosConfig"; +import { isAxiosError } from "axios"; +import { ApiHttpError } from "@/types"; + +export interface TenantDetails { + name: string; +} + +export interface TenantValidationResult { + exists: boolean; + details?: TenantDetails; +} + +const validateTenantName = async (signal: AbortSignal, tenantName: string): Promise => { + try { + const response = await axiosInstance.get( + `/public/tenants/${encodeURIComponent(tenantName)}`, + { signal } + ); + + return { + exists: true, + details: response.data + }; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return { exists: false }; + } + throw error; + } +}; + +export const useGetTenantByName = ( + tenantName: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: ['auth', 'useGetTenantByName', tenantName] as const, + queryFn: ({ signal }) => validateTenantName(signal, tenantName), + retry: (failureCount) => failureCount < 3, + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + +export const isTenantRegistered = (result: TenantValidationResult | undefined): boolean => { + return !!result?.exists; +}; + +export const isTenantAvailable = (result: TenantValidationResult | undefined): boolean => { + return result !== undefined && !result.exists; +}; \ No newline at end of file