mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-03 02:24:48 +02:00
Merge branch 'auth-providers' into dev
This commit is contained in:
commit
f4fd33b47f
93 changed files with 5788 additions and 1608 deletions
292
package-lock.json
generated
292
package-lock.json
generated
|
@ -32,14 +32,18 @@
|
|||
"@react-email/components": "0.0.36",
|
||||
"@react-email/render": "^1.0.6",
|
||||
"@react-email/tailwind": "1.0.4",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"arctic": "^3.6.0",
|
||||
"axios": "1.8.4",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"eslint": "9.17.0",
|
||||
|
@ -51,7 +55,9 @@
|
|||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.469.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.2.4",
|
||||
|
@ -71,7 +77,7 @@
|
|||
"semver": "7.6.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "1.1.2",
|
||||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
|
@ -87,7 +93,9 @@
|
|||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/react": "19.1.1",
|
||||
|
@ -101,7 +109,7 @@
|
|||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "4.0.6",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5",
|
||||
|
@ -2756,6 +2764,21 @@
|
|||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oslojs/jwt": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz",
|
||||
"integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oslojs/encoding": "0.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz",
|
||||
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||
|
@ -4048,6 +4071,18 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
|
||||
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mini-svg-data-uri": "^1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
|
||||
|
@ -4061,6 +4096,13 @@
|
|||
"tailwindcss": "4.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
||||
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
|
||||
|
@ -4285,6 +4327,13 @@
|
|||
"tailwindcss": "4.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
||||
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||
|
@ -4418,6 +4467,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jmespath": {
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
|
||||
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
|
@ -4437,6 +4493,17 @@
|
|||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
@ -4444,6 +4511,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
|
@ -5111,6 +5185,17 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/arctic": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/arctic/-/arctic-3.6.0.tgz",
|
||||
"integrity": "sha512-egHDsCqEacb6oSHz5QSSxNhp07J+QJwJdPvs0katL+mNM5LaGQVqxmcdq1KwfaSNSAlVumBBs0MRExS88TxbMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@oslojs/jwt": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
|
@ -5552,6 +5637,12 @@
|
|||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
|
@ -5986,12 +6077,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
|
@ -6007,12 +6098,34 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookies": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"keygrip": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
|
@ -6988,6 +7101,15 @@
|
|||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eciesjs": {
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz",
|
||||
|
@ -7073,6 +7195,16 @@
|
|||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
|
@ -9361,6 +9493,15 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jmespath": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
@ -9422,6 +9563,28 @@
|
|||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
|
@ -9437,6 +9600,39 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keygrip": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tsscmp": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
@ -9746,12 +9942,54 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||
|
@ -9995,6 +10233,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-svg-data-uri": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
@ -15615,20 +15862,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
||||
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
|
@ -15841,6 +16079,15 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsscmp": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
|
||||
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.x"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.19.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
||||
|
@ -15873,6 +16120,15 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tw-animate-css": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz",
|
||||
"integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Wombosvideo"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
12
package.json
12
package.json
|
@ -43,14 +43,18 @@
|
|||
"@react-email/components": "0.0.36",
|
||||
"@react-email/render": "^1.0.6",
|
||||
"@react-email/tailwind": "1.0.4",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"arctic": "^3.6.0",
|
||||
"axios": "1.8.4",
|
||||
"better-sqlite3": "11.7.0",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"eslint": "9.17.0",
|
||||
|
@ -62,7 +66,9 @@
|
|||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"jmespath": "^0.16.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.469.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.2.4",
|
||||
|
@ -82,7 +88,7 @@
|
|||
"semver": "7.6.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "1.1.2",
|
||||
"winston": "3.17.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
|
@ -98,7 +104,9 @@
|
|||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/react": "19.1.1",
|
||||
|
@ -112,7 +120,7 @@
|
|||
"esbuild-node-externals": "1.18.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "4.0.6",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "^5",
|
||||
|
|
|
@ -6,6 +6,9 @@ import createHttpError from "http-errors";
|
|||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
listOrgs = "listOrgs",
|
||||
listUserOrgs = "listUserOrgs",
|
||||
createOrg = "createOrg",
|
||||
// deleteOrg = "deleteOrg",
|
||||
getOrg = "getOrg",
|
||||
|
@ -65,7 +68,16 @@ export enum ActionsEnum {
|
|||
listResourceRules = "listResourceRules",
|
||||
updateResourceRule = "updateResourceRule",
|
||||
listOrgDomains = "listOrgDomains",
|
||||
createNewt = "createNewt"
|
||||
createNewt = "createNewt",
|
||||
createIdp = "createIdp",
|
||||
updateIdp = "updateIdp",
|
||||
deleteIdp = "deleteIdp",
|
||||
listIdps = "listIdps",
|
||||
getIdp = "getIdp",
|
||||
createIdpOrg = "createIdpOrg",
|
||||
deleteIdpOrg = "deleteIdpOrg",
|
||||
listIdpOrgs = "listIdpOrgs",
|
||||
updateIdpOrg = "updateIdpOrg"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
|
@ -111,8 +111,14 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||
|
||||
export const users = sqliteTable("user", {
|
||||
userId: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
email: text("email"),
|
||||
username: text("username").notNull(),
|
||||
name: text("name"),
|
||||
type: text("type").notNull(), // "internal", "oidc"
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
passwordHash: text("passwordHash"),
|
||||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
@ -420,6 +426,38 @@ export const supporterKey = sqliteTable("supporterKey", {
|
|||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
// Identity Providers
|
||||
export const idp = sqliteTable("idp", {
|
||||
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
defaultRoleMapping: text("defaultRoleMapping"),
|
||||
defaultOrgMapping: text("defaultOrgMapping"),
|
||||
autoProvision: integer("autoProvision", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
// Identity Provider OAuth Configuration
|
||||
export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
clientId: text("clientId").notNull(),
|
||||
clientSecret: text("clientSecret").notNull(),
|
||||
authUrl: text("authUrl").notNull(),
|
||||
tokenUrl: text("tokenUrl").notNull(),
|
||||
identifierPath: text("identifierPath").notNull(),
|
||||
emailPath: text("emailPath"),
|
||||
namePath: text("namePath"),
|
||||
scopes: text("scopes").notNull()
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
@ -455,3 +493,4 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
|||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
|
|
|
@ -91,7 +91,19 @@ const configSchema = z.object({
|
|||
credentials: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
trust_proxy: z.boolean().optional().default(true)
|
||||
trust_proxy: z.boolean().optional().default(true),
|
||||
secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_SECRET"))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(
|
||||
32,
|
||||
"SERVER_SECRET must be at least 32 characters long"
|
||||
)
|
||||
)
|
||||
}),
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
|
|
40
server/lib/crypto.ts
Normal file
40
server/lib/crypto.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import * as crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
export function encrypt(value: string, key: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final()
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString("base64"),
|
||||
encrypted.toString("base64"),
|
||||
authTag.toString("base64")
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function decrypt(encryptedValue: string, key: string): string {
|
||||
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
|
||||
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const encrypted = Buffer.from(encryptedB64, "base64");
|
||||
const authTag = Buffer.from(authTagB64, "base64");
|
||||
const keyBuffer = Buffer.from(key, "base64");
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
8
server/lib/idp/generateRedirectUrl.ts
Normal file
8
server/lib/idp/generateRedirectUrl.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import config from "@server/lib/config";
|
||||
|
||||
export function generateOidcRedirectUrl(idpId: number) {
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
const redirectPath = `/auth/idp/${idpId}/oidc/callback`;
|
||||
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
|
||||
return redirectUrl;
|
||||
}
|
|
@ -14,4 +14,5 @@ export * from "./verifyAdmin";
|
|||
export * from "./verifySetResourceUsers";
|
||||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyIsLoggedInUser";
|
||||
|
|
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
44
server/middlewares/verifyIsLoggedInUser.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyIsLoggedInUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const reqUserId =
|
||||
req.params.userId || req.body.userId || req.query.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
// allow server admins to access any user
|
||||
if (req.user?.serverAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (reqUserId !== userId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User only has access to their own account"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error checking if user has access to this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,5 +11,6 @@ export enum OpenAPITags {
|
|||
Invitation = "Invitation",
|
||||
Target = "Target",
|
||||
Rule = "Rule",
|
||||
AccessToken = "Access Token"
|
||||
AccessToken = "Access Token",
|
||||
Idp = "Identity Provider"
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import logger from "@server/logger";
|
|||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const changePasswordBody = z
|
||||
.object({
|
||||
|
@ -50,6 +51,15 @@ export async function changePassword(
|
|||
const { newPassword, oldPassword, code } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (newPassword === oldPassword) {
|
||||
return next(
|
||||
|
@ -62,7 +72,7 @@ export async function changePassword(
|
|||
|
||||
const validPassword = await verifyPassword(
|
||||
oldPassword,
|
||||
user.passwordHash
|
||||
user.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
|
|
|
@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
|
|||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||
import config from "@server/lib/config";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const disable2faBody = z
|
||||
.object({
|
||||
|
@ -47,8 +48,17 @@ export async function disable2fa(
|
|||
const { password, code } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
@ -99,11 +109,11 @@ export async function disable2fa(
|
|||
|
||||
sendEmail(
|
||||
TwoFactorAuthNotification({
|
||||
email: user.email,
|
||||
email: user.email!, // email is not null because we are checking user.type
|
||||
enabled: false
|
||||
}),
|
||||
{
|
||||
to: user.email,
|
||||
to: user.email!,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
subject: "Two-factor authentication disabled"
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import db from "@server/db";
|
|||
import { users } from "@server/db/schemas";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
|
@ -17,6 +17,7 @@ import config from "@server/lib/config";
|
|||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
|
@ -69,7 +70,9 @@ export async function login(
|
|||
const existingUserRes = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||
);
|
||||
if (!existingUserRes || !existingUserRes.length) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
logger.info(
|
||||
|
@ -88,7 +91,7 @@ export async function login(
|
|||
|
||||
const validPassword = await verifyPassword(
|
||||
password,
|
||||
existingUser.passwordHash
|
||||
existingUser.passwordHash!
|
||||
);
|
||||
if (!validPassword) {
|
||||
if (config.getRawConfig().app.log_failed_attempts) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
|
|||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export type RequestEmailVerificationCodeResponse = {
|
||||
codeSent: boolean;
|
||||
|
@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
|
|||
try {
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Email verification is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -37,7 +47,7 @@ export async function requestEmailVerificationCode(
|
|||
);
|
||||
}
|
||||
|
||||
await sendEmailVerificationCode(user.email, user.userId);
|
||||
await sendEmailVerificationCode(user.email!, user.userId);
|
||||
|
||||
return response<RequestEmailVerificationCodeResponse>(res, {
|
||||
data: {
|
||||
|
|
|
@ -74,7 +74,7 @@ export async function requestPasswordReset(
|
|||
|
||||
await trx.insert(passwordResetTokens).values({
|
||||
userId: existingUser[0].userId,
|
||||
email: existingUser[0].email,
|
||||
email: existingUser[0].email!,
|
||||
tokenHash,
|
||||
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
|
|||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const requestTotpSecretBody = z
|
||||
.object({
|
||||
|
@ -46,8 +47,17 @@ export async function requestTotpSecret(
|
|||
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||
if (!validPassword) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
@ -63,7 +73,7 @@ export async function requestTotpSecret(
|
|||
|
||||
const hex = crypto.getRandomValues(new Uint8Array(20));
|
||||
const secret = encodeHex(hex);
|
||||
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
|
||||
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
|
|
|
@ -8,7 +8,7 @@ import createHttpError from "http-errors";
|
|||
import response from "@server/lib/response";
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import {
|
||||
createSession,
|
||||
|
@ -21,6 +21,7 @@ import logger from "@server/logger";
|
|||
import { hashPassword } from "@server/auth/password";
|
||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const signupBodySchema = z.object({
|
||||
email: z
|
||||
|
@ -110,7 +111,9 @@ export async function signup(
|
|||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email));
|
||||
.where(
|
||||
and(eq(users.email, email), eq(users.type, UserType.Internal))
|
||||
);
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
if (!config.getRawConfig().flags?.require_email_verification) {
|
||||
|
@ -157,6 +160,8 @@ export async function signup(
|
|||
|
||||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
type: UserType.Internal,
|
||||
username: email,
|
||||
email: email,
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString()
|
||||
|
|
|
@ -14,6 +14,7 @@ import logger from "@server/logger";
|
|||
import { sendEmail } from "@server/emails";
|
||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export const verifyTotpBody = z
|
||||
.object({
|
||||
|
@ -48,6 +49,15 @@ export async function verifyTotp(
|
|||
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Two-factor authentication is not supported for external users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
@ -111,11 +121,11 @@ export async function verifyTotp(
|
|||
|
||||
sendEmail(
|
||||
TwoFactorAuthNotification({
|
||||
email: user.email,
|
||||
email: user.email!,
|
||||
enabled: true
|
||||
}),
|
||||
{
|
||||
to: user.email,
|
||||
to: user.email!,
|
||||
from: config.getRawConfig().email?.no_reply,
|
||||
subject: "Two-factor authentication enabled"
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import * as auth from "./auth";
|
|||
import * as role from "./role";
|
||||
import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import * as idp from "./idp";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyAccessTokenAccess,
|
||||
|
@ -24,7 +25,8 @@ import {
|
|||
verifySetResourceUsers,
|
||||
verifyUserAccess,
|
||||
getUserOrgs,
|
||||
verifyUserIsServerAdmin
|
||||
verifyUserIsServerAdmin,
|
||||
verifyIsLoggedInUser
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
@ -46,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
|
|||
|
||||
authenticated.get("/org/checkId", org.checkId);
|
||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
|
||||
|
||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
|
@ -443,7 +448,15 @@ authenticated.delete(
|
|||
user.adminRemoveUser
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
verifyOrgAccess,
|
||||
|
@ -493,6 +506,24 @@ authenticated.delete(
|
|||
// createNewt
|
||||
// );
|
||||
|
||||
authenticated.put(
|
||||
"/idp/oidc",
|
||||
verifyUserIsServerAdmin,
|
||||
// verifyUserHasAction(ActionsEnum.createIdp),
|
||||
idp.createOidcIdp
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/idp/:idpId/oidc",
|
||||
verifyUserIsServerAdmin,
|
||||
idp.updateOidcIdp
|
||||
);
|
||||
|
||||
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||
|
||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||
|
||||
// Auth routes
|
||||
export const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
@ -582,3 +613,7 @@ authRouter.post(
|
|||
);
|
||||
|
||||
authRouter.post("/access-token", resource.authWithAccessToken);
|
||||
|
||||
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
|
||||
|
||||
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||
|
|
132
server/routers/idp/createOidcIdp.ts
Normal file
132
server/routers/idp/createOidcIdp.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.object({}).strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
name: z.string().nonempty(),
|
||||
clientId: z.string().nonempty(),
|
||||
clientSecret: z.string().nonempty(),
|
||||
authUrl: z.string().url(),
|
||||
tokenUrl: z.string().url(),
|
||||
identifierPath: z.string().nonempty(),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().nonempty(),
|
||||
autoProvision: z.boolean().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateIdpResponse = {
|
||||
idpId: number;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/idp/oidc",
|
||||
description: "Create an OIDC IdP.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createOidcIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
scopes,
|
||||
identifierPath,
|
||||
emailPath,
|
||||
namePath,
|
||||
name,
|
||||
autoProvision
|
||||
} = parsedBody.data;
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
const encryptedSecret = encrypt(clientSecret, key);
|
||||
const encryptedClientId = encrypt(clientId, key);
|
||||
|
||||
let idpId: number | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
const [idpRes] = await trx
|
||||
.insert(idp)
|
||||
.values({
|
||||
name,
|
||||
autoProvision,
|
||||
type: "oidc"
|
||||
})
|
||||
.returning();
|
||||
|
||||
idpId = idpRes.idpId;
|
||||
|
||||
await trx.insert(idpOidcConfig).values({
|
||||
idpId: idpRes.idpId,
|
||||
clientId: encryptedClientId,
|
||||
clientSecret: encryptedSecret,
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
scopes,
|
||||
identifierPath,
|
||||
emailPath,
|
||||
namePath
|
||||
});
|
||||
});
|
||||
|
||||
const redirectUrl = generateOidcRedirectUrl(idpId as number);
|
||||
|
||||
return response<CreateIdpResponse>(res, {
|
||||
data: {
|
||||
idpId: idpId as number,
|
||||
redirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Idp created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
89
server/routers/idp/deleteIdp.ts
Normal file
89
server/routers/idp/deleteIdp.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
idpId: z.coerce.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/idp/{idpId}",
|
||||
description: "Delete IDP.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
// Check if IDP exists
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.where(eq(idp.idpId, idpId));
|
||||
|
||||
if (!existingIdp) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"IdP not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the IDP and its related records in a transaction
|
||||
await db.transaction(async (trx) => {
|
||||
// Delete OIDC config if it exists
|
||||
await trx
|
||||
.delete(idpOidcConfig)
|
||||
.where(eq(idpOidcConfig.idpId, idpId));
|
||||
|
||||
// Delete the IDP itself
|
||||
await trx
|
||||
.delete(idp)
|
||||
.where(eq(idp.idpId, idpId));
|
||||
});
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "IdP deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
148
server/routers/idp/generateOidcUrl.ts
Normal file
148
server/routers/idp/generateOidcUrl.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as arctic from "arctic";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import cookie from "cookie";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
idpId: z.coerce.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
redirectUrl: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GenerateOidcUrlResponse = {
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export async function generateOidcUrl(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { redirectUrl: postAuthRedirectUrl } = parsedBody.data;
|
||||
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
|
||||
|
||||
if (!existingIdp) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IdP not found for the organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedScopes = existingIdp.idpOidcConfig.scopes
|
||||
.split(" ")
|
||||
.map((scope) => {
|
||||
return scope.trim();
|
||||
})
|
||||
.filter((scope) => {
|
||||
return scope.length > 0;
|
||||
});
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
const decryptedClientId = decrypt(
|
||||
existingIdp.idpOidcConfig.clientId,
|
||||
key
|
||||
);
|
||||
const decryptedClientSecret = decrypt(
|
||||
existingIdp.idpOidcConfig.clientSecret,
|
||||
key
|
||||
);
|
||||
|
||||
const redirectUrl = generateOidcRedirectUrl(idpId);
|
||||
const client = new arctic.OAuth2Client(
|
||||
decryptedClientId,
|
||||
decryptedClientSecret,
|
||||
redirectUrl
|
||||
);
|
||||
|
||||
const codeVerifier = arctic.generateCodeVerifier();
|
||||
const state = arctic.generateState();
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
existingIdp.idpOidcConfig.authUrl,
|
||||
state,
|
||||
arctic.CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
parsedScopes
|
||||
);
|
||||
|
||||
const stateJwt = jsonwebtoken.sign(
|
||||
{
|
||||
redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe
|
||||
state,
|
||||
codeVerifier
|
||||
},
|
||||
config.getRawConfig().server.secret
|
||||
);
|
||||
|
||||
res.cookie("p_oidc_state", stateJwt, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: req.protocol === "https",
|
||||
expires: new Date(Date.now() + 60 * 10 * 1000),
|
||||
sameSite: "lax"
|
||||
});
|
||||
|
||||
return response<GenerateOidcUrlResponse>(res, {
|
||||
data: {
|
||||
redirectUrl: url.toString()
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Idp auth url generated",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
97
server/routers/idp/getIdp.ts
Normal file
97
server/routers/idp/getIdp.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
idpId: z.coerce.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function query(idpId: number) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.where(eq(idp.idpId, idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type GetIdpResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/idp/{idpId}",
|
||||
description: "Get an IDP by its IDP ID.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
const idpRes = await query(idpId);
|
||||
|
||||
if (!idpRes) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found"));
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
if (idpRes.idp.type === "oidc") {
|
||||
const clientSecret = idpRes.idpOidcConfig!.clientSecret;
|
||||
const clientId = idpRes.idpOidcConfig!.clientId;
|
||||
|
||||
idpRes.idpOidcConfig!.clientSecret = decrypt(
|
||||
clientSecret,
|
||||
key
|
||||
);
|
||||
idpRes.idpOidcConfig!.clientId = decrypt(
|
||||
clientId,
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetIdpResponse>(res, {
|
||||
data: idpRes,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Idp retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
7
server/routers/idp/index.ts
Normal file
7
server/routers/idp/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export * from "./createOidcIdp";
|
||||
export * from "./updateOidcIdp";
|
||||
export * from "./deleteIdp";
|
||||
export * from "./listIdps";
|
||||
export * from "./generateOidcUrl";
|
||||
export * from "./validateOidcCallback";
|
||||
export * from "./getIdp";
|
111
server/routers/idp/listIdps.ts
Normal file
111
server/routers/idp/listIdps.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function query(limit: number, offset: number) {
|
||||
const res = await db
|
||||
.select({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
})
|
||||
.from(idp)
|
||||
.groupBy(idp.idpId)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type ListIdpsResponse = {
|
||||
idps: Array<{
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/idp",
|
||||
description: "List all IDP in the system.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
query: querySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listIdps(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const list = await query(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(idp);
|
||||
|
||||
return response<ListIdpsResponse>(res, {
|
||||
data: {
|
||||
idps: list,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Idps retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
184
server/routers/idp/updateOidcIdp.ts
Normal file
184
server/routers/idp/updateOidcIdp.ts
Normal file
|
@ -0,0 +1,184 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
idpId: z.coerce.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
authUrl: z.string().optional(),
|
||||
tokenUrl: z.string().optional(),
|
||||
identifierPath: z.string().optional(),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().optional(),
|
||||
autoProvision: z.boolean().optional(),
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateIdpResponse = {
|
||||
idpId: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/idp/:idpId/oidc",
|
||||
description: "Update an OIDC IdP.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateOidcIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
scopes,
|
||||
identifierPath,
|
||||
emailPath,
|
||||
namePath,
|
||||
name,
|
||||
autoProvision,
|
||||
defaultRoleMapping,
|
||||
defaultOrgMapping
|
||||
} = parsedBody.data;
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.where(eq(idp.idpId, idpId));
|
||||
|
||||
if (!existingIdp) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
|
||||
}
|
||||
|
||||
if (existingIdp.type !== "oidc") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IdP is not an OIDC provider"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
const encryptedSecret = clientSecret
|
||||
? encrypt(clientSecret, key)
|
||||
: undefined;
|
||||
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const idpData = {
|
||||
name,
|
||||
autoProvision,
|
||||
defaultRoleMapping,
|
||||
defaultOrgMapping
|
||||
};
|
||||
|
||||
// only update if at least one key is not undefined
|
||||
let keysToUpdate = Object.keys(idpData).filter(
|
||||
(key) => idpData[key as keyof typeof idpData] !== undefined
|
||||
);
|
||||
|
||||
if (keysToUpdate.length > 0) {
|
||||
await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
|
||||
}
|
||||
|
||||
const configData = {
|
||||
clientId: encryptedClientId,
|
||||
clientSecret: encryptedSecret,
|
||||
authUrl,
|
||||
tokenUrl,
|
||||
scopes,
|
||||
identifierPath,
|
||||
emailPath,
|
||||
namePath
|
||||
};
|
||||
|
||||
keysToUpdate = Object.keys(configData).filter(
|
||||
(key) =>
|
||||
configData[key as keyof typeof configData] !== undefined
|
||||
);
|
||||
|
||||
if (keysToUpdate.length > 0) {
|
||||
// Update OIDC config
|
||||
await trx
|
||||
.update(idpOidcConfig)
|
||||
.set(configData)
|
||||
.where(eq(idpOidcConfig.idpId, idpId));
|
||||
}
|
||||
});
|
||||
|
||||
return response<UpdateIdpResponse>(res, {
|
||||
data: {
|
||||
idpId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "IdP updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
250
server/routers/idp/validateOidcCallback.ts
Normal file
250
server/routers/idp/validateOidcCallback.ts
Normal file
|
@ -0,0 +1,250 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig, users } from "@server/db/schemas";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as arctic from "arctic";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import jmespath from "jmespath";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
createSession,
|
||||
generateSessionToken,
|
||||
serializeSessionCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { response } from "@server/lib";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
idpId: z.coerce.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z.object({
|
||||
code: z.string().nonempty(),
|
||||
state: z.string().nonempty(),
|
||||
storedState: z.string().nonempty()
|
||||
});
|
||||
|
||||
export type ValidateOidcUrlCallbackResponse = {
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export async function validateOidcCallback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { storedState, code, state: expectedState } = parsedBody.data;
|
||||
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
|
||||
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
|
||||
|
||||
if (!existingIdp) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IdP not found for the organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
const decryptedClientId = decrypt(
|
||||
existingIdp.idpOidcConfig.clientId,
|
||||
key
|
||||
);
|
||||
const decryptedClientSecret = decrypt(
|
||||
existingIdp.idpOidcConfig.clientSecret,
|
||||
key
|
||||
);
|
||||
|
||||
const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId);
|
||||
const client = new arctic.OAuth2Client(
|
||||
decryptedClientId,
|
||||
decryptedClientSecret,
|
||||
redirectUrl
|
||||
);
|
||||
|
||||
const statePayload = jsonwebtoken.verify(
|
||||
storedState,
|
||||
config.getRawConfig().server.secret,
|
||||
function (err, decoded) {
|
||||
if (err) {
|
||||
logger.error("Error verifying state JWT", { err });
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid state JWT"
|
||||
)
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
);
|
||||
|
||||
const stateObj = z
|
||||
.object({
|
||||
redirectUrl: z.string(),
|
||||
state: z.string(),
|
||||
codeVerifier: z.string()
|
||||
})
|
||||
.safeParse(statePayload);
|
||||
|
||||
if (!stateObj.success) {
|
||||
logger.error("Error parsing state JWT");
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(stateObj.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
codeVerifier,
|
||||
state,
|
||||
redirectUrl: postAuthRedirectUrl
|
||||
} = stateObj.data;
|
||||
|
||||
if (state !== expectedState) {
|
||||
logger.error("State mismatch", { expectedState, state });
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "State mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
const tokens = await client.validateAuthorizationCode(
|
||||
existingIdp.idpOidcConfig.tokenUrl,
|
||||
code,
|
||||
codeVerifier
|
||||
);
|
||||
|
||||
const idToken = tokens.idToken();
|
||||
const claims = arctic.decodeIdToken(idToken);
|
||||
|
||||
const userIdentifier = jmespath.search(
|
||||
claims,
|
||||
existingIdp.idpOidcConfig.identifierPath
|
||||
);
|
||||
|
||||
if (!userIdentifier) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User identifier not found in the ID token"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("User identifier", { userIdentifier });
|
||||
|
||||
let email = null;
|
||||
let name = null;
|
||||
try {
|
||||
if (existingIdp.idpOidcConfig.emailPath) {
|
||||
email = jmespath.search(
|
||||
claims,
|
||||
existingIdp.idpOidcConfig.emailPath
|
||||
);
|
||||
}
|
||||
|
||||
if (existingIdp.idpOidcConfig.namePath) {
|
||||
name = jmespath.search(
|
||||
claims,
|
||||
existingIdp.idpOidcConfig.namePath || ""
|
||||
);
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
logger.debug("User email", { email });
|
||||
logger.debug("User name", { name });
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.username, userIdentifier),
|
||||
eq(users.idpId, existingIdp.idp.idpId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingIdp.idp.autoProvision) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Auto provisioning is not supported"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
if (!existingUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"User not provisioned in the system"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateSessionToken();
|
||||
const sess = await createSession(token, existingUser.userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(sess.expiresAt)
|
||||
);
|
||||
|
||||
res.appendHeader("Set-Cookie", cookie);
|
||||
|
||||
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||
data: {
|
||||
redirectUrl: postAuthRedirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "OIDC callback validated successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ export * from "./getOrg";
|
|||
export * from "./createOrg";
|
||||
export * from "./deleteOrg";
|
||||
export * from "./updateOrg";
|
||||
export * from "./listOrgs";
|
||||
export * from "./listUserOrgs";
|
||||
export * from "./checkId";
|
||||
export * from "./getOrgOverview";
|
||||
export * from "./listOrgs";
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Org, orgs } from "@server/db/schemas";
|
||||
import { Org, orgs, userOrgs } from "@server/db/schemas";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, inArray } from "drizzle-orm";
|
||||
import { sql, inArray, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
|
|||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/orgs",
|
||||
description: "List all organizations in the system",
|
||||
path: "/user/:userId/orgs",
|
||||
description: "List all organizations in the system.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: listOrgsSchema
|
||||
|
@ -59,37 +59,15 @@ export async function listOrgs(
|
|||
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
// Use the userOrgs passed from the middleware
|
||||
const userOrgIds = req.userOrgIds;
|
||||
|
||||
if (!userOrgIds || userOrgIds.length === 0) {
|
||||
return response<ListOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "No organizations found for the user",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const organizations = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, userOrgIds))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, userOrgIds));
|
||||
.from(orgs);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
return response<ListOrgsResponse>(res, {
|
||||
|
|
141
server/routers/org/listUserOrgs.ts
Normal file
141
server/routers/org/listUserOrgs.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Org, orgs, userOrgs } from "@server/db/schemas";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, inArray, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const listOrgsParamsSchema = z.object({
|
||||
userId: z.string()
|
||||
});
|
||||
|
||||
const listOrgsSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/user/:userId/orgs",
|
||||
description: "List all organizations for a user.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||
request: {
|
||||
query: listOrgsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export type ListUserOrgsResponse = {
|
||||
orgs: Org[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function listUserOrgs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listOrgsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listOrgsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = parsedParams.data;
|
||||
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
roleId: userOrgs.roleId
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
|
||||
const userOrgIds = userOrganizations.map((org) => org.orgId);
|
||||
|
||||
if (!userOrgIds || userOrgIds.length === 0) {
|
||||
return response<ListUserOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "No organizations found for the user",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const organizations = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, userOrgIds))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalCountResult = await db
|
||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||
.from(orgs)
|
||||
.where(inArray(orgs.orgId, userOrgIds));
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
return response<ListUserOrgsResponse>(res, {
|
||||
data: {
|
||||
orgs: organizations,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Organizations retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -39,7 +39,6 @@ const createHttpResourceSchema = z
|
|||
isBaseDomain: z.boolean().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
domainId: z.string()
|
||||
})
|
||||
.strict()
|
||||
|
@ -203,7 +202,7 @@ async function createHttpResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||
const { name, subdomain, isBaseDomain, http, domainId } =
|
||||
parsedBody.data;
|
||||
|
||||
const [orgDomain] = await db
|
||||
|
@ -262,7 +261,7 @@ async function createHttpResource(
|
|||
name,
|
||||
subdomain,
|
||||
http,
|
||||
protocol,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
isBaseDomain
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
|
||||
import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
|
|||
return await db
|
||||
.select({
|
||||
userId: userResources.userId,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
email: users.email
|
||||
})
|
||||
.from(userResources)
|
||||
.innerJoin(users, eq(userResources.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(userResources.resourceId, resourceId));
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import createHttpError from "http-errors";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { users } from "@server/db/schemas";
|
||||
import { idp, users } from "@server/db/schemas";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listUsersSchema = z
|
||||
|
@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
|
|||
.select({
|
||||
id: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
dateCreated: users.dateCreated,
|
||||
serverAdmin: users.serverAdmin
|
||||
serverAdmin: users.serverAdmin,
|
||||
type: users.type,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(users.serverAdmin, false))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
|
207
server/routers/user/createOrgUser.ts
Normal file
207
server/routers/user/createOrgUser.ts
Normal file
|
@ -0,0 +1,207 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import db from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db/schemas";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const bodySchema = z
|
||||
.object({
|
||||
email: z.string().email().optional(),
|
||||
username: z.string().nonempty(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(["internal", "oidc"]).optional(),
|
||||
idpId: z.number().optional(),
|
||||
roleId: z.number()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateOrgUserResponse = {};
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/user",
|
||||
description: "Create an organization user.",
|
||||
tags: [OpenAPITags.User, OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createOrgUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { username, email, name, type, idpId, roleId } = parsedBody.data;
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId));
|
||||
|
||||
if (!role) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Role ID not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "internal") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Internal users are not supported yet"
|
||||
)
|
||||
);
|
||||
} else if (type === "oidc") {
|
||||
if (!idpId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IDP ID is required for OIDC users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [idpRes] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(eq(idp.idpId, idpId));
|
||||
|
||||
if (!idpRes) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (idpRes.idp.type !== "oidc") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"IDP ID is not of type OIDC"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (existingUser) {
|
||||
const [existingOrgUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, existingUser.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingOrgUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User already exists in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
} else {
|
||||
const userId = generateId(15);
|
||||
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
userId: userId,
|
||||
email,
|
||||
username,
|
||||
name,
|
||||
type: "oidc",
|
||||
idpId,
|
||||
dateCreated: new Date().toISOString(),
|
||||
emailVerified: true
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.insert(userOrgs)
|
||||
.values({
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateOrgUserResponse>(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org user created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -17,6 +17,9 @@ async function queryUser(orgId: string, userId: string) {
|
|||
orgId: userOrgs.orgId,
|
||||
userId: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schemas";
|
||||
import { idp, users } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
@ -13,11 +13,17 @@ async function queryUser(userId: string) {
|
|||
.select({
|
||||
userId: users.userId,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(users.userId, userId))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
|
|
@ -9,3 +9,4 @@ export * from "./adminListUsers";
|
|||
export * from "./adminRemoveUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
|
|
@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
|
|||
import { sendEmail } from "@server/emails";
|
||||
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
||||
|
||||
|
@ -115,7 +116,13 @@ export async function inviteUser(
|
|||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||
.where(
|
||||
and(
|
||||
eq(users.email, email),
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(users.type, UserType.Internal)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length) {
|
||||
|
@ -190,7 +197,7 @@ export async function inviteUser(
|
|||
inviteLink,
|
||||
expiresInDays: (validHours / 24).toString(),
|
||||
orgName: org[0].name || orgId,
|
||||
inviterName: req.user?.email
|
||||
inviterName: req.user?.email || req.user?.username
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
|
@ -242,7 +249,7 @@ export async function inviteUser(
|
|||
inviteLink,
|
||||
expiresInDays: (validHours / 24).toString(),
|
||||
orgName: org[0].name || orgId,
|
||||
inviterName: req.user?.email
|
||||
inviterName: req.user?.email || req.user?.username
|
||||
}),
|
||||
{
|
||||
to: email,
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db/schemas";
|
||||
import { idp, roles, userOrgs, users } from "@server/db/schemas";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { and, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const listUsersParamsSchema = z
|
||||
.object({
|
||||
|
@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
|||
emailVerified: users.emailVerified,
|
||||
dateCreated: users.dateCreated,
|
||||
orgId: userOrgs.orgId,
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner
|
||||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)
|
||||
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`)
|
||||
.where(sql`${userOrgs.orgId} = ${orgId}`)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.where(eq(userOrgs.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
@ -107,7 +114,8 @@ export async function listUsers(
|
|||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.orgId, orgId));
|
||||
|
||||
return response<ListUsersResponse>(res, {
|
||||
data: {
|
||||
|
|
|
@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
|
|||
import moment from "moment";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
export async function setupServerAdmin() {
|
||||
const {
|
||||
|
@ -34,7 +35,7 @@ export async function setupServerAdmin() {
|
|||
if (existing) {
|
||||
const passwordChanged = !(await verifyPassword(
|
||||
password,
|
||||
existing.passwordHash
|
||||
existing.passwordHash!
|
||||
));
|
||||
|
||||
if (passwordChanged) {
|
||||
|
@ -65,6 +66,8 @@ export async function setupServerAdmin() {
|
|||
await db.insert(users).values({
|
||||
userId: userId,
|
||||
email: email,
|
||||
type: UserType.Internal,
|
||||
username: email,
|
||||
passwordHash,
|
||||
dateCreated: moment().toISOString(),
|
||||
serverAdmin: true,
|
||||
|
|
4
server/types/UserTypes.ts
Normal file
4
server/types/UserTypes.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export enum UserType {
|
||||
Internal = "internal",
|
||||
OIDC = "oidc"
|
||||
}
|
|
@ -8,7 +8,8 @@ import { AxiosResponse } from "axios";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { orgNavItems } from "../navigation";
|
||||
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
navItems={orgNavItems}
|
||||
>
|
||||
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
|
|
|
@ -1,369 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
|
||||
type InviteUserFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
validForHours: z.string().min(1, { message: "Please select a duration" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
|
||||
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
validForHours: "72",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSendEmail(env.email.emailEnabled);
|
||||
form.reset();
|
||||
setInviteLink(null);
|
||||
setExpiresInDays(1);
|
||||
}
|
||||
}, [open, env.email.emailEnabled, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListRolesResponse>
|
||||
>(`/org/${org?.org.orgId}/roles`)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setRoles(res.data.data.roles);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${org?.org.orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleId: parseInt(values.roleId),
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail: sendEmail
|
||||
} as InviteUserBody
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "User Already Exists",
|
||||
description:
|
||||
"This user is already a member of the organization."
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to invite user",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while inviting the user"
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
setInviteLink(res.data.data.inviteLink);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User invited",
|
||||
description: "The user has been successfully invited."
|
||||
});
|
||||
|
||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setInviteLink(null);
|
||||
setLoading(false);
|
||||
setExpiresInDays(1);
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Invite User</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Give new users access to your organization
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{!inviteLink && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="invite-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(e) =>
|
||||
setSendEmail(
|
||||
e as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to user
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validForHours"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={field.value.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{validFor.map(
|
||||
(option) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.hours
|
||||
}
|
||||
value={option.hours.toString()}
|
||||
>
|
||||
{
|
||||
option.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{inviteLink && (
|
||||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has been sent to the user
|
||||
with the access link below. They
|
||||
must access the link to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has been invited. They must
|
||||
access the link below to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays === 1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="invite-user-form"
|
||||
loading={loading}
|
||||
disabled={inviteLink !== null || loading}
|
||||
>
|
||||
Create Invitation
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -24,7 +24,7 @@ export function UsersDataTable<TData, TValue>({
|
|||
searchPlaceholder="Search users..."
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
addButtonText="Invite User"
|
||||
addButtonText="Create User"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import { Button } from "@app/components/ui/button";
|
|||
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||
import { UsersDataTable } from "./UsersDataTable";
|
||||
import { useState } from "react";
|
||||
import InviteUserForm from "./InviteUserForm";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
|
@ -24,7 +23,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
email: string | null;
|
||||
displayUsername: string | null;
|
||||
username: string;
|
||||
name: string | null;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
type: string;
|
||||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
|
@ -35,16 +40,11 @@ type UsersTableProps = {
|
|||
};
|
||||
|
||||
export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
|
||||
|
||||
const [users, setUsers] = useState<UserRow[]>(u);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
|
@ -82,7 +82,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
Manage User
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{userRow.email !== user?.email && (
|
||||
{`${userRow.username}-${userRow.idpId}` !==
|
||||
`${user?.username}-${userRow.idpId}` && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
|
@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
accessorKey: "displayUsername",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
|
@ -117,14 +118,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
|
@ -133,7 +134,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Status
|
||||
Identity Provider
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
|
@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove{" "}
|
||||
<b>{selectedUser?.email}</b> from the organization?
|
||||
<b>
|
||||
{selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email address of the
|
||||
user below.
|
||||
To confirm, please type the name of the of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove User"
|
||||
onConfirm={removeUser}
|
||||
string={selectedUser?.email ?? ""}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title="Remove User from Organization"
|
||||
/>
|
||||
|
||||
<InviteUserForm
|
||||
open={isInviteModalOpen}
|
||||
setOpen={setIsInviteModalOpen}
|
||||
/>
|
||||
|
||||
<UsersDataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
inviteUser={() => {
|
||||
setIsInviteModalOpen(true);
|
||||
router.push(`/${org?.org.orgId}/settings/access/users/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
793
src/app/[orgId]/settings/access/users/create/page.tsx
Normal file
793
src/app/[orgId]/settings/access/users/create/page.tsx
Normal file
|
@ -0,0 +1,793 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
interface UserTypeOption {
|
||||
id: UserType;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface IdpOption {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
validForHours: z.string().min(1, { message: "Please select a duration" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
username: z.string().min(1, { message: "Username is required" }),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: "Invalid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: "Please select a role" }),
|
||||
idpId: z.string().min(1, { message: "Please select an identity provider" })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return "Generic OAuth2/OIDC provider.";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [userType, setUserType] = useState<UserType | null>("internal");
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
];
|
||||
|
||||
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
|
||||
resolver: zodResolver(internalFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
validForHours: "72",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
|
||||
resolver: zodResolver(externalFormSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
name: "",
|
||||
roleId: "",
|
||||
idpId: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userType === "internal") {
|
||||
setSendEmail(env.email.emailEnabled);
|
||||
internalForm.reset();
|
||||
setInviteLink(null);
|
||||
setExpiresInDays(1);
|
||||
} else if (userType === "oidc") {
|
||||
externalForm.reset();
|
||||
}
|
||||
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userType) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setRoles(res.data.data.roles);
|
||||
if (userType === "internal") {
|
||||
setDataLoaded(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIdps() {
|
||||
const res = await api
|
||||
.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch identity providers",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching identity providers"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setIdps(res.data.data.idps);
|
||||
setDataLoaded(true);
|
||||
}
|
||||
}
|
||||
|
||||
setDataLoaded(false);
|
||||
fetchRoles();
|
||||
if (userType !== "internal") {
|
||||
fetchIdps();
|
||||
}
|
||||
}, [userType]);
|
||||
|
||||
async function onSubmitInternal(
|
||||
values: z.infer<typeof internalFormSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${orgId}/create-invite`,
|
||||
{
|
||||
email: values.email,
|
||||
roleId: parseInt(values.roleId),
|
||||
validHours: parseInt(values.validForHours),
|
||||
sendEmail: sendEmail
|
||||
} as InviteUserBody
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "User Already Exists",
|
||||
description:
|
||||
"This user is already a member of the organization."
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to invite user",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while inviting the user"
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
setInviteLink(res.data.data.inviteLink);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User invited",
|
||||
description: "The user has been successfully invited."
|
||||
});
|
||||
|
||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmitExternal(
|
||||
values: z.infer<typeof externalFormSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.put(`/org/${orgId}/user`, {
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
name: values.name,
|
||||
type: "oidc",
|
||||
idpId: parseInt(values.idpId),
|
||||
roleId: parseInt(values.roleId)
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create user",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the user"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User created",
|
||||
description: "The user has been successfully created."
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const userTypes: ReadonlyArray<UserTypeOption> = [
|
||||
{
|
||||
id: "internal",
|
||||
title: "Internal User",
|
||||
description: "Invite a user to join your organization directly."
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
title: "External User",
|
||||
description: "Create a user with an external identity provider."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create User"
|
||||
description="Follow the steps below to create a new user"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
See All Users
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Type
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to create the user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={userTypes}
|
||||
defaultValue={userType || undefined}
|
||||
onChange={(value) => {
|
||||
setUserType(value as UserType);
|
||||
if (value === "internal") {
|
||||
internalForm.reset();
|
||||
} else if (value === "oidc") {
|
||||
externalForm.reset();
|
||||
setSelectedIdp(null);
|
||||
}
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{userType === "internal" && dataLoaded && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...internalForm}>
|
||||
<form
|
||||
onSubmit={internalForm.handleSubmit(
|
||||
onSubmitInternal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) =>
|
||||
setSendEmail(
|
||||
e as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to
|
||||
user
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="validForHours"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{validFor.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.hours
|
||||
}
|
||||
value={option.hours.toString()}
|
||||
>
|
||||
{
|
||||
option.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has
|
||||
been sent to the
|
||||
user with the
|
||||
access link
|
||||
below. They must
|
||||
access the link
|
||||
to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has
|
||||
been invited.
|
||||
They must access
|
||||
the link below
|
||||
to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will
|
||||
expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays ===
|
||||
1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{userType !== "internal" && dataLoaded && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Identity Provider
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the identity provider for the
|
||||
external user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{idps.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
No identity providers are
|
||||
configured. Please configure an
|
||||
identity provider before creating
|
||||
external users.
|
||||
</p>
|
||||
) : (
|
||||
<Form {...externalForm}>
|
||||
<FormField
|
||||
control={externalForm.control}
|
||||
name="idpId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={idps.map(
|
||||
(idp) => ({
|
||||
id: idp.idpId.toString(),
|
||||
title: idp.name,
|
||||
description:
|
||||
formatIdpType(
|
||||
idp.type
|
||||
)
|
||||
})
|
||||
)}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
onChange={(
|
||||
value
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
const idp =
|
||||
idps.find(
|
||||
(idp) =>
|
||||
idp.idpId.toString() ===
|
||||
value
|
||||
);
|
||||
setSelectedIdp(
|
||||
idp || null
|
||||
);
|
||||
}}
|
||||
cols={3}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{idps.length > 0 && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...externalForm}>
|
||||
<form
|
||||
onSubmit={externalForm.handleSubmit(
|
||||
onSubmitExternal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Username
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This must
|
||||
match the
|
||||
unique
|
||||
username
|
||||
that exists
|
||||
in the
|
||||
selected
|
||||
identity
|
||||
provider.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
(Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
(Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{userType && dataLoaded && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
(userType === "internal" && inviteLink !== null)
|
||||
}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
const userRows: UserRow[] = users.map((user) => {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayUsername: user.email || user.name || user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || "Internal",
|
||||
status: "Confirmed",
|
||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
||||
isOwner: user.isOwner || false
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
|
@ -43,6 +43,7 @@ import {
|
|||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string()
|
||||
|
@ -57,6 +58,7 @@ export default function GeneralPage() {
|
|||
const router = useRouter();
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
|
@ -101,7 +103,9 @@ export default function GeneralPage() {
|
|||
|
||||
async function pickNewOrgAndNavigate() {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
|
||||
const res = await api.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
if (res.data.data.orgs.length > 0) {
|
||||
|
@ -237,9 +241,7 @@ export default function GeneralPage() {
|
|||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Danger Zone
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Once you delete this org, there is no going back. Please
|
||||
be certain.
|
||||
|
|
|
@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
|||
import { redirect } from "next/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { cache } from "react";
|
||||
import { GetOrgUserResponse } from "@server/routers/user";
|
||||
|
@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
redirect(`/${params.orgId}`);
|
||||
}
|
||||
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
|
|
|
@ -21,10 +21,8 @@ import {
|
|||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CreateResourceForm from "./CreateResourceForm";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { set } from "zod";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
|
@ -58,10 +56,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
const [selectedResource, setSelectedResource] = useState<ResourceRow | null>();
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
|
@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CreateResourceForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
/>
|
||||
|
||||
{selectedResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
|
@ -328,7 +319,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
columns={columns}
|
||||
data={resources}
|
||||
createResource={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/resources/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
|
|||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
password. It must be at least 4
|
||||
characters long.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
|
|||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
PIN code. It must be at least 6
|
||||
digits long.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput";
|
|||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
|
@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() {
|
|||
setAllUsers(
|
||||
usersResponse.data.data.users.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: user.email
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
|
@ -183,7 +186,7 @@ export default function ResourceAuthenticationPage() {
|
|||
"users",
|
||||
resourceUsersResponse.data.data.users.map((i) => ({
|
||||
id: i.userId.toString(),
|
||||
text: i.email
|
||||
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
|
@ -611,117 +614,127 @@ export default function ResourceAuthenticationPage() {
|
|||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
One-time Passwords
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Require email-based authentication for resource
|
||||
access
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label="Email Whitelist"
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
/>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
One-time Passwords
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Require email-based authentication for resource
|
||||
access
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{!env.email.emailEnabled && (
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
SMTP Required
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
SMTP must be enabled on the server to use one-time password authentication.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label="Email Whitelist"
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
/>
|
||||
|
||||
{whitelistEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form id="whitelist-form">
|
||||
<FormField
|
||||
control={whitelistForm.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text="Whitelisted Emails"
|
||||
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size={"sm"}
|
||||
validateTag={(
|
||||
tag
|
||||
) => {
|
||||
return z
|
||||
.string()
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder="Enter an email"
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
whitelistForm.setValue(
|
||||
"emails",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Press enter to add an
|
||||
email after typing it in
|
||||
the input field.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={saveWhitelist}
|
||||
form="whitelist-form"
|
||||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
>
|
||||
Save Whitelist
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
)}
|
||||
{whitelistEnabled && env.email.emailEnabled && (
|
||||
<Form {...whitelistForm}>
|
||||
<form id="whitelist-form">
|
||||
<FormField
|
||||
control={whitelistForm.control}
|
||||
name="emails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text="Whitelisted Emails"
|
||||
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{/* @ts-ignore */}
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeEmailTagIndex
|
||||
}
|
||||
size={"sm"}
|
||||
validateTag={(
|
||||
tag
|
||||
) => {
|
||||
return z
|
||||
.string()
|
||||
.email()
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||
}
|
||||
)
|
||||
)
|
||||
.safeParse(
|
||||
tag
|
||||
).success;
|
||||
}}
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder="Enter an email"
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
whitelistForm.setValue(
|
||||
"emails",
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Press enter to add an
|
||||
email after typing it in
|
||||
the input field.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={saveWhitelist}
|
||||
form="whitelist-form"
|
||||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
>
|
||||
Save Whitelist
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
|
|
File diff suppressed because it is too large
Load diff
31
src/app/admin/idp/AdminIdpDataTable.tsx
Normal file
31
src/app/admin/idp/AdminIdpDataTable.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function IdpDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Identity Providers"
|
||||
searchPlaceholder="Search identity providers..."
|
||||
searchColumn="name"
|
||||
addButtonText="Add Identity Provider"
|
||||
onAdd={() => {
|
||||
router.push("/admin/idp/create");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
214
src/app/admin/idp/AdminIdpTable.tsx
Normal file
214
src/app/admin/idp/AdminIdpTable.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { IdpDataTable } from "./AdminIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
idps: IdpRow[];
|
||||
};
|
||||
|
||||
export default function IdpTable({ idps }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
await api.delete(`/idp/${idpId}`);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider deleted successfully"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
case "oidc":
|
||||
return "OAuth2/OIDC";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<IdpRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/admin/idp/${r.idpId}/general`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedIdp(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "idpId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
return (
|
||||
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedIdp && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete the
|
||||
identity provider <b>{selectedIdp.name}</b>?
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the identity provider and
|
||||
all associated configurations. Users who
|
||||
authenticate through this provider will no
|
||||
longer be able to log in.
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the name of the identity
|
||||
provider below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Identity Provider"
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
title="Delete Identity Provider"
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdpDataTable columns={columns} data={idps} />
|
||||
</>
|
||||
);
|
||||
}
|
507
src/app/admin/idp/[idpId]/general/page.tsx
Normal file
507
src/app/admin/idp/[idpId]/general/page.tsx
Normal file
|
@ -0,0 +1,507 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter, useParams, redirect } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionGrid
|
||||
} from "@app/components/Settings";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState, useEffect } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const { idpId } = useParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "sub",
|
||||
emailPath: "email",
|
||||
namePath: "name",
|
||||
scopes: "openid profile email",
|
||||
autoProvision: true
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadIdp = async () => {
|
||||
try {
|
||||
const res = await api.get(`/idp/${idpId}`);
|
||||
if (res.status === 200) {
|
||||
const data = res.data.data;
|
||||
form.reset({
|
||||
name: data.idp.name,
|
||||
clientId: data.idpOidcConfig.clientId,
|
||||
clientSecret: data.idpOidcConfig.clientSecret,
|
||||
authUrl: data.idpOidcConfig.authUrl,
|
||||
tokenUrl: data.idpOidcConfig.tokenUrl,
|
||||
identifierPath: data.idpOidcConfig.identifierPath,
|
||||
emailPath: data.idpOidcConfig.emailPath,
|
||||
namePath: data.idpOidcConfig.namePath,
|
||||
scopes: data.idpOidcConfig.scopes,
|
||||
autoProvision: data.idp.autoProvision
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
router.push("/admin/idp");
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadIdp();
|
||||
}, [idpId, api, form, router]);
|
||||
|
||||
async function onSubmit(data: GeneralFormValues) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
authUrl: data.authUrl,
|
||||
tokenUrl: data.tokenUrl,
|
||||
identifierPath: data.identifierPath,
|
||||
emailPath: data.emailPath,
|
||||
namePath: data.namePath,
|
||||
autoProvision: data.autoProvision,
|
||||
scopes: data.scopes
|
||||
};
|
||||
|
||||
const res = await api.post(`/idp/${idpId}/oidc`, payload);
|
||||
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider updated successfully"
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Redirect URL
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={redirectUrl} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Redirect URL
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is the URL to which users will be
|
||||
redirected after authentication. You need to
|
||||
configure this URL in your identity provider
|
||||
settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
disabled={true}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
checked
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Badge className="ml-2">
|
||||
Enterprise
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints and
|
||||
credentials
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information from
|
||||
the ID token
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the user
|
||||
identifier in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's email in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="namePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's name in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionGrid>
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end mt-8">
|
||||
<Button
|
||||
type="submit"
|
||||
form="general-settings-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
57
src/app/admin/idp/[idpId]/layout.tsx
Normal file
57
src/app/admin/idp/[idpId]/layout.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { GetIdpResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ idpId: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
let idp = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetIdpResponse>>(
|
||||
`/idp/${params.idpId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
idp = res.data.data;
|
||||
} catch {
|
||||
redirect("/admin/idp");
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${idp.idp.name} Settings`}
|
||||
description="Configure the settings for your identity provider"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
8
src/app/admin/idp/[idpId]/page.tsx
Normal file
8
src/app/admin/idp/[idpId]/page.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function IdpPage(props: {
|
||||
params: Promise<{ idpId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(`/admin/idp/${params.idpId}/general`);
|
||||
}
|
516
src/app/admin/idp/create/page.tsx
Normal file
516
src/app/admin/idp/create/page.tsx
Normal file
|
@ -0,0 +1,516 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionGrid,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: "Configure an OpenID Connect identity provider"
|
||||
}
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
||||
const form = useForm<CreateIdpFormValues>({
|
||||
resolver: zodResolver(createIdpFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "sub",
|
||||
namePath: "name",
|
||||
emailPath: "email",
|
||||
scopes: "openid profile email",
|
||||
autoProvision: false
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: CreateIdpFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: data.name,
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
authUrl: data.authUrl,
|
||||
tokenUrl: data.tokenUrl,
|
||||
identifierPath: data.identifierPath,
|
||||
emailPath: data.emailPath,
|
||||
namePath: data.namePath,
|
||||
autoProvision: data.autoProvision,
|
||||
scopes: data.scopes
|
||||
};
|
||||
|
||||
const res = await api.put("/idp/oidc", payload);
|
||||
|
||||
if (res.status === 201) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider created successfully"
|
||||
});
|
||||
router.push(`/admin/idp/${res.data.data.idpId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Identity Provider"
|
||||
description="Configure a new identity provider for user authentication"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
See All Identity Providers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
disabled={true}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
checked
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Badge className="ml-2">
|
||||
Enterprise
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Provider Type
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the type of identity provider you want to
|
||||
configure
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={providerTypes}
|
||||
defaultValue={form.getValues("type")}
|
||||
onChange={(value) => {
|
||||
form.setValue("type", value as "oidc");
|
||||
}}
|
||||
cols={3}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{form.watch("type") === "oidc" && (
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints
|
||||
and credentials
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-idp.com/oauth2/authorize"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-idp.com/oauth2/token"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Important Information
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
After creating the identity provider,
|
||||
you will need to configure the callback
|
||||
URL in your identity provider's
|
||||
settings. The callback URL will be
|
||||
provided after successful creation.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information
|
||||
from the ID token
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the user
|
||||
identifier in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's email in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="namePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's name in the ID
|
||||
token
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionGrid>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
loading={createLoading}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
Create Identity Provider
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
28
src/app/admin/idp/page.tsx
Normal file
28
src/app/admin/idp/page.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import IdpTable, { IdpRow } from "./AdminIdpTable";
|
||||
|
||||
export default async function IdpPage() {
|
||||
let idps: IdpRow[] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<{ idps: IdpRow[] }>>(
|
||||
`/idp`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
idps = res.data.data.idps;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Identity Providers"
|
||||
description="View and manage identity providers in the system"
|
||||
/>
|
||||
<IdpTable idps={idps} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
|||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
@ -31,10 +31,13 @@ export default async function AdminLayout(props: LayoutProps) {
|
|||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
cookie
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
|
|
|
@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
type: string;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
dateCreated: string;
|
||||
};
|
||||
|
||||
|
@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) {
|
|||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => {
|
||||
|
@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) {
|
|||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
|
@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
|
|||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b>{selected?.email || selected?.id}</b> from
|
||||
the server?
|
||||
<b>
|
||||
{selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username}
|
||||
</b>{" "}
|
||||
from the server?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
|
|||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email of the user
|
||||
To confirm, please type the name of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={selected.email}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title="Delete User from Server"
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -4,6 +4,8 @@ import { AxiosResponse } from "axios";
|
|||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
||||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -27,6 +29,11 @@ export default async function UsersPage(props: PageProps) {
|
|||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
idpId: row.idpId,
|
||||
idpName: row.idpName || "Internal",
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
|
@ -38,6 +45,13 @@ export default async function UsersPage(props: PageProps) {
|
|||
title="Manage All Users"
|
||||
description="View and manage all users in the system"
|
||||
/>
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About User Management</AlertTitle>
|
||||
<AlertDescription>
|
||||
This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<UsersTable users={userRows} />
|
||||
</>
|
||||
);
|
||||
|
|
118
src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx
Normal file
118
src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
idpId: string;
|
||||
code: string | undefined;
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log("Validating OIDC token", {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
||||
code: props.code,
|
||||
state: props.expectedState,
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log("Validate OIDC token response", res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Connecting to {props.idp.name}</CardTitle>
|
||||
<CardDescription>Validating your identity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
There was a problem connecting to{" "}
|
||||
{props.idp.name}. Please contact your
|
||||
administrator.
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
42
src/app/auth/idp/[idpId]/oidc/callback/page.tsx
Normal file
42
src/app/auth/idp/[idpId]/oidc/callback/page.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { cookies } from "next/headers";
|
||||
import ValidateOidcToken from "./ValidateOidcToken";
|
||||
import { idp } from "@server/db/schemas";
|
||||
import db from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ orgId: string; idpId: string }>;
|
||||
searchParams: Promise<{
|
||||
code: string;
|
||||
state: string;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const allCookies = await cookies();
|
||||
const stateCookie = allCookies.get("p_oidc_state")?.value;
|
||||
|
||||
// query db directly in server component because just need the name
|
||||
const [idpRes] = await db
|
||||
.select({ name: idp.name })
|
||||
.from(idp)
|
||||
.where(eq(idp.idpId, parseInt(params.idpId!)));
|
||||
|
||||
if (!idpRes) {
|
||||
return <div>IdP not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ValidateOidcToken
|
||||
orgId={params.orgId}
|
||||
idpId={params.idpId}
|
||||
code={searchParams.code}
|
||||
expectedState={searchParams.state}
|
||||
stateCookie={stateCookie}
|
||||
idp={{ name: idpRes.name }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -8,7 +8,7 @@ import {
|
|||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
redirect
|
||||
redirect,
|
||||
idps
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
// const api = createApiClient(useEnvContext());
|
||||
|
@ -51,12 +53,15 @@ export default function DashboardLoginForm({
|
|||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Log in to get started</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to get started
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
onLogin={() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
|
|
|
@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm";
|
|||
import { Mail } from "lucide-react";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import db from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -31,10 +34,16 @@ export default async function Page(props: {
|
|||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
||||
}
|
||||
|
||||
const idps = await db.select().from(idp);
|
||||
const loginIdps = idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
<div className="border rounded-md p-3 mb-4">
|
||||
<div className="border rounded-md p-3 mb-4 bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
|
@ -48,7 +57,7 @@ export default async function Page(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DashboardLoginForm redirect={redirectUrl} />
|
||||
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
|
|
@ -33,7 +33,7 @@ import { useRouter } from "next/navigation";
|
|||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import LoginForm from "@app/components/LoginForm";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithWhitelistResponse
|
||||
|
@ -81,6 +81,7 @@ type ResourceAuthPortalProps = {
|
|||
id: number;
|
||||
};
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
@ -376,31 +377,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||
index={
|
||||
0
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
@ -490,7 +497,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
redirect={`/auth/resource/${props.resource.id}`}
|
||||
idps={props.idps}
|
||||
redirect={props.redirect}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
}
|
||||
|
|
|
@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound";
|
|||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import AccessToken from "./AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import db from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
|
||||
export default async function ResourceAuthPage(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
|
@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: {
|
|||
redirect(redirectUrl);
|
||||
}
|
||||
|
||||
|
||||
// convert the dashboard token into a resource session token
|
||||
let userIsUnauthorized = false;
|
||||
if (user && authInfo.sso) {
|
||||
|
@ -128,6 +130,12 @@ export default async function ResourceAuthPage(props: {
|
|||
);
|
||||
}
|
||||
|
||||
const idps = await db.select().from(idp);
|
||||
const loginIdps = idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{userIsUnauthorized && isSSOOnly ? (
|
||||
|
@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: {
|
|||
id: authInfo.resourceId
|
||||
}}
|
||||
redirect={redirectUrl}
|
||||
idps={loginIdps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -50,7 +50,7 @@ export default async function Page(props: {
|
|||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
<div className="border rounded-md p-3 mb-4">
|
||||
<div className="border rounded-md p-3 mb-4 bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
|
|
|
@ -36,7 +36,7 @@ export default async function Page(props: {
|
|||
return (
|
||||
<>
|
||||
<VerifyEmailForm
|
||||
email={user.email}
|
||||
email={user.email!}
|
||||
redirect={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap");
|
||||
@import 'tw-animate-css';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
@ -23,7 +24,7 @@
|
|||
--border: hsl(20 5.9% 90%);
|
||||
--input: hsl(20 5.9% 75%);
|
||||
--ring: hsl(24.6 95% 53.1%);
|
||||
--radius: 0.50rem;
|
||||
--radius: 0.75rem;
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Figtree, Inter, Red_Hat_Display, Red_Hat_Mono, Red_Hat_Text, Space_Grotesk } from "next/font/google";
|
||||
import {
|
||||
Figtree,
|
||||
Inter,
|
||||
Red_Hat_Display,
|
||||
Red_Hat_Mono,
|
||||
Red_Hat_Text,
|
||||
Space_Grotesk
|
||||
} from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
|
|
|
@ -5,32 +5,42 @@ import {
|
|||
Users,
|
||||
Link as LinkIcon,
|
||||
Waypoints,
|
||||
Combine
|
||||
Combine,
|
||||
Fingerprint,
|
||||
KeyRound
|
||||
} from "lucide-react";
|
||||
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Overview",
|
||||
href: "/{orgId}",
|
||||
icon: <Home className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
export const rootNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Home",
|
||||
href: "/"
|
||||
// icon: <Home className="h-4 w-4" />
|
||||
href: "/",
|
||||
icon: <Home className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
export const orgNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Sites",
|
||||
href: "/{orgId}/settings/sites"
|
||||
// icon: <Combine className="h-4 w-4" />
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Combine className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
href: "/{orgId}/settings/resources"
|
||||
// icon: <Waypoints className="h-4 w-4" />
|
||||
href: "/{orgId}/settings/resources",
|
||||
icon: <Waypoints className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Access Control",
|
||||
href: "/{orgId}/settings/access",
|
||||
// icon: <Users className="h-4 w-4" />,
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
autoExpand: true,
|
||||
children: [
|
||||
{
|
||||
|
@ -51,20 +61,37 @@ export const orgNavItems: SidebarNavItem[] = [
|
|||
},
|
||||
{
|
||||
title: "Shareable Links",
|
||||
href: "/{orgId}/settings/share-links"
|
||||
// icon: <LinkIcon className="h-4 w-4" />
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <LinkIcon className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
showEnterprise: true
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/{orgId}/settings/general"
|
||||
// icon: <Settings className="h-4 w-4" />
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
export const adminNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "All Users",
|
||||
href: "/admin/users"
|
||||
// icon: <Users className="h-4 w-4" />
|
||||
href: "/admin/users",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
showEnterprise: true
|
||||
},
|
||||
{
|
||||
title: "Identity Providers",
|
||||
href: "/admin/idp",
|
||||
icon: <Fingerprint className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
|
|
@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
@ -36,10 +36,7 @@ export default async function Page(props: {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!user.emailVerified &&
|
||||
env.flags.emailVerificationRequired
|
||||
) {
|
||||
if (!user.emailVerified && env.flags.emailVerificationRequired) {
|
||||
if (params.redirect) {
|
||||
const safe = cleanRedirect(params.redirect);
|
||||
redirect(`/auth/verify-email?redirect=${safe}`);
|
||||
|
@ -48,10 +45,10 @@ export default async function Page(props: {
|
|||
}
|
||||
}
|
||||
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
|
||||
`/orgs`,
|
||||
const res = await internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
|
||||
|
@ -61,24 +58,19 @@ export default async function Page(props: {
|
|||
} catch (e) {}
|
||||
|
||||
if (!orgs.length) {
|
||||
if (
|
||||
!env.flags.disableUserCreateOrg ||
|
||||
user.serverAdmin
|
||||
) {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgs={orgs}
|
||||
navItems={rootNavItems}
|
||||
showBreadcrumbs={false}
|
||||
>
|
||||
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
|
||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLanding
|
||||
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
||||
disableCreateOrg={
|
||||
env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||
}
|
||||
organizations={orgs.map((org) => ({
|
||||
name: org.name,
|
||||
id: org.orgId
|
||||
|
|
|
@ -61,6 +61,9 @@ export default function StepperForm() {
|
|||
const router = useRouter();
|
||||
|
||||
const checkOrgIdAvailability = useCallback(async (value: string) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.get(`/org/checkId`, {
|
||||
params: {
|
||||
|
|
|
@ -18,53 +18,51 @@ export function Breadcrumbs() {
|
|||
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
||||
let label = segment;
|
||||
|
||||
// Format labels
|
||||
if (segment === "settings") {
|
||||
label = "Settings";
|
||||
} else if (segment === "sites") {
|
||||
label = "Sites";
|
||||
} else if (segment === "resources") {
|
||||
label = "Resources";
|
||||
} else if (segment === "access") {
|
||||
label = "Access Control";
|
||||
} else if (segment === "general") {
|
||||
label = "General";
|
||||
} else if (segment === "share-links") {
|
||||
label = "Shareable Links";
|
||||
} else if (segment === "users") {
|
||||
label = "Users";
|
||||
} else if (segment === "roles") {
|
||||
label = "Roles";
|
||||
} else if (segment === "invitations") {
|
||||
label = "Invitations";
|
||||
} else if (segment === "connectivity") {
|
||||
label = "Connectivity";
|
||||
} else if (segment === "authentication") {
|
||||
label = "Authentication";
|
||||
}
|
||||
// // Format labels
|
||||
// if (segment === "settings") {
|
||||
// label = "Settings";
|
||||
// } else if (segment === "sites") {
|
||||
// label = "Sites";
|
||||
// } else if (segment === "resources") {
|
||||
// label = "Resources";
|
||||
// } else if (segment === "access") {
|
||||
// label = "Access Control";
|
||||
// } else if (segment === "general") {
|
||||
// label = "General";
|
||||
// } else if (segment === "share-links") {
|
||||
// label = "Shareable Links";
|
||||
// } else if (segment === "users") {
|
||||
// label = "Users";
|
||||
// } else if (segment === "roles") {
|
||||
// label = "Roles";
|
||||
// } else if (segment === "invitations") {
|
||||
// label = "Invitations";
|
||||
// } else if (segment === "connectivity") {
|
||||
// label = "Connectivity";
|
||||
// } else if (segment === "authentication") {
|
||||
// label = "Authentication";
|
||||
// }
|
||||
|
||||
return { label, href };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-b px-4 py-2 overflow-x-auto scrollbar-hide bg-card">
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.href} className="flex items-center whitespace-nowrap">
|
||||
{index !== 0 && <ChevronRight className="h-4 w-4" />}
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className={cn(
|
||||
"ml-1 hover:text-foreground whitespace-nowrap",
|
||||
index === breadcrumbs.length - 1 &&
|
||||
"text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<nav className="flex items-center space-x-1 text-muted-foreground">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.href} className="flex items-center flex-nowrap">
|
||||
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className={cn(
|
||||
"ml-1 hover:text-foreground whitespace-nowrap",
|
||||
index === breadcrumbs.length - 1 &&
|
||||
"text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import Image from "next/image";
|
||||
|
||||
interface HeaderProps {
|
||||
orgId?: string;
|
||||
orgs?: any;
|
||||
}
|
||||
|
||||
export function Header({ orgId, orgs }: HeaderProps) {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Image
|
||||
src="/logo/pangolin_orange.svg"
|
||||
alt="Pangolin Logo"
|
||||
width={34}
|
||||
height={34}
|
||||
/>
|
||||
<span className="font-[Space_Grotesk] font-bold text-2xl text-neutral-500">Pangolin</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
|
@ -1,16 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Header } from "@app/components/Header";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { TopBar } from "@app/components/TopBar";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExternalLink, Menu, X } from "lucide-react";
|
||||
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
@ -21,11 +20,13 @@ import {
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Breadcrumbs } from "@app/components/Breadcrumbs";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
orgId?: string;
|
||||
orgs?: ListOrgsResponse["orgs"];
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems?: Array<{
|
||||
title: string;
|
||||
href: string;
|
||||
|
@ -54,109 +55,193 @@ export function Layout({
|
|||
}: LayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { env } = useEnvContext();
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Mobile Menu Button */}
|
||||
{showSidebar && (
|
||||
<div className="md:hidden fixed top-4 left-4 z-50">
|
||||
<Sheet
|
||||
open={isMobileMenuOpen}
|
||||
onOpenChange={setIsMobileMenuOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-64 p-0 flex flex-col h-full"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation Menu
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Main navigation menu for the application
|
||||
</SheetDescription>
|
||||
{showHeader && (
|
||||
<div className="flex h-16 items-center border-b px-4 shrink-0">
|
||||
<Header orgId={orgId} orgs={orgs} />
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Full width header */}
|
||||
{showHeader && (
|
||||
<div className="border-b shrink-0 bg-card">
|
||||
<div className="h-16 flex items-center px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSidebar && (
|
||||
<div className="md:hidden">
|
||||
<Sheet
|
||||
open={isMobileMenuOpen}
|
||||
onOpenChange={setIsMobileMenuOpen}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-64 p-0 flex flex-col h-full"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation Menu
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Main navigation menu for the
|
||||
application
|
||||
</SheetDescription>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<SidebarNav
|
||||
items={navItems}
|
||||
onItemClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="p-4 border-t">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
Server Admin
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
v{env.app.version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<SidebarNav items={navItems} onItemClick={() => setIsMobileMenuOpen(false)} />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center hidden md:block"
|
||||
>
|
||||
<Image
|
||||
src="/logo/pangolin_orange.svg"
|
||||
alt="Pangolin Logo"
|
||||
width={35}
|
||||
height={35}
|
||||
/>
|
||||
</Link>
|
||||
{showBreadcrumbs && (
|
||||
<div className="hidden md:block overflow-x-auto scrollbar-hide">
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showTopBar && (
|
||||
<div className="ml-auto flex items-center justify-end md:justify-between">
|
||||
<div className="hidden md:flex items-center space-x-3 mr-6">
|
||||
<Link
|
||||
href="https://docs.fossorial.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:support@fossorial.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
<OrgSelector orgId={orgId} orgs={orgs} />
|
||||
)}
|
||||
</div>
|
||||
{showBreadcrumbs && (
|
||||
<div className="md:hidden px-4 pb-2 overflow-x-auto scrollbar-hide">
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<SidebarNav items={navItems} />
|
||||
</div>
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="p-4 border-t">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
Server Admin
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
<OrgSelector orgId={orgId} orgs={orgs} />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
Open Source
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
v{env.app.version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
|
||||
{showHeader && (
|
||||
<div className="flex h-16 items-center border-b px-4 shrink-0">
|
||||
<Header orgId={orgId} orgs={orgs} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col h-full min-w-0",
|
||||
!showSidebar && "w-full"
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<SidebarNav items={navItems} />
|
||||
</div>
|
||||
<div className="p-4 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
<OrgSelector orgId={orgId} orgs={orgs} />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
Open Source
|
||||
<ExternalLink size={12}/>
|
||||
</Link>
|
||||
</div>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
v{env.app.version}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<div className="container mx-auto max-w-12xl mb-12">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col h-full min-w-0",
|
||||
!showSidebar && "w-full"
|
||||
)}
|
||||
>
|
||||
{showTopBar && (
|
||||
<div className="h-16 border-b shrink-0 bg-card">
|
||||
<div className="flex h-full items-center justify-end px-4">
|
||||
<TopBar orgId={orgId} orgs={orgs} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showBreadcrumbs && <Breadcrumbs />}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -25,7 +25,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||
import { LoginResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
@ -37,11 +37,19 @@ import {
|
|||
} from "./ui/input-otp";
|
||||
import Link from "next/link";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import Image from 'next/image'
|
||||
import Image from "next/image";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type LoginFormProps = {
|
||||
redirect?: string;
|
||||
onLogin?: () => void | Promise<void>;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
|
@ -55,7 +63,7 @@ const mfaSchema = z.object({
|
|||
code: z.string().length(6, { message: "Invalid code" })
|
||||
});
|
||||
|
||||
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
@ -64,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
|
||||
const [mfaRequested, setMfaRequested] = useState(false);
|
||||
|
||||
|
@ -130,60 +139,83 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
|
||||
`/auth/idp/${idpId}/oidc/generate-url`,
|
||||
{
|
||||
redirectUrl: redirect || "/"
|
||||
}
|
||||
);
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (!res) {
|
||||
setError("An error occurred while logging in");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data;
|
||||
window.location.href = data.redirectUrl;
|
||||
} catch (e) {
|
||||
console.error(formatAxiosError(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!mfaRequested && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
|
@ -193,7 +225,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the code from your authenticator app or one of your single-use backup codes.
|
||||
Enter the code from your authenticator app or one of
|
||||
your single-use backup codes.
|
||||
</p>
|
||||
</div>
|
||||
<Form {...mfaForm}>
|
||||
|
@ -268,16 +301,47 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
|||
)}
|
||||
|
||||
{!mfaRequested && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Log In
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Log In
|
||||
</Button>
|
||||
|
||||
{hasIdp && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="px-2 bg-card text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{idps.map((idp) => (
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
{idp.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mfaRequested && (
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "@app/components/ui/popover";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
@ -25,7 +25,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
|||
|
||||
interface OrgSelectorProps {
|
||||
orgId?: string;
|
||||
orgs?: ListOrgsResponse["orgs"];
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
}
|
||||
|
||||
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
||||
|
@ -121,4 +121,4 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,9 @@ export default function ProfileIcon() {
|
|||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||
|
||||
function getInitials() {
|
||||
return user.email.substring(0, 1).toUpperCase();
|
||||
return (user.email || user.name || user.username)
|
||||
.substring(0, 1)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function handleThemeChange(theme: "light" | "dark" | "system") {
|
||||
|
@ -66,9 +68,9 @@ export default function ProfileIcon() {
|
|||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||
|
||||
<div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0">
|
||||
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
|
||||
<span className="truncate max-w-full font-medium min-w-0">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
@ -92,13 +94,17 @@ export default function ProfileIcon() {
|
|||
Signed in as
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
{user.email || user.name || user.username}
|
||||
</p>
|
||||
</div>
|
||||
{user.serverAdmin && (
|
||||
{user.serverAdmin ? (
|
||||
<p className="text-xs leading-none text-muted-foreground mt-2">
|
||||
Server Admin
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs leading-none text-muted-foreground mt-2">
|
||||
{user.idpName || "Internal"}
|
||||
</p>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
@ -1,31 +1,69 @@
|
|||
export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-6">{children}</div>
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-lg bg-card p-5">{children}</div>
|
||||
return <div className="border rounded-lg bg-card p-5">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
|
||||
export function SettingsSectionHeader({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
|
||||
return <div className="max-w-xl">{children}</div>
|
||||
export function SettingsSectionForm({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="max-w-xl">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">{children}</h2>
|
||||
export function SettingsSectionTitle({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>
|
||||
export function SettingsSectionDescription({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-5">{children}</div>
|
||||
export function SettingsSectionBody({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="space-y-5">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex justify-end space-x-4 mt-8">{children}</div>
|
||||
export function SettingsSectionFooter({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionGrid({
|
||||
children,
|
||||
cols
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols: number;
|
||||
}) {
|
||||
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import Link from "next/link";
|
|||
import { useParams, usePathname } from "next/navigation";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
|
||||
export interface SidebarNavItem {
|
||||
href: string;
|
||||
|
@ -12,6 +14,7 @@ export interface SidebarNavItem {
|
|||
icon?: React.ReactNode;
|
||||
children?: SidebarNavItem[];
|
||||
autoExpand?: boolean;
|
||||
showEnterprise?: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
|
@ -35,25 +38,7 @@ export function SidebarNav({
|
|||
const userId = params.userId as string;
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize expanded items based on autoExpand property
|
||||
useEffect(() => {
|
||||
const autoExpanded = new Set<string>();
|
||||
|
||||
function findAutoExpanded(items: SidebarNavItem[]) {
|
||||
items.forEach(item => {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
if (item.autoExpand) {
|
||||
autoExpanded.add(hydratedHref);
|
||||
}
|
||||
if (item.children) {
|
||||
findAutoExpanded(item.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findAutoExpanded(items);
|
||||
setExpandedItems(autoExpanded);
|
||||
}, [items]);
|
||||
const { user } = useUserContext();
|
||||
|
||||
function hydrateHref(val: string): string {
|
||||
return val
|
||||
|
@ -63,8 +48,39 @@ export function SidebarNav({
|
|||
.replace("{userId}", userId);
|
||||
}
|
||||
|
||||
// Initialize expanded items based on autoExpand property and current path
|
||||
useEffect(() => {
|
||||
const autoExpanded = new Set<string>();
|
||||
|
||||
function findAutoExpandedAndActivePath(
|
||||
items: SidebarNavItem[],
|
||||
parentHrefs: string[] = []
|
||||
) {
|
||||
items.forEach((item) => {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
|
||||
// Add current item's href to the path
|
||||
const currentPath = [...parentHrefs, hydratedHref];
|
||||
|
||||
// Auto expand if specified or if this item or any child is active
|
||||
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
|
||||
// Expand all parent sections when a child is active
|
||||
currentPath.forEach((href) => autoExpanded.add(href));
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
if (item.children) {
|
||||
findAutoExpandedAndActivePath(item.children, currentPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findAutoExpandedAndActivePath(items);
|
||||
setExpandedItems(autoExpanded);
|
||||
}, [items, pathname]);
|
||||
|
||||
function toggleItem(href: string) {
|
||||
setExpandedItems(prev => {
|
||||
setExpandedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(href)) {
|
||||
newSet.delete(href);
|
||||
|
@ -81,46 +97,67 @@ export function SidebarNav({
|
|||
const isActive = pathname.startsWith(hydratedHref);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedItems.has(hydratedHref);
|
||||
const indent = level * 16; // Base indent for each level
|
||||
const indent = level * 28; // Base indent for each level
|
||||
const isEnterprise = item.showEnterprise;
|
||||
const isDisabled = disabled || isEnterprise;
|
||||
|
||||
return (
|
||||
<div key={hydratedHref}>
|
||||
<div className="flex items-center group" style={{ marginLeft: `${indent}px` }}>
|
||||
<Link
|
||||
href={hydratedHref}
|
||||
<div
|
||||
className="flex items-center group"
|
||||
style={{ marginLeft: `${indent}px` }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1 w-full transition-colors",
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed opacity-60"
|
||||
"flex items-center w-full transition-colors rounded-md",
|
||||
isActive && level === 0 && "bg-primary/10"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
} else if (onItemClick) {
|
||||
onItemClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.title}
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleItem(hydratedHref)}
|
||||
className="p-2 hover:bg-muted rounded-md ml-auto"
|
||||
disabled={disabled}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<Link
|
||||
href={isEnterprise ? "#" : hydratedHref}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3 py-2",
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground group-hover:text-foreground",
|
||||
isDisabled && "cursor-not-allowed"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
} else if (onItemClick) {
|
||||
onItemClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={isDisabled ? -1 : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
<div className={cn("flex items-center", isDisabled && "opacity-60")}>
|
||||
{item.icon && (
|
||||
<span className="mr-3">{item.icon}</span>
|
||||
)}
|
||||
{item.title}
|
||||
</div>
|
||||
{isEnterprise && (
|
||||
<Badge className="ml-2">
|
||||
Enterprise
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => toggleItem(hydratedHref)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="space-y-1 mt-1">
|
||||
|
@ -135,7 +172,7 @@ export function SidebarNav({
|
|||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col space-y-1",
|
||||
"flex flex-col space-y-2",
|
||||
disabled && "pointer-events-none opacity-60",
|
||||
className
|
||||
)}
|
||||
|
|
|
@ -7,6 +7,7 @@ interface SwitchComponentProps {
|
|||
label: string;
|
||||
description?: string;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
|
@ -14,6 +15,7 @@ export function SwitchInput({
|
|||
id,
|
||||
label,
|
||||
description,
|
||||
disabled,
|
||||
defaultChecked = false,
|
||||
onCheckedChange
|
||||
}: SwitchComponentProps) {
|
||||
|
@ -24,6 +26,7 @@ export function SwitchInput({
|
|||
id={id}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import Link from "next/link";
|
||||
|
||||
interface TopBarProps {
|
||||
orgId?: string;
|
||||
orgs?: any;
|
||||
}
|
||||
|
||||
export function TopBar({ orgId, orgs }: TopBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end md:justify-between w-full h-full">
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<Link
|
||||
href="https://docs.fossorial.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:support@fossorial.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-bottom-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
@ -10,24 +10,29 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface InfoPopupProps {
|
||||
text: string;
|
||||
text?: string;
|
||||
info: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function InfoPopup({ text, info }: InfoPopupProps) {
|
||||
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">Show info</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{text}</span>
|
||||
{text && <span>{text}</span>}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full p-0"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="sr-only">Show info</span>
|
||||
</Button>
|
||||
{trigger ?? defaultTrigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
|
|
|
@ -8,8 +8,8 @@ import { cn } from "@app/lib/cn"
|
|||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean }
|
||||
>(({ className, containerClassName, obscured = false, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
|
@ -32,8 +32,8 @@ InputOTPGroup.displayName = "InputOTPGroup"
|
|||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean }
|
||||
>(({ index, className, obscured = false, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
|
@ -47,7 +47,7 @@ const InputOTPSlot = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{char && obscured ? "•" : char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
|
|
|
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -29,7 +29,7 @@ const toastVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
default: "border bg-card text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
|
|
|
@ -9,10 +9,10 @@ const patterns: PatternConfig[] = [
|
|||
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
|
||||
];
|
||||
|
||||
export function cleanRedirect(input: string): string {
|
||||
export function cleanRedirect(input: string, fallback?: string): string {
|
||||
if (!input || typeof input !== "string") {
|
||||
return "/";
|
||||
}
|
||||
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
|
||||
return isAccepted ? input : "/";
|
||||
return isAccepted ? input : fallback || "/";
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue