mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-04 02:54:59 +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/components": "0.0.36",
|
||||||
"@react-email/render": "^1.0.6",
|
"@react-email/render": "^1.0.6",
|
||||||
"@react-email/tailwind": "1.0.4",
|
"@react-email/tailwind": "1.0.4",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
|
"arctic": "^3.6.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
|
@ -51,7 +55,9 @@
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.469.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
|
@ -71,7 +77,7 @@
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
|
@ -87,7 +93,9 @@
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/react": "19.1.1",
|
"@types/react": "19.1.1",
|
||||||
|
@ -101,7 +109,7 @@
|
||||||
"esbuild-node-externals": "1.18.0",
|
"esbuild-node-externals": "1.18.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react-email": "4.0.6",
|
"react-email": "4.0.6",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.10",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
@ -2756,6 +2764,21 @@
|
||||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||||
|
@ -4048,6 +4071,18 @@
|
||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
|
||||||
|
@ -4061,6 +4096,13 @@
|
||||||
"tailwindcss": "4.1.3"
|
"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": {
|
"node_modules/@tailwindcss/oxide": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
|
||||||
|
@ -4285,6 +4327,13 @@
|
||||||
"tailwindcss": "4.1.3"
|
"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": {
|
"node_modules/@tanstack/react-table": {
|
||||||
"version": "8.20.6",
|
"version": "8.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||||
|
@ -4418,6 +4467,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/js-yaml": {
|
||||||
"version": "4.0.9",
|
"version": "4.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
@ -4437,6 +4493,17 @@
|
||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
|
@ -4444,6 +4511,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.14.1",
|
"version": "22.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||||
|
@ -5111,6 +5185,17 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
@ -5552,6 +5637,12 @@
|
||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
@ -5986,12 +6077,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-parser": {
|
"node_modules/cookie-parser": {
|
||||||
|
@ -6007,12 +6098,34 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
|
@ -6988,6 +7101,15 @@
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/eciesjs": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz",
|
||||||
|
@ -7073,6 +7195,16 @@
|
||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/engine.io/node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
|
@ -9361,6 +9493,15 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
@ -9422,6 +9563,28 @@
|
||||||
"json5": "lib/cli.js"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
|
@ -9437,6 +9600,39 @@
|
||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
@ -9746,12 +9942,54 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||||
|
@ -9995,6 +10233,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
|
@ -15615,20 +15862,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||||
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
|
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||||
|
@ -15841,6 +16079,15 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.19.3",
|
"version": "4.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
|
||||||
|
@ -15873,6 +16120,15 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"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/components": "0.0.36",
|
||||||
"@react-email/render": "^1.0.6",
|
"@react-email/render": "^1.0.6",
|
||||||
"@react-email/tailwind": "1.0.4",
|
"@react-email/tailwind": "1.0.4",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-table": "8.20.6",
|
"@tanstack/react-table": "8.20.6",
|
||||||
|
"arctic": "^3.6.0",
|
||||||
"axios": "1.8.4",
|
"axios": "1.8.4",
|
||||||
"better-sqlite3": "11.7.0",
|
"better-sqlite3": "11.7.0",
|
||||||
"canvas-confetti": "1.9.3",
|
"canvas-confetti": "1.9.3",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
|
@ -62,7 +66,9 @@
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"i": "^0.3.7",
|
"i": "^0.3.7",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
|
"jmespath": "^0.16.0",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "0.469.0",
|
"lucide-react": "0.469.0",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
|
@ -82,7 +88,7 @@
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
|
@ -98,7 +104,9 @@
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/react": "19.1.1",
|
"@types/react": "19.1.1",
|
||||||
|
@ -112,7 +120,7 @@
|
||||||
"esbuild-node-externals": "1.18.0",
|
"esbuild-node-externals": "1.18.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"react-email": "4.0.6",
|
"react-email": "4.0.6",
|
||||||
"tailwindcss": "^4.1.3",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.10",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|
|
@ -6,6 +6,9 @@ import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
export enum ActionsEnum {
|
export enum ActionsEnum {
|
||||||
|
createOrgUser = "createOrgUser",
|
||||||
|
listOrgs = "listOrgs",
|
||||||
|
listUserOrgs = "listUserOrgs",
|
||||||
createOrg = "createOrg",
|
createOrg = "createOrg",
|
||||||
// deleteOrg = "deleteOrg",
|
// deleteOrg = "deleteOrg",
|
||||||
getOrg = "getOrg",
|
getOrg = "getOrg",
|
||||||
|
@ -65,7 +68,16 @@ export enum ActionsEnum {
|
||||||
listResourceRules = "listResourceRules",
|
listResourceRules = "listResourceRules",
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
listOrgDomains = "listOrgDomains",
|
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(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -111,8 +111,14 @@ export const exitNodes = sqliteTable("exitNodes", {
|
||||||
|
|
||||||
export const users = sqliteTable("user", {
|
export const users = sqliteTable("user", {
|
||||||
userId: text("id").primaryKey(),
|
userId: text("id").primaryKey(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email"),
|
||||||
passwordHash: text("passwordHash").notNull(),
|
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" })
|
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
@ -420,6 +426,38 @@ export const supporterKey = sqliteTable("supporterKey", {
|
||||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
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 Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -455,3 +493,4 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||||
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
|
|
|
@ -91,7 +91,19 @@ const configSchema = z.object({
|
||||||
credentials: z.boolean().optional()
|
credentials: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.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({
|
traefik: z.object({
|
||||||
http_entrypoint: z.string(),
|
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;
|
||||||
|
}
|
|
@ -15,3 +15,4 @@ export * from "./verifySetResourceUsers";
|
||||||
export * from "./verifyUserInRole";
|
export * from "./verifyUserInRole";
|
||||||
export * from "./verifyAccessTokenAccess";
|
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",
|
Invitation = "Invitation",
|
||||||
Target = "Target",
|
Target = "Target",
|
||||||
Rule = "Rule",
|
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 { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const changePasswordBody = z
|
export const changePasswordBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -50,6 +51,15 @@ export async function changePassword(
|
||||||
const { newPassword, oldPassword, code } = parsedBody.data;
|
const { newPassword, oldPassword, code } = parsedBody.data;
|
||||||
const user = req.user as User;
|
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 {
|
try {
|
||||||
if (newPassword === oldPassword) {
|
if (newPassword === oldPassword) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -62,7 +72,7 @@ export async function changePassword(
|
||||||
|
|
||||||
const validPassword = await verifyPassword(
|
const validPassword = await verifyPassword(
|
||||||
oldPassword,
|
oldPassword,
|
||||||
user.passwordHash
|
user.passwordHash!
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
|
||||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const disable2faBody = z
|
export const disable2faBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -47,8 +48,17 @@ export async function disable2fa(
|
||||||
const { password, code } = parsedBody.data;
|
const { password, code } = parsedBody.data;
|
||||||
const user = req.user as User;
|
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 {
|
try {
|
||||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
@ -99,11 +109,11 @@ export async function disable2fa(
|
||||||
|
|
||||||
sendEmail(
|
sendEmail(
|
||||||
TwoFactorAuthNotification({
|
TwoFactorAuthNotification({
|
||||||
email: user.email,
|
email: user.email!, // email is not null because we are checking user.type
|
||||||
enabled: false
|
enabled: false
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: user.email,
|
to: user.email!,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getRawConfig().email?.no_reply,
|
||||||
subject: "Two-factor authentication disabled"
|
subject: "Two-factor authentication disabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import db from "@server/db";
|
||||||
import { users } from "@server/db/schemas";
|
import { users } from "@server/db/schemas";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -17,6 +17,7 @@ import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const loginBodySchema = z
|
export const loginBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -69,7 +70,9 @@ export async function login(
|
||||||
const existingUserRes = await db
|
const existingUserRes = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email));
|
.where(
|
||||||
|
and(eq(users.type, UserType.Internal), eq(users.email, email))
|
||||||
|
);
|
||||||
if (!existingUserRes || !existingUserRes.length) {
|
if (!existingUserRes || !existingUserRes.length) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
@ -88,7 +91,7 @@ export async function login(
|
||||||
|
|
||||||
const validPassword = await verifyPassword(
|
const validPassword = await verifyPassword(
|
||||||
password,
|
password,
|
||||||
existingUser.passwordHash
|
existingUser.passwordHash!
|
||||||
);
|
);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
|
||||||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export type RequestEmailVerificationCodeResponse = {
|
export type RequestEmailVerificationCodeResponse = {
|
||||||
codeSent: boolean;
|
codeSent: boolean;
|
||||||
|
@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
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) {
|
if (user.emailVerified) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
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, {
|
return response<RequestEmailVerificationCodeResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -74,7 +74,7 @@ export async function requestPasswordReset(
|
||||||
|
|
||||||
await trx.insert(passwordResetTokens).values({
|
await trx.insert(passwordResetTokens).values({
|
||||||
userId: existingUser[0].userId,
|
userId: existingUser[0].userId,
|
||||||
email: existingUser[0].email,
|
email: existingUser[0].email!,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const requestTotpSecretBody = z
|
export const requestTotpSecretBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -46,8 +47,17 @@ export async function requestTotpSecret(
|
||||||
|
|
||||||
const user = req.user as User;
|
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 {
|
try {
|
||||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
const validPassword = await verifyPassword(password, user.passwordHash!);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return next(unauthorized());
|
return next(unauthorized());
|
||||||
}
|
}
|
||||||
|
@ -63,7 +73,7 @@ export async function requestTotpSecret(
|
||||||
|
|
||||||
const hex = crypto.getRandomValues(new Uint8Array(20));
|
const hex = crypto.getRandomValues(new Uint8Array(20));
|
||||||
const secret = encodeHex(hex);
|
const secret = encodeHex(hex);
|
||||||
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
|
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
|
|
|
@ -8,7 +8,7 @@ import createHttpError from "http-errors";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { SqliteError } from "better-sqlite3";
|
import { SqliteError } from "better-sqlite3";
|
||||||
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
|
@ -21,6 +21,7 @@ import logger from "@server/logger";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
|
@ -110,7 +111,9 @@ export async function signup(
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email));
|
.where(
|
||||||
|
and(eq(users.email, email), eq(users.type, UserType.Internal))
|
||||||
|
);
|
||||||
|
|
||||||
if (existing && existing.length > 0) {
|
if (existing && existing.length > 0) {
|
||||||
if (!config.getRawConfig().flags?.require_email_verification) {
|
if (!config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
@ -157,6 +160,8 @@ export async function signup(
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
type: UserType.Internal,
|
||||||
|
username: email,
|
||||||
email: email,
|
email: email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
|
|
|
@ -14,6 +14,7 @@ import logger from "@server/logger";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const verifyTotpBody = z
|
export const verifyTotpBody = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -48,6 +49,15 @@ export async function verifyTotp(
|
||||||
|
|
||||||
const user = req.user as User;
|
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) {
|
if (user.twoFactorEnabled) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -111,11 +121,11 @@ export async function verifyTotp(
|
||||||
|
|
||||||
sendEmail(
|
sendEmail(
|
||||||
TwoFactorAuthNotification({
|
TwoFactorAuthNotification({
|
||||||
email: user.email,
|
email: user.email!,
|
||||||
enabled: true
|
enabled: true
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: user.email,
|
to: user.email!,
|
||||||
from: config.getRawConfig().email?.no_reply,
|
from: config.getRawConfig().email?.no_reply,
|
||||||
subject: "Two-factor authentication enabled"
|
subject: "Two-factor authentication enabled"
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import * as auth from "./auth";
|
||||||
import * as role from "./role";
|
import * as role from "./role";
|
||||||
import * as supporterKey from "./supporterKey";
|
import * as supporterKey from "./supporterKey";
|
||||||
import * as accessToken from "./accessToken";
|
import * as accessToken from "./accessToken";
|
||||||
|
import * as idp from "./idp";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
|
@ -24,7 +25,8 @@ import {
|
||||||
verifySetResourceUsers,
|
verifySetResourceUsers,
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
getUserOrgs,
|
getUserOrgs,
|
||||||
verifyUserIsServerAdmin
|
verifyUserIsServerAdmin,
|
||||||
|
verifyIsLoggedInUser
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
@ -46,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
|
||||||
|
|
||||||
authenticated.get("/org/checkId", org.checkId);
|
authenticated.get("/org/checkId", org.checkId);
|
||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -443,7 +448,15 @@ authenticated.delete(
|
||||||
user.adminRemoveUser
|
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/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/users",
|
"/org/:orgId/users",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -493,6 +506,24 @@ authenticated.delete(
|
||||||
// createNewt
|
// 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
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
@ -582,3 +613,7 @@ authRouter.post(
|
||||||
);
|
);
|
||||||
|
|
||||||
authRouter.post("/access-token", resource.authWithAccessToken);
|
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 "./createOrg";
|
||||||
export * from "./deleteOrg";
|
export * from "./deleteOrg";
|
||||||
export * from "./updateOrg";
|
export * from "./updateOrg";
|
||||||
export * from "./listOrgs";
|
export * from "./listUserOrgs";
|
||||||
export * from "./checkId";
|
export * from "./checkId";
|
||||||
export * from "./getOrgOverview";
|
export * from "./getOrgOverview";
|
||||||
|
export * from "./listOrgs";
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, inArray } from "drizzle-orm";
|
import { sql, inArray, eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/orgs",
|
path: "/user/:userId/orgs",
|
||||||
description: "List all organizations in the system",
|
description: "List all organizations in the system.",
|
||||||
tags: [OpenAPITags.Org],
|
tags: [OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
query: listOrgsSchema
|
query: listOrgsSchema
|
||||||
|
@ -59,37 +59,15 @@ export async function listOrgs(
|
||||||
|
|
||||||
const { limit, offset } = parsedQuery.data;
|
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
|
const organizations = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
.where(inArray(orgs.orgId, userOrgIds))
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
const totalCountResult = await db
|
const totalCountResult = await db
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
.from(orgs)
|
.from(orgs);
|
||||||
.where(inArray(orgs.orgId, userOrgIds));
|
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
return response<ListOrgsResponse>(res, {
|
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(),
|
isBaseDomain: z.boolean().optional(),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.string(),
|
|
||||||
domainId: z.string()
|
domainId: z.string()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
@ -203,7 +202,7 @@ async function createHttpResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
const { name, subdomain, isBaseDomain, http, domainId } =
|
||||||
parsedBody.data;
|
parsedBody.data;
|
||||||
|
|
||||||
const [orgDomain] = await db
|
const [orgDomain] = await db
|
||||||
|
@ -262,7 +261,7 @@ async function createHttpResource(
|
||||||
name,
|
name,
|
||||||
subdomain,
|
subdomain,
|
||||||
http,
|
http,
|
||||||
protocol,
|
protocol: "tcp",
|
||||||
ssl: true,
|
ssl: true,
|
||||||
isBaseDomain
|
isBaseDomain
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
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 { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select({
|
||||||
userId: userResources.userId,
|
userId: userResources.userId,
|
||||||
|
username: users.username,
|
||||||
|
type: users.type,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpId: users.idpId,
|
||||||
email: users.email
|
email: users.email
|
||||||
})
|
})
|
||||||
.from(userResources)
|
.from(userResources)
|
||||||
.innerJoin(users, eq(userResources.userId, users.userId))
|
.innerJoin(users, eq(userResources.userId, users.userId))
|
||||||
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
.where(eq(userResources.resourceId, resourceId));
|
.where(eq(userResources.resourceId, resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, eq } from "drizzle-orm";
|
import { sql, eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { users } from "@server/db/schemas";
|
import { idp, users } from "@server/db/schemas";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
const listUsersSchema = z
|
const listUsersSchema = z
|
||||||
|
@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
|
||||||
.select({
|
.select({
|
||||||
id: users.userId,
|
id: users.userId,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
username: users.username,
|
||||||
|
name: users.name,
|
||||||
dateCreated: users.dateCreated,
|
dateCreated: users.dateCreated,
|
||||||
serverAdmin: users.serverAdmin
|
serverAdmin: users.serverAdmin,
|
||||||
|
type: users.type,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpId: users.idpId
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
.where(eq(users.serverAdmin, false))
|
.where(eq(users.serverAdmin, false))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.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,
|
orgId: userOrgs.orgId,
|
||||||
userId: users.userId,
|
userId: users.userId,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
username: users.username,
|
||||||
|
name: users.name,
|
||||||
|
type: users.type,
|
||||||
roleId: userOrgs.roleId,
|
roleId: userOrgs.roleId,
|
||||||
roleName: roles.name,
|
roleName: roles.name,
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db/schemas";
|
import { idp, users } from "@server/db/schemas";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -13,11 +13,17 @@ async function queryUser(userId: string) {
|
||||||
.select({
|
.select({
|
||||||
userId: users.userId,
|
userId: users.userId,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
username: users.username,
|
||||||
|
name: users.name,
|
||||||
|
type: users.type,
|
||||||
twoFactorEnabled: users.twoFactorEnabled,
|
twoFactorEnabled: users.twoFactorEnabled,
|
||||||
emailVerified: users.emailVerified,
|
emailVerified: users.emailVerified,
|
||||||
serverAdmin: users.serverAdmin
|
serverAdmin: users.serverAdmin,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpId: users.idpId
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
.where(eq(users.userId, userId))
|
.where(eq(users.userId, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return user;
|
return user;
|
||||||
|
|
|
@ -9,3 +9,4 @@ export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
export * from "./removeInvitation";
|
||||||
|
export * from "./createOrgUser";
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
||||||
|
|
||||||
|
@ -115,7 +116,13 @@ export async function inviteUser(
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (existingUser.length) {
|
if (existingUser.length) {
|
||||||
|
@ -190,7 +197,7 @@ export async function inviteUser(
|
||||||
inviteLink,
|
inviteLink,
|
||||||
expiresInDays: (validHours / 24).toString(),
|
expiresInDays: (validHours / 24).toString(),
|
||||||
orgName: org[0].name || orgId,
|
orgName: org[0].name || orgId,
|
||||||
inviterName: req.user?.email
|
inviterName: req.user?.email || req.user?.username
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
|
@ -242,7 +249,7 @@ export async function inviteUser(
|
||||||
inviteLink,
|
inviteLink,
|
||||||
expiresInDays: (validHours / 24).toString(),
|
expiresInDays: (validHours / 24).toString(),
|
||||||
orgName: org[0].name || orgId,
|
orgName: org[0].name || orgId,
|
||||||
inviterName: req.user?.email
|
inviterName: req.user?.email || req.user?.username
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql } from "drizzle-orm";
|
import { and, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const listUsersParamsSchema = z
|
const listUsersParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
emailVerified: users.emailVerified,
|
emailVerified: users.emailVerified,
|
||||||
dateCreated: users.dateCreated,
|
dateCreated: users.dateCreated,
|
||||||
orgId: userOrgs.orgId,
|
orgId: userOrgs.orgId,
|
||||||
|
username: users.username,
|
||||||
|
name: users.name,
|
||||||
|
type: users.type,
|
||||||
roleId: userOrgs.roleId,
|
roleId: userOrgs.roleId,
|
||||||
roleName: roles.name,
|
roleName: roles.name,
|
||||||
isOwner: userOrgs.isOwner
|
isOwner: userOrgs.isOwner,
|
||||||
|
idpName: idp.name,
|
||||||
|
idpId: users.idpId
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)
|
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`)
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
.where(sql`${userOrgs.orgId} = ${orgId}`)
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
|
.where(eq(userOrgs.orgId, orgId))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
}
|
}
|
||||||
|
@ -107,7 +114,8 @@ export async function listUsers(
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(users);
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.orgId, orgId));
|
||||||
|
|
||||||
return response<ListUsersResponse>(res, {
|
return response<ListUsersResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export async function setupServerAdmin() {
|
export async function setupServerAdmin() {
|
||||||
const {
|
const {
|
||||||
|
@ -34,7 +35,7 @@ export async function setupServerAdmin() {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const passwordChanged = !(await verifyPassword(
|
const passwordChanged = !(await verifyPassword(
|
||||||
password,
|
password,
|
||||||
existing.passwordHash
|
existing.passwordHash!
|
||||||
));
|
));
|
||||||
|
|
||||||
if (passwordChanged) {
|
if (passwordChanged) {
|
||||||
|
@ -65,6 +66,8 @@ export async function setupServerAdmin() {
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
email: email,
|
email: email,
|
||||||
|
type: UserType.Internal,
|
||||||
|
username: email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
serverAdmin: true,
|
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 { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { orgNavItems } from "../navigation";
|
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
|
||||||
type OrgPageProps = {
|
type OrgPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
redirect(`/${orgId}/settings`);
|
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 (
|
return (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout
|
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||||
orgId={orgId}
|
|
||||||
navItems={orgNavItems}
|
|
||||||
>
|
|
||||||
{overview && (
|
{overview && (
|
||||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||||
<OrganizationLandingCard
|
<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..."
|
searchPlaceholder="Search users..."
|
||||||
searchColumn="email"
|
searchColumn="email"
|
||||||
onAdd={inviteUser}
|
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 { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||||
import { UsersDataTable } from "./UsersDataTable";
|
import { UsersDataTable } from "./UsersDataTable";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import InviteUserForm from "./InviteUserForm";
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
@ -24,7 +23,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
export type UserRow = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
|
displayUsername: string | null;
|
||||||
|
username: string;
|
||||||
|
name: string | null;
|
||||||
|
idpId: number | null;
|
||||||
|
idpName: string;
|
||||||
|
type: string;
|
||||||
status: string;
|
status: string;
|
||||||
role: string;
|
role: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
@ -35,16 +40,11 @@ type UsersTableProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ users: u }: UsersTableProps) {
|
export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
|
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserRow[]>(u);
|
const [users, setUsers] = useState<UserRow[]>(u);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const { user, updateUser } = useUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
|
@ -82,7 +82,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
Manage User
|
Manage User
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
{userRow.email !== user?.email && (
|
{`${userRow.username}-${userRow.idpId}` !==
|
||||||
|
`${user?.username}-${userRow.idpId}` && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(
|
setIsDeleteModalOpen(
|
||||||
|
@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "displayUsername",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -117,14 +118,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Email
|
Username
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "idpName",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -133,7 +134,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Status
|
Identity Provider
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
<Link
|
<Link
|
||||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||||
>
|
>
|
||||||
<Button variant={"outlinePrimary"} className="ml-2">
|
<Button
|
||||||
|
variant={"outlinePrimary"}
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
Manage
|
Manage
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to remove{" "}
|
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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
To confirm, please type the email address of the
|
To confirm, please type the name of the of the user
|
||||||
user below.
|
below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText="Confirm Remove User"
|
buttonText="Confirm Remove User"
|
||||||
onConfirm={removeUser}
|
onConfirm={removeUser}
|
||||||
string={selectedUser?.email ?? ""}
|
string={
|
||||||
|
selectedUser?.email ||
|
||||||
|
selectedUser?.name ||
|
||||||
|
selectedUser?.username ||
|
||||||
|
""
|
||||||
|
}
|
||||||
title="Remove User from Organization"
|
title="Remove User from Organization"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InviteUserForm
|
|
||||||
open={isInviteModalOpen}
|
|
||||||
setOpen={setIsInviteModalOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UsersDataTable
|
<UsersDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={users}
|
data={users}
|
||||||
inviteUser={() => {
|
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) => {
|
const userRows: UserRow[] = users.map((user) => {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayUsername: user.email || user.name || user.username,
|
||||||
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
type: user.type,
|
||||||
|
idpId: user.idpId,
|
||||||
|
idpName: user.idpName || "Internal",
|
||||||
status: "Confirmed",
|
status: "Confirmed",
|
||||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
||||||
isOwner: user.isOwner || false
|
isOwner: user.isOwner || false
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { AxiosResponse } from "axios";
|
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 { redirect, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
@ -43,6 +43,7 @@ import {
|
||||||
SettingsSectionForm,
|
SettingsSectionForm,
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
|
@ -57,6 +58,7 @@ export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const { user } = useUserContext();
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
|
@ -101,7 +103,9 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
async function pickNewOrgAndNavigate() {
|
async function pickNewOrgAndNavigate() {
|
||||||
try {
|
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.status === 200) {
|
||||||
if (res.data.data.orgs.length > 0) {
|
if (res.data.data.orgs.length > 0) {
|
||||||
|
@ -237,9 +241,7 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||||
Danger Zone
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
Once you delete this org, there is no going back. Please
|
Once you delete this org, there is no going back. Please
|
||||||
be certain.
|
be certain.
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
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 { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
import { GetOrgUserResponse } from "@server/routers/user";
|
||||||
|
@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
redirect(`/${params.orgId}`);
|
redirect(`/${params.orgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgs: ListOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(() =>
|
const getOrgs = cache(() =>
|
||||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`,
|
||||||
|
cookie
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const res = await getOrgs();
|
const res = await getOrgs();
|
||||||
if (res && res.data.data.orgs) {
|
if (res && res.data.data.orgs) {
|
||||||
|
|
|
@ -21,10 +21,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import CreateResourceForm from "./CreateResourceForm";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { set } from "zod";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
@ -58,10 +56,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedResource, setSelectedResource] =
|
const [selectedResource, setSelectedResource] = useState<ResourceRow | null>();
|
||||||
useState<ResourceRow | null>();
|
|
||||||
|
|
||||||
const deleteResource = (resourceId: number) => {
|
const deleteResource = (resourceId: number) => {
|
||||||
api.delete(`/resource/${resourceId}`)
|
api.delete(`/resource/${resourceId}`)
|
||||||
|
@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateResourceForm
|
|
||||||
open={isCreateModalOpen}
|
|
||||||
setOpen={setIsCreateModalOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedResource && (
|
{selectedResource && (
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
|
@ -328,7 +319,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={resources}
|
data={resources}
|
||||||
createResource={() => {
|
createResource={() => {
|
||||||
setIsCreateModalOpen(true);
|
router.push(`/${orgId}/settings/resources/create`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
|
||||||
Users will be able to access
|
|
||||||
this resource by entering this
|
|
||||||
password. It must be at least 4
|
|
||||||
characters long.
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
|
||||||
<InputOTPGroup className="flex">
|
<InputOTPGroup className="flex">
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={0}
|
index={0}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={1}
|
index={1}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={2}
|
index={2}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={3}
|
index={3}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={4}
|
index={4}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={5}
|
index={5}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
</InputOTPGroup>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<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>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
import { useRouter } from "next/navigation";
|
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({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
|
@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
setAllUsers(
|
setAllUsers(
|
||||||
usersResponse.data.data.users.map((user) => ({
|
usersResponse.data.data.users.map((user) => ({
|
||||||
id: user.id.toString(),
|
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",
|
"users",
|
||||||
resourceUsersResponse.data.data.users.map((i) => ({
|
resourceUsersResponse.data.data.users.map((i) => ({
|
||||||
id: i.userId.toString(),
|
id: i.userId.toString(),
|
||||||
text: i.email
|
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -611,7 +614,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{env.email.emailEnabled && (
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
|
@ -623,14 +625,26 @@ export default function ResourceAuthenticationPage() {
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<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
|
<SwitchInput
|
||||||
id="whitelist-toggle"
|
id="whitelist-toggle"
|
||||||
label="Email Whitelist"
|
label="Email Whitelist"
|
||||||
defaultChecked={resource.emailWhitelistEnabled}
|
defaultChecked={resource.emailWhitelistEnabled}
|
||||||
onCheckedChange={setWhitelistEnabled}
|
onCheckedChange={setWhitelistEnabled}
|
||||||
|
disabled={!env.email.emailEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{whitelistEnabled && (
|
{whitelistEnabled && env.email.emailEnabled && (
|
||||||
<Form {...whitelistForm}>
|
<Form {...whitelistForm}>
|
||||||
<form id="whitelist-form">
|
<form id="whitelist-form">
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -721,7 +735,6 @@ export default function ResourceAuthenticationPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, buttonVariants } from "@app/components/ui/button";
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -10,48 +18,22 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { useEffect, useState } from "react";
|
||||||
Credenza,
|
import { Controller, useForm } from "react-hook-form";
|
||||||
CredenzaBody,
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
CredenzaClose,
|
import { Input } from "@app/components/ui/input";
|
||||||
CredenzaContent,
|
import { Button } from "@app/components/ui/button";
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { CheckIcon } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "@app/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "@app/components/ui/command";
|
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
|
||||||
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { Resource } from "@server/db/schemas";
|
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { AxiosResponse } from "axios";
|
||||||
|
import { Resource } from "@server/db/schemas";
|
||||||
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
@ -60,118 +42,189 @@ import {
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import Link from "next/link";
|
|
||||||
import { SquareArrowOutUpRight } from "lucide-react";
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { ListDomainsResponse } from "@server/routers/domain";
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
const createResourceFormSchema = z
|
const baseResourceFormSchema = z.object({
|
||||||
.object({
|
|
||||||
subdomain: z.string().optional(),
|
|
||||||
domainId: z.string().min(1).optional(),
|
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean()
|
||||||
protocol: z.string(),
|
});
|
||||||
proxyPort: z.number().optional(),
|
|
||||||
isBaseDomain: z.boolean().optional()
|
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
|
||||||
|
z.object({
|
||||||
|
isBaseDomain: z.literal(true),
|
||||||
|
domainId: z.string().min(1)
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
isBaseDomain: z.literal(false),
|
||||||
|
domainId: z.string().min(1),
|
||||||
|
subdomain: z.string().pipe(subdomainSchema)
|
||||||
})
|
})
|
||||||
.refine(
|
]);
|
||||||
(data) => {
|
|
||||||
if (!data.http) {
|
|
||||||
return z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(65535)
|
|
||||||
.safeParse(data.proxyPort).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid port number",
|
|
||||||
path: ["proxyPort"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.http && !data.isBaseDomain) {
|
|
||||||
return subdomainSchema.safeParse(data.subdomain).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid subdomain",
|
|
||||||
path: ["subdomain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
|
const tcpUdpResourceFormSchema = z.object({
|
||||||
|
protocol: z.string(),
|
||||||
|
proxyPort: z.number().int().min(1).max(65535)
|
||||||
|
});
|
||||||
|
|
||||||
type CreateResourceFormProps = {
|
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
|
||||||
open: boolean;
|
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
|
||||||
setOpen: (open: boolean) => void;
|
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreateResourceForm({
|
type ResourceType = "http" | "raw";
|
||||||
open,
|
|
||||||
setOpen
|
|
||||||
}: CreateResourceFormProps) {
|
|
||||||
const [formKey, setFormKey] = useState(0);
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
interface ResourceTypeOption {
|
||||||
const params = useParams();
|
id: ResourceType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const orgId = params.orgId;
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { orgId } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const { env } = useEnvContext();
|
|
||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||||
const [baseDomains, setBaseDomains] = useState<
|
const [baseDomains, setBaseDomains] = useState<
|
||||||
{ domainId: string; baseDomain: string }[]
|
{ domainId: string; baseDomain: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [resourceId, setResourceId] = useState<number | null>(null);
|
|
||||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
|
||||||
"subdomain"
|
|
||||||
);
|
|
||||||
const [loadingPage, setLoadingPage] = useState(true);
|
|
||||||
|
|
||||||
const form = useForm<CreateResourceFormValues>({
|
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
|
||||||
resolver: zodResolver(createResourceFormSchema),
|
{
|
||||||
|
id: "http",
|
||||||
|
title: "HTTPS Resource",
|
||||||
|
description:
|
||||||
|
"Proxy requests to your app over HTTPS using a subdomain or base domain."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "raw",
|
||||||
|
title: "Raw TCP/UDP Resource",
|
||||||
|
description:
|
||||||
|
"Proxy requests to your app over TCP/UDP using a port number.",
|
||||||
|
disabled: !env.flags.allowRawResources
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseForm = useForm<BaseResourceFormValues>({
|
||||||
|
resolver: zodResolver(baseResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
subdomain: "",
|
|
||||||
domainId: "",
|
|
||||||
name: "",
|
name: "",
|
||||||
http: true,
|
http: true
|
||||||
protocol: "tcp"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
const httpForm = useForm<HttpResourceFormValues>({
|
||||||
form.reset();
|
resolver: zodResolver(httpResourceFormSchema),
|
||||||
setSites([]);
|
defaultValues: {
|
||||||
setShowSnippets(false);
|
subdomain: "",
|
||||||
setResourceId(null);
|
domainId: "",
|
||||||
|
isBaseDomain: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
|
||||||
|
resolver: zodResolver(tcpUdpResourceFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
protocol: "tcp",
|
||||||
|
proxyPort: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
setCreateLoading(true);
|
||||||
|
|
||||||
|
const baseData = baseForm.getValues();
|
||||||
|
const isHttp = baseData.http;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: baseData.name,
|
||||||
|
siteId: baseData.siteId,
|
||||||
|
http: baseData.http
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHttp) {
|
||||||
|
const httpData = httpForm.getValues();
|
||||||
|
if (httpData.isBaseDomain) {
|
||||||
|
Object.assign(payload, {
|
||||||
|
domainId: httpData.domainId,
|
||||||
|
isBaseDomain: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(payload, {
|
||||||
|
subdomain: httpData.subdomain,
|
||||||
|
domainId: httpData.domainId,
|
||||||
|
isBaseDomain: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tcpUdpData = tcpUdpForm.getValues();
|
||||||
|
Object.assign(payload, {
|
||||||
|
protocol: tcpUdpData.protocol,
|
||||||
|
proxyPort: tcpUdpData.proxyPort
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.put<
|
||||||
|
AxiosResponse<Resource>
|
||||||
|
>(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating resource",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred when creating the resource"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const id = res.data.data.resourceId;
|
||||||
|
router.push(`/${orgId}/settings/resources/${id}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error creating resource:", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating resource",
|
||||||
|
description: "An unexpected error occurred"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
const load = async () => {
|
||||||
return;
|
setLoadingPage(true);
|
||||||
}
|
|
||||||
|
|
||||||
reset();
|
|
||||||
|
|
||||||
const fetchSites = async () => {
|
const fetchSites = async () => {
|
||||||
const res = await api
|
const res = await api
|
||||||
.get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`)
|
.get<
|
||||||
|
AxiosResponse<ListSitesResponse>
|
||||||
|
>(`/org/${orgId}/sites/`)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
@ -187,7 +240,10 @@ export default function CreateResourceForm({
|
||||||
setSites(res.data.data.sites);
|
setSites(res.data.data.sites);
|
||||||
|
|
||||||
if (res.data.data.sites.length > 0) {
|
if (res.data.data.sites.length > 0) {
|
||||||
form.setValue("siteId", res.data.data.sites[0].siteId);
|
baseForm.setValue(
|
||||||
|
"siteId",
|
||||||
|
res.data.data.sites[0].siteId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -212,119 +268,55 @@ export default function CreateResourceForm({
|
||||||
const domains = res.data.data.domains;
|
const domains = res.data.data.domains;
|
||||||
setBaseDomains(domains);
|
setBaseDomains(domains);
|
||||||
if (domains.length) {
|
if (domains.length) {
|
||||||
form.setValue("domainId", domains[0].domainId);
|
httpForm.setValue("domainId", domains[0].domainId);
|
||||||
setFormKey((k) => k + 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoadingPage(true);
|
|
||||||
|
|
||||||
await fetchSites();
|
await fetchSites();
|
||||||
await fetchDomains();
|
await fetchDomains();
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
|
||||||
|
|
||||||
setLoadingPage(false);
|
setLoadingPage(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [open]);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(data: CreateResourceFormValues) {
|
|
||||||
const res = await api
|
|
||||||
.put<AxiosResponse<Resource>>(
|
|
||||||
`/org/${orgId}/site/${data.siteId}/resource/`,
|
|
||||||
{
|
|
||||||
name: data.name,
|
|
||||||
subdomain: data.http ? data.subdomain : undefined,
|
|
||||||
domainId: data.http ? data.domainId : undefined,
|
|
||||||
http: data.http,
|
|
||||||
protocol: data.protocol,
|
|
||||||
proxyPort: data.http ? undefined : data.proxyPort,
|
|
||||||
siteId: data.siteId,
|
|
||||||
isBaseDomain: data.http ? data.isBaseDomain : undefined
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error creating resource",
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
"An error occurred when creating the resource"
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
|
||||||
const id = res.data.data.resourceId;
|
|
||||||
setResourceId(id);
|
|
||||||
|
|
||||||
if (data.http) {
|
|
||||||
goToResource(id);
|
|
||||||
} else {
|
|
||||||
setShowSnippets(true);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToResource(id?: number) {
|
|
||||||
// navigate to the resource page
|
|
||||||
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const launchOptions = [
|
|
||||||
{
|
|
||||||
id: "http",
|
|
||||||
title: "HTTPS Resource",
|
|
||||||
description:
|
|
||||||
"Proxy requests to your app over HTTPS using a subdomain or base domain."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "raw",
|
|
||||||
title: "Raw TCP/UDP Resource",
|
|
||||||
description:
|
|
||||||
"Proxy requests to your app over TCP/UDP using a port number."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Credenza
|
<div className="flex justify-between">
|
||||||
open={open}
|
<HeaderTitle
|
||||||
onOpenChange={(val) => {
|
title="Create Resource"
|
||||||
setOpen(val);
|
description="Follow the steps below to create a new resource"
|
||||||
setLoading(false);
|
/>
|
||||||
|
<Button
|
||||||
// reset all values
|
variant="outline"
|
||||||
form.reset();
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/resources`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
See All Resources
|
||||||
<CredenzaHeader>
|
</Button>
|
||||||
<CredenzaTitle>Create Resource</CredenzaTitle>
|
</div>
|
||||||
<CredenzaDescription>
|
|
||||||
Create a new resource to proxy requests to your app
|
{!loadingPage && (
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
{loadingPage ? (
|
|
||||||
<LoaderPlaceholder height="300px" />
|
|
||||||
) : (
|
|
||||||
<div>
|
<div>
|
||||||
{!showSnippets && (
|
<SettingsContainer>
|
||||||
<Form {...form} key={formKey}>
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Resource Information
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...baseForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(
|
|
||||||
onSubmit
|
|
||||||
)}
|
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-resource-form"
|
id="base-resource-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={baseForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -335,12 +327,17 @@ export default function CreateResourceForm({
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
This is the display
|
||||||
|
name for the
|
||||||
|
resource.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={baseForm.control}
|
||||||
name="siteId"
|
name="siteId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col">
|
<FormItem className="flex flex-col">
|
||||||
|
@ -395,7 +392,7 @@ export default function CreateResourceForm({
|
||||||
site.siteId
|
site.siteId
|
||||||
}
|
}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue(
|
baseForm.setValue(
|
||||||
"siteId",
|
"siteId",
|
||||||
site.siteId
|
site.siteId
|
||||||
);
|
);
|
||||||
|
@ -430,35 +427,61 @@ export default function CreateResourceForm({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{!env.flags.allowRawResources || (
|
<SettingsSection>
|
||||||
<div className="space-y-2">
|
<SettingsSectionHeader>
|
||||||
<FormLabel>
|
<SettingsSectionTitle>
|
||||||
Resource Type
|
Resource Type
|
||||||
</FormLabel>
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Determine how you want to access your
|
||||||
|
resource
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
<StrategySelect
|
<StrategySelect
|
||||||
options={launchOptions}
|
options={resourceTypes}
|
||||||
defaultValue="http"
|
defaultValue="http"
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
form.setValue(
|
baseForm.setValue(
|
||||||
"http",
|
"http",
|
||||||
value === "http"
|
value === "http"
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
|
cols={2}
|
||||||
/>
|
/>
|
||||||
<FormDescription>
|
</SettingsSectionBody>
|
||||||
You cannot change the
|
</SettingsSection>
|
||||||
type of resource after
|
|
||||||
creation.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{form.watch("http") &&
|
{baseForm.watch("http") ? (
|
||||||
env.flags
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
HTTPS Settings
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Configure how your resource will be
|
||||||
|
accessed over HTTPS
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...httpForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="http-settings-form"
|
||||||
|
>
|
||||||
|
{env.flags
|
||||||
.allowBaseDomainResources && (
|
.allowBaseDomainResources && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={
|
||||||
|
httpForm.control
|
||||||
|
}
|
||||||
name="isBaseDomain"
|
name="isBaseDomain"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -467,20 +490,15 @@ export default function CreateResourceForm({
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={
|
||||||
domainType
|
field.value
|
||||||
}
|
|
||||||
onValueChange={(
|
|
||||||
val
|
|
||||||
) => {
|
|
||||||
setDomainType(
|
|
||||||
val ===
|
|
||||||
"basedomain"
|
|
||||||
? "basedomain"
|
? "basedomain"
|
||||||
: "subdomain"
|
: "subdomain"
|
||||||
);
|
}
|
||||||
form.setValue(
|
onValueChange={(
|
||||||
"isBaseDomain",
|
value
|
||||||
val ===
|
) => {
|
||||||
|
field.onChange(
|
||||||
|
value ===
|
||||||
"basedomain"
|
"basedomain"
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -506,19 +524,18 @@ export default function CreateResourceForm({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{form.watch("http") && (
|
{!httpForm.watch(
|
||||||
<>
|
"isBaseDomain"
|
||||||
{domainType ===
|
) && (
|
||||||
"subdomain" ? (
|
<FormItem>
|
||||||
<div className="w-fill space-y-2">
|
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Subdomain
|
Subdomain
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="flex">
|
<div className="flex space-x-0">
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
httpForm.control
|
||||||
}
|
}
|
||||||
name="subdomain"
|
name="subdomain"
|
||||||
render={({
|
render={({
|
||||||
|
@ -539,7 +556,7 @@ export default function CreateResourceForm({
|
||||||
<div className="w-1/2">
|
<div className="w-1/2">
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
httpForm.control
|
||||||
}
|
}
|
||||||
name="domainId"
|
name="domainId"
|
||||||
render={({
|
render={({
|
||||||
|
@ -590,20 +607,26 @@ export default function CreateResourceForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FormDescription>
|
||||||
) : (
|
The subdomain where
|
||||||
|
your resource will
|
||||||
|
be accessible.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{httpForm.watch(
|
||||||
|
"isBaseDomain"
|
||||||
|
) && (
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={
|
||||||
form.control
|
httpForm.control
|
||||||
}
|
}
|
||||||
name="domainId"
|
name="domainId"
|
||||||
render={({
|
render={({ field }) => (
|
||||||
field
|
|
||||||
}) => (
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Base
|
Base Domain
|
||||||
Domain
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={
|
onValueChange={
|
||||||
|
@ -645,13 +668,31 @@ export default function CreateResourceForm({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</form>
|
||||||
)}
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
{!form.watch("http") && (
|
</SettingsSectionBody>
|
||||||
<>
|
</SettingsSection>
|
||||||
<FormField
|
) : (
|
||||||
control={form.control}
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
TCP/UDP Settings
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Configure how your resource will be
|
||||||
|
accessed over TCP/UDP
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...tcpUdpForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="tcp-udp-settings-form"
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={tcpUdpForm.control}
|
||||||
name="protocol"
|
name="protocol"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -659,12 +700,10 @@ export default function CreateResourceForm({
|
||||||
Protocol
|
Protocol
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
value={
|
|
||||||
field.value
|
|
||||||
}
|
|
||||||
onValueChange={
|
onValueChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
|
{...field}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
@ -684,8 +723,9 @@ export default function CreateResourceForm({
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={tcpUdpForm.control}
|
||||||
name="proxyPort"
|
name="proxyPort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -711,7 +751,7 @@ export default function CreateResourceForm({
|
||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
: null
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -719,92 +759,50 @@ export default function CreateResourceForm({
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The external
|
The external
|
||||||
port number
|
port number to
|
||||||
to proxy
|
proxy requests.
|
||||||
requests.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
{showSnippets && (
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
<div>
|
|
||||||
<div className="flex items-start space-x-4 mb-6 last:mb-0">
|
|
||||||
<div className="grow">
|
|
||||||
<h3 className="text-lg font-semibold mb-3">
|
|
||||||
Traefik: Add Entrypoints
|
|
||||||
</h3>
|
|
||||||
<CopyTextBox
|
|
||||||
text={`entryPoints:
|
|
||||||
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
|
|
||||||
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
|
|
||||||
wrapText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start space-x-4 mb-6 last:mb-0">
|
|
||||||
<div className="grow">
|
|
||||||
<h3 className="text-lg font-semibold mb-3">
|
|
||||||
Gerbil: Expose Ports in
|
|
||||||
Docker Compose
|
|
||||||
</h3>
|
|
||||||
<CopyTextBox
|
|
||||||
text={`ports:
|
|
||||||
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
|
|
||||||
wrapText={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
|
||||||
href="https://docs.fossorial.io/Pangolin/tcp-udp"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Learn how to configure TCP/UDP
|
|
||||||
resources
|
|
||||||
</span>
|
|
||||||
<SquareArrowOutUpRight size={14} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
{!showSnippets && (
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
form="create-resource-form"
|
variant="outline"
|
||||||
loading={loading}
|
onClick={() =>
|
||||||
disabled={loading}
|
router.push(`/${orgId}/settings/resources`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const isHttp = baseForm.watch("http");
|
||||||
|
const baseValid = await baseForm.trigger();
|
||||||
|
const settingsValid = isHttp
|
||||||
|
? await httpForm.trigger()
|
||||||
|
: await tcpUdpForm.trigger();
|
||||||
|
|
||||||
|
if (baseValid && settingsValid) {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={createLoading}
|
||||||
>
|
>
|
||||||
Create Resource
|
Create Resource
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSnippets && (
|
|
||||||
<Button
|
|
||||||
loading={loading}
|
|
||||||
onClick={() => goToResource()}
|
|
||||||
>
|
|
||||||
Go to Resource
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
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 { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
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 { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
@ -31,10 +31,13 @@ export default async function AdminLayout(props: LayoutProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = await authCookieHeader();
|
const cookie = await authCookieHeader();
|
||||||
let orgs: ListOrgsResponse["orgs"] = [];
|
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||||
try {
|
try {
|
||||||
const getOrgs = cache(() =>
|
const getOrgs = cache(() =>
|
||||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
|
`/user/${user.userId}/orgs`,
|
||||||
|
cookie
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const res = await getOrgs();
|
const res = await getOrgs();
|
||||||
if (res && res.data.data.orgs) {
|
if (res && res.data.data.orgs) {
|
||||||
|
|
|
@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
export type GlobalUserRow = {
|
export type GlobalUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
name: string | null;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
type: string;
|
||||||
|
idpId: number | null;
|
||||||
|
idpName: string;
|
||||||
dateCreated: 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",
|
accessorKey: "email",
|
||||||
header: ({ column }) => {
|
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",
|
id: "actions",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to permanently delete{" "}
|
Are you sure you want to permanently delete{" "}
|
||||||
<b>{selected?.email || selected?.id}</b> from
|
<b>
|
||||||
the server?
|
{selected?.email ||
|
||||||
|
selected?.name ||
|
||||||
|
selected?.username}
|
||||||
|
</b>{" "}
|
||||||
|
from the server?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
To confirm, please type the email of the user
|
To confirm, please type the name of the user
|
||||||
below.
|
below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText="Confirm Delete User"
|
buttonText="Confirm Delete User"
|
||||||
onConfirm={async () => deleteUser(selected!.id)}
|
onConfirm={async () => deleteUser(selected!.id)}
|
||||||
string={selected.email}
|
string={
|
||||||
|
selected.email || selected.name || selected.username
|
||||||
|
}
|
||||||
title="Delete User from Server"
|
title="Delete User from Server"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { AxiosResponse } from "axios";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
||||||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
@ -27,6 +29,11 @@ export default async function UsersPage(props: PageProps) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username,
|
||||||
|
type: row.type,
|
||||||
|
idpId: row.idpId,
|
||||||
|
idpName: row.idpName || "Internal",
|
||||||
dateCreated: row.dateCreated,
|
dateCreated: row.dateCreated,
|
||||||
serverAdmin: row.serverAdmin
|
serverAdmin: row.serverAdmin
|
||||||
};
|
};
|
||||||
|
@ -38,6 +45,13 @@ export default async function UsersPage(props: PageProps) {
|
||||||
title="Manage All Users"
|
title="Manage All Users"
|
||||||
description="View and manage all users in the system"
|
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} />
|
<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
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { createApiClient } from "@app/lib/api";
|
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 { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
type DashboardLoginFormProps = {
|
type DashboardLoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
idps?: LoginFormIDP[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardLoginForm({
|
export default function DashboardLoginForm({
|
||||||
redirect
|
redirect,
|
||||||
|
idps
|
||||||
}: DashboardLoginFormProps) {
|
}: DashboardLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// const api = createApiClient(useEnvContext());
|
// const api = createApiClient(useEnvContext());
|
||||||
|
@ -51,12 +53,15 @@ export default function DashboardLoginForm({
|
||||||
<h1 className="text-2xl font-bold mt-1">
|
<h1 className="text-2xl font-bold mt-1">
|
||||||
Welcome to Pangolin
|
Welcome to Pangolin
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
redirect={redirect}
|
redirect={redirect}
|
||||||
|
idps={idps}
|
||||||
onLogin={() => {
|
onLogin={() => {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
const safe = cleanRedirect(redirect);
|
const safe = cleanRedirect(redirect);
|
||||||
|
|
|
@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
@ -31,10 +34,16 @@ export default async function Page(props: {
|
||||||
redirectUrl = cleanRedirect(searchParams.redirect as string);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isInvite && (
|
{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">
|
<div className="flex flex-col items-center">
|
||||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||||
|
@ -48,7 +57,7 @@ export default async function Page(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardLoginForm redirect={redirectUrl} />
|
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
|
||||||
|
|
||||||
{(!signUpDisabled || isInvite) && (
|
{(!signUpDisabled || isInvite) && (
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<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 { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import LoginForm from "@app/components/LoginForm";
|
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||||
import {
|
import {
|
||||||
AuthWithPasswordResponse,
|
AuthWithPasswordResponse,
|
||||||
AuthWithWhitelistResponse
|
AuthWithWhitelistResponse
|
||||||
|
@ -81,6 +81,7 @@ type ResourceAuthPortalProps = {
|
||||||
id: number;
|
id: number;
|
||||||
};
|
};
|
||||||
redirect: string;
|
redirect: string;
|
||||||
|
idps?: LoginFormIDP[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
@ -376,31 +377,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
index={
|
index={
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={
|
index={
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={
|
index={
|
||||||
2
|
2
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={
|
index={
|
||||||
3
|
3
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={
|
index={
|
||||||
4
|
4
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
index={
|
index={
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
|
obscured
|
||||||
/>
|
/>
|
||||||
</InputOTPGroup>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
|
@ -490,7 +497,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||||
>
|
>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
redirect={`/auth/resource/${props.resource.id}`}
|
idps={props.idps}
|
||||||
|
redirect={props.redirect}
|
||||||
onLogin={async () =>
|
onLogin={async () =>
|
||||||
await handleSSOAuth()
|
await handleSSOAuth()
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound";
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import AccessToken from "./AccessToken";
|
import AccessToken from "./AccessToken";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
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: {
|
export default async function ResourceAuthPage(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
|
@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: {
|
||||||
redirect(redirectUrl);
|
redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// convert the dashboard token into a resource session token
|
// convert the dashboard token into a resource session token
|
||||||
let userIsUnauthorized = false;
|
let userIsUnauthorized = false;
|
||||||
if (user && authInfo.sso) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{userIsUnauthorized && isSSOOnly ? (
|
{userIsUnauthorized && isSSOOnly ? (
|
||||||
|
@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: {
|
||||||
id: authInfo.resourceId
|
id: authInfo.resourceId
|
||||||
}}
|
}}
|
||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
|
idps={loginIdps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -50,7 +50,7 @@ export default async function Page(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isInvite && (
|
{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">
|
<div className="flex flex-col items-center">
|
||||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default async function Page(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VerifyEmailForm
|
<VerifyEmailForm
|
||||||
email={user.email}
|
email={user.email!}
|
||||||
redirect={redirectUrl}
|
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 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';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
@ -23,7 +24,7 @@
|
||||||
--border: hsl(20 5.9% 90%);
|
--border: hsl(20 5.9% 90%);
|
||||||
--input: hsl(20 5.9% 75%);
|
--input: hsl(20 5.9% 75%);
|
||||||
--ring: hsl(24.6 95% 53.1%);
|
--ring: hsl(24.6 95% 53.1%);
|
||||||
--radius: 0.50rem;
|
--radius: 0.75rem;
|
||||||
--chart-1: hsl(12 76% 61%);
|
--chart-1: hsl(12 76% 61%);
|
||||||
--chart-2: hsl(173 58% 39%);
|
--chart-2: hsl(173 58% 39%);
|
||||||
--chart-3: hsl(197 37% 24%);
|
--chart-3: hsl(197 37% 24%);
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
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 { Toaster } from "@/components/ui/toaster";
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
|
|
|
@ -5,32 +5,42 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Combine
|
Combine,
|
||||||
|
Fingerprint,
|
||||||
|
KeyRound
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||||
|
{
|
||||||
|
title: "Overview",
|
||||||
|
href: "/{orgId}",
|
||||||
|
icon: <Home className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const rootNavItems: SidebarNavItem[] = [
|
export const rootNavItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
title: "Home",
|
title: "Home",
|
||||||
href: "/"
|
href: "/",
|
||||||
// icon: <Home className="h-4 w-4" />
|
icon: <Home className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const orgNavItems: SidebarNavItem[] = [
|
export const orgNavItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
title: "Sites",
|
title: "Sites",
|
||||||
href: "/{orgId}/settings/sites"
|
href: "/{orgId}/settings/sites",
|
||||||
// icon: <Combine className="h-4 w-4" />
|
icon: <Combine className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Resources",
|
title: "Resources",
|
||||||
href: "/{orgId}/settings/resources"
|
href: "/{orgId}/settings/resources",
|
||||||
// icon: <Waypoints className="h-4 w-4" />
|
icon: <Waypoints className="h-4 w-4" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Access Control",
|
title: "Access Control",
|
||||||
href: "/{orgId}/settings/access",
|
href: "/{orgId}/settings/access",
|
||||||
// icon: <Users className="h-4 w-4" />,
|
icon: <Users className="h-4 w-4" />,
|
||||||
autoExpand: true,
|
autoExpand: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
@ -51,20 +61,37 @@ export const orgNavItems: SidebarNavItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Shareable Links",
|
title: "Shareable Links",
|
||||||
href: "/{orgId}/settings/share-links"
|
href: "/{orgId}/settings/share-links",
|
||||||
// icon: <LinkIcon className="h-4 w-4" />
|
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",
|
title: "Settings",
|
||||||
href: "/{orgId}/settings/general"
|
href: "/{orgId}/settings/general",
|
||||||
// icon: <Settings className="h-4 w-4" />
|
icon: <Settings className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const adminNavItems: SidebarNavItem[] = [
|
export const adminNavItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
title: "All Users",
|
title: "All Users",
|
||||||
href: "/admin/users"
|
href: "/admin/users",
|
||||||
// icon: <Users className="h-4 w-4" />
|
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 { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
@ -36,10 +36,7 @@ export default async function Page(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!user.emailVerified && env.flags.emailVerificationRequired) {
|
||||||
!user.emailVerified &&
|
|
||||||
env.flags.emailVerificationRequired
|
|
||||||
) {
|
|
||||||
if (params.redirect) {
|
if (params.redirect) {
|
||||||
const safe = cleanRedirect(params.redirect);
|
const safe = cleanRedirect(params.redirect);
|
||||||
redirect(`/auth/verify-email?redirect=${safe}`);
|
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 {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
|
const res = await internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||||
`/orgs`,
|
`/user/${user.userId}/orgs`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -61,24 +58,19 @@ export default async function Page(props: {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
if (!orgs.length) {
|
if (!orgs.length) {
|
||||||
if (
|
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||||
!env.flags.disableUserCreateOrg ||
|
|
||||||
user.serverAdmin
|
|
||||||
) {
|
|
||||||
redirect("/setup");
|
redirect("/setup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider user={user}>
|
<UserProvider user={user}>
|
||||||
<Layout
|
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
|
||||||
orgs={orgs}
|
|
||||||
navItems={rootNavItems}
|
|
||||||
showBreadcrumbs={false}
|
|
||||||
>
|
|
||||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||||
<OrganizationLanding
|
<OrganizationLanding
|
||||||
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
|
disableCreateOrg={
|
||||||
|
env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||||
|
}
|
||||||
organizations={orgs.map((org) => ({
|
organizations={orgs.map((org) => ({
|
||||||
name: org.name,
|
name: org.name,
|
||||||
id: org.orgId
|
id: org.orgId
|
||||||
|
|
|
@ -61,6 +61,9 @@ export default function StepperForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const checkOrgIdAvailability = useCallback(async (value: string) => {
|
const checkOrgIdAvailability = useCallback(async (value: string) => {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/org/checkId`, {
|
const res = await api.get(`/org/checkId`, {
|
||||||
params: {
|
params: {
|
||||||
|
|
|
@ -18,40 +18,39 @@ export function Breadcrumbs() {
|
||||||
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
const href = `/${segments.slice(0, index + 1).join("/")}`;
|
||||||
let label = segment;
|
let label = segment;
|
||||||
|
|
||||||
// Format labels
|
// // Format labels
|
||||||
if (segment === "settings") {
|
// if (segment === "settings") {
|
||||||
label = "Settings";
|
// label = "Settings";
|
||||||
} else if (segment === "sites") {
|
// } else if (segment === "sites") {
|
||||||
label = "Sites";
|
// label = "Sites";
|
||||||
} else if (segment === "resources") {
|
// } else if (segment === "resources") {
|
||||||
label = "Resources";
|
// label = "Resources";
|
||||||
} else if (segment === "access") {
|
// } else if (segment === "access") {
|
||||||
label = "Access Control";
|
// label = "Access Control";
|
||||||
} else if (segment === "general") {
|
// } else if (segment === "general") {
|
||||||
label = "General";
|
// label = "General";
|
||||||
} else if (segment === "share-links") {
|
// } else if (segment === "share-links") {
|
||||||
label = "Shareable Links";
|
// label = "Shareable Links";
|
||||||
} else if (segment === "users") {
|
// } else if (segment === "users") {
|
||||||
label = "Users";
|
// label = "Users";
|
||||||
} else if (segment === "roles") {
|
// } else if (segment === "roles") {
|
||||||
label = "Roles";
|
// label = "Roles";
|
||||||
} else if (segment === "invitations") {
|
// } else if (segment === "invitations") {
|
||||||
label = "Invitations";
|
// label = "Invitations";
|
||||||
} else if (segment === "connectivity") {
|
// } else if (segment === "connectivity") {
|
||||||
label = "Connectivity";
|
// label = "Connectivity";
|
||||||
} else if (segment === "authentication") {
|
// } else if (segment === "authentication") {
|
||||||
label = "Authentication";
|
// label = "Authentication";
|
||||||
}
|
// }
|
||||||
|
|
||||||
return { label, href };
|
return { label, href };
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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-muted-foreground">
|
||||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<div key={crumb.href} className="flex items-center whitespace-nowrap">
|
<div key={crumb.href} className="flex items-center flex-nowrap">
|
||||||
{index !== 0 && <ChevronRight className="h-4 w-4" />}
|
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
|
||||||
<Link
|
<Link
|
||||||
href={crumb.href}
|
href={crumb.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
@ -65,6 +64,5 @@ export function Breadcrumbs() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Header } from "@app/components/Header";
|
|
||||||
import { SidebarNav } from "@app/components/SidebarNav";
|
import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
import { TopBar } from "@app/components/TopBar";
|
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { cn } from "@app/lib/cn";
|
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 SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
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 {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
@ -21,11 +20,13 @@ import {
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { Breadcrumbs } from "@app/components/Breadcrumbs";
|
import { Breadcrumbs } from "@app/components/Breadcrumbs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
navItems?: Array<{
|
navItems?: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
@ -54,12 +55,19 @@ export function Layout({
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
|
const { user } = useUserContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
{/* Mobile Menu Button */}
|
{/* 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 && (
|
{showSidebar && (
|
||||||
<div className="md:hidden fixed top-4 left-4 z-50">
|
<div className="md:hidden">
|
||||||
<Sheet
|
<Sheet
|
||||||
open={isMobileMenuOpen}
|
open={isMobileMenuOpen}
|
||||||
onOpenChange={setIsMobileMenuOpen}
|
onOpenChange={setIsMobileMenuOpen}
|
||||||
|
@ -77,19 +85,44 @@ export function Layout({
|
||||||
Navigation Menu
|
Navigation Menu
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
Main navigation menu for the application
|
Main navigation menu for the
|
||||||
|
application
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
{showHeader && (
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex h-16 items-center border-b px-4 shrink-0">
|
<div className="p-4">
|
||||||
<Header orgId={orgId} orgs={orgs} />
|
<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="flex-1 overflow-y-auto p-4">
|
|
||||||
<SidebarNav items={navItems} onItemClick={() => setIsMobileMenuOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4 border-t shrink-0">
|
<div className="p-4 space-y-4 border-t shrink-0">
|
||||||
<SupporterStatus />
|
<SupporterStatus />
|
||||||
<OrgSelector orgId={orgId} orgs={orgs} />
|
<OrgSelector
|
||||||
|
orgId={orgId}
|
||||||
|
orgs={orgs}
|
||||||
|
/>
|
||||||
{env?.app?.version && (
|
{env?.app?.version && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
v{env.app.version}
|
v{env.app.version}
|
||||||
|
@ -100,17 +133,76 @@ export function Layout({
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
||||||
|
{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 */}
|
{/* Desktop Sidebar */}
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
|
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
|
||||||
{showHeader && (
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex h-16 items-center border-b px-4 shrink-0">
|
<div className="p-4">
|
||||||
<Header orgId={orgId} orgs={orgs} />
|
<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="flex-1 overflow-y-auto p-4">
|
|
||||||
<SidebarNav items={navItems} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4 border-t shrink-0">
|
<div className="p-4 space-y-4 border-t shrink-0">
|
||||||
<SupporterStatus />
|
<SupporterStatus />
|
||||||
|
@ -124,7 +216,7 @@ export function Layout({
|
||||||
className="flex items-center justify-center gap-1"
|
className="flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
Open Source
|
Open Source
|
||||||
<ExternalLink size={12}/>
|
<ExternalLink size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{env?.app?.version && (
|
{env?.app?.version && (
|
||||||
|
@ -144,20 +236,13 @@ export function Layout({
|
||||||
!showSidebar && "w-full"
|
!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">
|
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||||
<div className="container mx-auto max-w-12xl">
|
<div className="container mx-auto max-w-12xl mb-12">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { LoginResponse } from "@server/routers/auth";
|
import { LoginResponse } from "@server/routers/auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { LockIcon } from "lucide-react";
|
import { LockIcon } from "lucide-react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
@ -37,11 +37,19 @@ import {
|
||||||
} from "./ui/input-otp";
|
} from "./ui/input-otp";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
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 = {
|
type LoginFormProps = {
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
onLogin?: () => void | Promise<void>;
|
onLogin?: () => void | Promise<void>;
|
||||||
|
idps?: LoginFormIDP[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
@ -55,7 +63,7 @@ const mfaSchema = z.object({
|
||||||
code: z.string().length(6, { message: "Invalid code" })
|
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 router = useRouter();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
@ -64,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const hasIdp = idps && idps.length > 0;
|
||||||
|
|
||||||
const [mfaRequested, setMfaRequested] = useState(false);
|
const [mfaRequested, setMfaRequested] = useState(false);
|
||||||
|
|
||||||
|
@ -130,9 +139,33 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
|
<>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
@ -146,9 +179,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} />
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -184,6 +215,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequested && (
|
{mfaRequested && (
|
||||||
|
@ -193,7 +225,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
Two-Factor Authentication
|
Two-Factor Authentication
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Form {...mfaForm}>
|
<Form {...mfaForm}>
|
||||||
|
@ -268,6 +301,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="form"
|
form="form"
|
||||||
|
@ -278,6 +312,36 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
Log In
|
Log In
|
||||||
</Button>
|
</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 && (
|
{mfaRequested && (
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
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 { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -25,7 +25,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
|
||||||
interface OrgSelectorProps {
|
interface OrgSelectorProps {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
|
||||||
|
|
|
@ -38,7 +38,9 @@ export default function ProfileIcon() {
|
||||||
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
const [openDisable2fa, setOpenDisable2fa] = useState(false);
|
||||||
|
|
||||||
function getInitials() {
|
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") {
|
function handleThemeChange(theme: "light" | "dark" | "system") {
|
||||||
|
@ -66,9 +68,9 @@ export default function ProfileIcon() {
|
||||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
<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">
|
<span className="truncate max-w-full font-medium min-w-0">
|
||||||
{user.email}
|
{user.email || user.name || user.username}
|
||||||
</span>
|
</span>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
@ -92,13 +94,17 @@ export default function ProfileIcon() {
|
||||||
Signed in as
|
Signed in as
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs leading-none text-muted-foreground">
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
{user.email}
|
{user.email || user.name || user.username}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{user.serverAdmin && (
|
{user.serverAdmin ? (
|
||||||
<p className="text-xs leading-none text-muted-foreground mt-2">
|
<p className="text-xs leading-none text-muted-foreground mt-2">
|
||||||
Server Admin
|
Server Admin
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs leading-none text-muted-foreground mt-2">
|
||||||
|
{user.idpName || "Internal"}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
|
@ -1,31 +1,69 @@
|
||||||
export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
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 }) {
|
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 }) {
|
export function SettingsSectionHeader({
|
||||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
|
export function SettingsSectionForm({
|
||||||
return <div className="max-w-xl">{children}</div>
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="max-w-xl">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionTitle({ children }: { children: React.ReactNode }) {
|
export function SettingsSectionTitle({
|
||||||
return <h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">{children}</h2>
|
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 }) {
|
export function SettingsSectionDescription({
|
||||||
return <p className="text-muted-foreground text-sm">{children}</p>
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {
|
export function SettingsSectionBody({
|
||||||
return <div className="space-y-5">{children}</div>
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="space-y-5">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionFooter({ children }: { children: React.ReactNode }) {
|
export function SettingsSectionFooter({
|
||||||
return <div className="flex justify-end space-x-4 mt-8">{children}</div>
|
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 { useParams, usePathname } from "next/navigation";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
href: string;
|
href: string;
|
||||||
|
@ -12,6 +14,7 @@ export interface SidebarNavItem {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
children?: SidebarNavItem[];
|
children?: SidebarNavItem[];
|
||||||
autoExpand?: boolean;
|
autoExpand?: boolean;
|
||||||
|
showEnterprise?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
@ -35,25 +38,7 @@ export function SidebarNav({
|
||||||
const userId = params.userId as string;
|
const userId = params.userId as string;
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Initialize expanded items based on autoExpand property
|
const { user } = useUserContext();
|
||||||
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]);
|
|
||||||
|
|
||||||
function hydrateHref(val: string): string {
|
function hydrateHref(val: string): string {
|
||||||
return val
|
return val
|
||||||
|
@ -63,8 +48,39 @@ export function SidebarNav({
|
||||||
.replace("{userId}", userId);
|
.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) {
|
function toggleItem(href: string) {
|
||||||
setExpandedItems(prev => {
|
setExpandedItems((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
if (newSet.has(href)) {
|
if (newSet.has(href)) {
|
||||||
newSet.delete(href);
|
newSet.delete(href);
|
||||||
|
@ -81,47 +97,68 @@ export function SidebarNav({
|
||||||
const isActive = pathname.startsWith(hydratedHref);
|
const isActive = pathname.startsWith(hydratedHref);
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.has(hydratedHref);
|
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 (
|
return (
|
||||||
<div key={hydratedHref}>
|
<div key={hydratedHref}>
|
||||||
<div className="flex items-center group" style={{ marginLeft: `${indent}px` }}>
|
<div
|
||||||
<Link
|
className="flex items-center group"
|
||||||
href={hydratedHref}
|
style={{ marginLeft: `${indent}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center py-1 w-full transition-colors",
|
"flex items-center w-full transition-colors rounded-md",
|
||||||
|
isActive && level === 0 && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={isEnterprise ? "#" : hydratedHref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-3 py-2",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary font-medium"
|
? "text-primary font-medium"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground group-hover:text-foreground",
|
||||||
disabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (disabled) {
|
if (isDisabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (onItemClick) {
|
} else if (onItemClick) {
|
||||||
onItemClick();
|
onItemClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={disabled ? -1 : undefined}
|
tabIndex={isDisabled ? -1 : undefined}
|
||||||
aria-disabled={disabled}
|
aria-disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
<div className={cn("flex items-center", isDisabled && "opacity-60")}>
|
||||||
|
{item.icon && (
|
||||||
|
<span className="mr-3">{item.icon}</span>
|
||||||
|
)}
|
||||||
{item.title}
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{isEnterprise && (
|
||||||
|
<Badge className="ml-2">
|
||||||
|
Enterprise
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleItem(hydratedHref)}
|
onClick={() => toggleItem(hydratedHref)}
|
||||||
className="p-2 hover:bg-muted rounded-md ml-auto"
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && isExpanded && (
|
||||||
<div className="space-y-1 mt-1">
|
<div className="space-y-1 mt-1">
|
||||||
{renderItems(item.children || [], level + 1)}
|
{renderItems(item.children || [], level + 1)}
|
||||||
|
@ -135,7 +172,7 @@ export function SidebarNav({
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1",
|
"flex flex-col space-y-2",
|
||||||
disabled && "pointer-events-none opacity-60",
|
disabled && "pointer-events-none opacity-60",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -7,6 +7,7 @@ interface SwitchComponentProps {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
defaultChecked?: boolean;
|
defaultChecked?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onCheckedChange: (checked: boolean) => void;
|
onCheckedChange: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +15,7 @@ export function SwitchInput({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
disabled,
|
||||||
defaultChecked = false,
|
defaultChecked = false,
|
||||||
onCheckedChange
|
onCheckedChange
|
||||||
}: SwitchComponentProps) {
|
}: SwitchComponentProps) {
|
||||||
|
@ -24,6 +26,7 @@ export function SwitchInput({
|
||||||
id={id}
|
id={id}
|
||||||
defaultChecked={defaultChecked}
|
defaultChecked={defaultChecked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -10,16 +10,13 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface InfoPopupProps {
|
interface InfoPopupProps {
|
||||||
text: string;
|
text?: string;
|
||||||
info: string;
|
info: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoPopup({ text, info }: InfoPopupProps) {
|
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
|
||||||
return (
|
const defaultTrigger = (
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span>{text}</span>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
@ -28,6 +25,14 @@ export function InfoPopup({ text, info }: InfoPopupProps) {
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<span className="sr-only">Show info</span>
|
<span className="sr-only">Show info</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{text && <span>{text}</span>}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{trigger ?? defaultTrigger}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80">
|
<PopoverContent className="w-80">
|
||||||
<p className="text-sm text-muted-foreground">{info}</p>
|
<p className="text-sm text-muted-foreground">{info}</p>
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { cn } from "@app/lib/cn"
|
||||||
|
|
||||||
const InputOTP = React.forwardRef<
|
const InputOTP = React.forwardRef<
|
||||||
React.ElementRef<typeof OTPInput>,
|
React.ElementRef<typeof OTPInput>,
|
||||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean }
|
||||||
>(({ className, containerClassName, ...props }, ref) => (
|
>(({ className, containerClassName, obscured = false, ...props }, ref) => (
|
||||||
<OTPInput
|
<OTPInput
|
||||||
ref={ref}
|
ref={ref}
|
||||||
containerClassName={cn(
|
containerClassName={cn(
|
||||||
|
@ -32,8 +32,8 @@ InputOTPGroup.displayName = "InputOTPGroup"
|
||||||
|
|
||||||
const InputOTPSlot = React.forwardRef<
|
const InputOTPSlot = React.forwardRef<
|
||||||
React.ElementRef<"div">,
|
React.ElementRef<"div">,
|
||||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean }
|
||||||
>(({ index, className, ...props }, ref) => {
|
>(({ index, className, obscured = false, ...props }, ref) => {
|
||||||
const inputOTPContext = React.useContext(OTPInputContext)
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ const InputOTPSlot = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{char}
|
{char && obscured ? "•" : char}
|
||||||
{hasFakeCaret && (
|
{hasFakeCaret && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
<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" />
|
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
|
|
@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -29,7 +29,7 @@ const toastVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border bg-background text-foreground",
|
default: "border bg-card text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,10 +9,10 @@ const patterns: PatternConfig[] = [
|
||||||
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
|
{ 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") {
|
if (!input || typeof input !== "string") {
|
||||||
return "/";
|
return "/";
|
||||||
}
|
}
|
||||||
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
|
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