Merge branch 'auth-providers' into dev

This commit is contained in:
miloschwartz 2025-04-23 22:08:37 -04:00
commit f4fd33b47f
No known key found for this signature in database
93 changed files with 5788 additions and 1608 deletions

292
package-lock.json generated
View file

@ -32,14 +32,18 @@
"@react-email/components": "0.0.36",
"@react-email/render": "^1.0.6",
"@react-email/tailwind": "1.0.4",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.20.6",
"arctic": "^3.6.0",
"axios": "1.8.4",
"better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"cookie": "^1.0.2",
"cookie-parser": "1.4.7",
"cookies": "^0.9.1",
"cors": "2.8.5",
"drizzle-orm": "0.38.3",
"eslint": "9.17.0",
@ -51,7 +55,9 @@
"http-errors": "2.0.0",
"i": "^0.3.7",
"input-otp": "1.4.1",
"jmespath": "^0.16.0",
"js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.469.0",
"moment": "2.30.1",
"next": "15.2.4",
@ -71,7 +77,7 @@
"semver": "7.6.3",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"tw-animate-css": "^1.2.5",
"vaul": "1.1.2",
"winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0",
@ -87,7 +93,9 @@
"@types/cookie-parser": "1.4.8",
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22",
"@types/nodemailer": "6.4.17",
"@types/react": "19.1.1",
@ -101,7 +109,7 @@
"esbuild-node-externals": "1.18.0",
"postcss": "^8",
"react-email": "4.0.6",
"tailwindcss": "^4.1.3",
"tailwindcss": "^4.1.4",
"tsc-alias": "1.8.10",
"tsx": "4.19.3",
"typescript": "^5",
@ -2756,6 +2764,21 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@oslojs/jwt": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz",
"integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==",
"license": "MIT",
"dependencies": {
"@oslojs/encoding": "0.4.1"
}
},
"node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz",
"integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==",
"license": "MIT"
},
"node_modules/@petamoriken/float16": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
@ -4048,6 +4071,18 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"license": "MIT",
"dependencies": {
"mini-svg-data-uri": "^1.2.3"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz",
@ -4061,6 +4096,13 @@
"tailwindcss": "4.1.3"
}
},
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz",
@ -4285,6 +4327,13 @@
"tailwindcss": "4.1.3"
}
},
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
"dev": true,
"license": "MIT"
},
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
@ -4418,6 +4467,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jmespath": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@ -4437,6 +4493,17 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -4444,6 +4511,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
@ -5111,6 +5185,17 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arctic": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/arctic/-/arctic-3.6.0.tgz",
"integrity": "sha512-egHDsCqEacb6oSHz5QSSxNhp07J+QJwJdPvs0katL+mNM5LaGQVqxmcdq1KwfaSNSAlVumBBs0MRExS88TxbMg==",
"license": "MIT",
"dependencies": {
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@oslojs/jwt": "0.2.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -5552,6 +5637,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -5986,12 +6077,12 @@
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
}
},
"node_modules/cookie-parser": {
@ -6007,12 +6098,34 @@
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"keygrip": "~1.1.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -6988,6 +7101,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/eciesjs": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.14.tgz",
@ -7073,6 +7195,16 @@
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@ -9361,6 +9493,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -9422,6 +9563,28 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -9437,6 +9600,39 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
"license": "MIT",
"dependencies": {
"tsscmp": "1.0.6"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -9746,12 +9942,54 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@ -9995,6 +10233,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"license": "MIT",
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -15615,20 +15862,11 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz",
"integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@ -15841,6 +16079,15 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/tsx": {
"version": "4.19.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
@ -15873,6 +16120,15 @@
"node": "*"
}
},
"node_modules/tw-animate-css": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz",
"integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -43,14 +43,18 @@
"@react-email/components": "0.0.36",
"@react-email/render": "^1.0.6",
"@react-email/tailwind": "1.0.4",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.20.6",
"arctic": "^3.6.0",
"axios": "1.8.4",
"better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"cookie": "^1.0.2",
"cookie-parser": "1.4.7",
"cookies": "^0.9.1",
"cors": "2.8.5",
"drizzle-orm": "0.38.3",
"eslint": "9.17.0",
@ -62,7 +66,9 @@
"http-errors": "2.0.0",
"i": "^0.3.7",
"input-otp": "1.4.1",
"jmespath": "^0.16.0",
"js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "0.469.0",
"moment": "2.30.1",
"next": "15.2.4",
@ -82,7 +88,7 @@
"semver": "7.6.3",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"tw-animate-css": "^1.2.5",
"vaul": "1.1.2",
"winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0",
@ -98,7 +104,9 @@
"@types/cookie-parser": "1.4.8",
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22",
"@types/nodemailer": "6.4.17",
"@types/react": "19.1.1",
@ -112,7 +120,7 @@
"esbuild-node-externals": "1.18.0",
"postcss": "^8",
"react-email": "4.0.6",
"tailwindcss": "^4.1.3",
"tailwindcss": "^4.1.4",
"tsc-alias": "1.8.10",
"tsx": "4.19.3",
"typescript": "^5",

View file

@ -6,6 +6,9 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
listOrgs = "listOrgs",
listUserOrgs = "listUserOrgs",
createOrg = "createOrg",
// deleteOrg = "deleteOrg",
getOrg = "getOrg",
@ -65,7 +68,16 @@ export enum ActionsEnum {
listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains",
createNewt = "createNewt"
createNewt = "createNewt",
createIdp = "createIdp",
updateIdp = "updateIdp",
deleteIdp = "deleteIdp",
listIdps = "listIdps",
getIdp = "getIdp",
createIdpOrg = "createIdpOrg",
deleteIdpOrg = "deleteIdpOrg",
listIdpOrgs = "listIdpOrgs",
updateIdpOrg = "updateIdpOrg"
}
export async function checkUserActionPermission(

View file

@ -111,8 +111,14 @@ export const exitNodes = sqliteTable("exitNodes", {
export const users = sqliteTable("user", {
userId: text("id").primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("passwordHash").notNull(),
email: text("email"),
username: text("username").notNull(),
name: text("name"),
type: text("type").notNull(), // "internal", "oidc"
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
passwordHash: text("passwordHash"),
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
.notNull()
.default(false),
@ -420,6 +426,38 @@ export const supporterKey = sqliteTable("supporterKey", {
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
});
// Identity Providers
export const idp = sqliteTable("idp", {
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
defaultRoleMapping: text("defaultRoleMapping"),
defaultOrgMapping: text("defaultOrgMapping"),
autoProvision: integer("autoProvision", {
mode: "boolean"
})
.notNull()
.default(false)
});
// Identity Provider OAuth Configuration
export const idpOidcConfig = sqliteTable("idpOidcConfig", {
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
autoIncrement: true
}),
idpId: integer("idpId")
.notNull()
.references(() => idp.idpId, { onDelete: "cascade" }),
clientId: text("clientId").notNull(),
clientSecret: text("clientSecret").notNull(),
authUrl: text("authUrl").notNull(),
tokenUrl: text("tokenUrl").notNull(),
identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"),
namePath: text("namePath"),
scopes: text("scopes").notNull()
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@ -455,3 +493,4 @@ export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;

View file

@ -91,7 +91,19 @@ const configSchema = z.object({
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
trust_proxy: z.boolean().optional().default(true),
secret: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(
z
.string()
.min(
32,
"SERVER_SECRET must be at least 32 characters long"
)
)
}),
traefik: z.object({
http_entrypoint: z.string(),

40
server/lib/crypto.ts Normal file
View 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");
}

View 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;
}

View file

@ -14,4 +14,5 @@ export * from "./verifyAdmin";
export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";

View 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"
)
);
}
}

View file

@ -11,5 +11,6 @@ export enum OpenAPITags {
Invitation = "Invitation",
Target = "Target",
Rule = "Rule",
AccessToken = "Access Token"
AccessToken = "Access Token",
Idp = "Identity Provider"
}

View file

@ -16,6 +16,7 @@ import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const changePasswordBody = z
.object({
@ -50,6 +51,15 @@ export async function changePassword(
const { newPassword, oldPassword, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
if (newPassword === oldPassword) {
return next(
@ -62,7 +72,7 @@ export async function changePassword(
const validPassword = await verifyPassword(
oldPassword,
user.passwordHash
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());

View file

@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const disable2faBody = z
.object({
@ -47,8 +48,17 @@ export async function disable2fa(
const { password, code } = parsedBody.data;
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@ -99,11 +109,11 @@ export async function disable2fa(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!, // email is not null because we are checking user.type
enabled: false
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication disabled"
}

View file

@ -7,7 +7,7 @@ import db from "@server/db";
import { users } from "@server/db/schemas";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@ -17,6 +17,7 @@ import config from "@server/lib/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { verifySession } from "@server/auth/sessions/verifySession";
import { UserType } from "@server/types/UserTypes";
export const loginBodySchema = z
.object({
@ -69,7 +70,9 @@ export async function login(
const existingUserRes = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.type, UserType.Internal), eq(users.email, email))
);
if (!existingUserRes || !existingUserRes.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
@ -88,7 +91,7 @@ export async function login(
const validPassword = await verifyPassword(
password,
existingUser.passwordHash
existingUser.passwordHash!
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {

View file

@ -6,6 +6,7 @@ import { User } from "@server/db/schemas";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import config from "@server/lib/config";
import logger from "@server/logger";
import { UserType } from "@server/types/UserTypes";
export type RequestEmailVerificationCodeResponse = {
codeSent: boolean;
@ -28,6 +29,15 @@ export async function requestEmailVerificationCode(
try {
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email verification is not supported for external users"
)
);
}
if (user.emailVerified) {
return next(
createHttpError(
@ -37,7 +47,7 @@ export async function requestEmailVerificationCode(
);
}
await sendEmailVerificationCode(user.email, user.userId);
await sendEmailVerificationCode(user.email!, user.userId);
return response<RequestEmailVerificationCodeResponse>(res, {
data: {

View file

@ -74,7 +74,7 @@ export async function requestPasswordReset(
await trx.insert(passwordResetTokens).values({
userId: existingUser[0].userId,
email: existingUser[0].email,
email: existingUser[0].email!,
tokenHash,
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
});

View file

@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z
.object({
@ -46,8 +47,17 @@ export async function requestTotpSecret(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
try {
const validPassword = await verifyPassword(password, user.passwordHash);
const validPassword = await verifyPassword(password, user.passwordHash!);
if (!validPassword) {
return next(unauthorized());
}
@ -63,7 +73,7 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email, hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
await db
.update(users)

View file

@ -8,7 +8,7 @@ import createHttpError from "http-errors";
import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3";
import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import moment from "moment";
import {
createSession,
@ -21,6 +21,7 @@ import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export const signupBodySchema = z.object({
email: z
@ -110,7 +111,9 @@ export async function signup(
const existing = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(eq(users.email, email), eq(users.type, UserType.Internal))
);
if (existing && existing.length > 0) {
if (!config.getRawConfig().flags?.require_email_verification) {
@ -157,6 +160,8 @@ export async function signup(
await db.insert(users).values({
userId: userId,
type: UserType.Internal,
username: email,
email: email,
passwordHash,
dateCreated: moment().toISOString()

View file

@ -14,6 +14,7 @@ import logger from "@server/logger";
import { sendEmail } from "@server/emails";
import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const verifyTotpBody = z
.object({
@ -48,6 +49,15 @@ export async function verifyTotp(
const user = req.user as User;
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Two-factor authentication is not supported for external users"
)
);
}
if (user.twoFactorEnabled) {
return next(
createHttpError(
@ -111,11 +121,11 @@ export async function verifyTotp(
sendEmail(
TwoFactorAuthNotification({
email: user.email,
email: user.email!,
enabled: true
}),
{
to: user.email,
to: user.email!,
from: config.getRawConfig().email?.no_reply,
subject: "Two-factor authentication enabled"
}

View file

@ -10,6 +10,7 @@ import * as auth from "./auth";
import * as role from "./role";
import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import * as idp from "./idp";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@ -24,7 +25,8 @@ import {
verifySetResourceUsers,
verifyUserAccess,
getUserOrgs,
verifyUserIsServerAdmin
verifyUserIsServerAdmin,
verifyIsLoggedInUser
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -46,7 +48,10 @@ authenticated.use(verifySessionUserMiddleware);
authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
authenticated.get(
"/org/:orgId",
verifyOrgAccess,
@ -443,7 +448,15 @@ authenticated.delete(
user.adminRemoveUser
);
authenticated.put(
"/org/:orgId/user",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get(
"/org/:orgId/users",
verifyOrgAccess,
@ -493,6 +506,24 @@ authenticated.delete(
// createNewt
// );
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
@ -582,3 +613,7 @@ authRouter.post(
);
authRouter.post("/access-token", resource.authWithAccessToken);
authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl);
authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);

View 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")
);
}
}

View 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")
);
}
}

View 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")
);
}
}

View 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")
);
}
}

View 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";

View 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")
);
}
}

View 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")
);
}
}

View 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")
);
}
}

View file

@ -2,6 +2,7 @@ export * from "./getOrg";
export * from "./createOrg";
export * from "./deleteOrg";
export * from "./updateOrg";
export * from "./listOrgs";
export * from "./listUserOrgs";
export * from "./checkId";
export * from "./getOrgOverview";
export * from "./listOrgs";

View file

@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Org, orgs } from "@server/db/schemas";
import { Org, orgs, userOrgs } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, inArray } from "drizzle-orm";
import { sql, inArray, eq } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@ -27,8 +27,8 @@ const listOrgsSchema = z.object({
registry.registerPath({
method: "get",
path: "/orgs",
description: "List all organizations in the system",
path: "/user/:userId/orgs",
description: "List all organizations in the system.",
tags: [OpenAPITags.Org],
request: {
query: listOrgsSchema
@ -59,37 +59,15 @@ export async function listOrgs(
const { limit, offset } = parsedQuery.data;
// Use the userOrgs passed from the middleware
const userOrgIds = req.userOrgIds;
if (!userOrgIds || userOrgIds.length === 0) {
return response<ListOrgsResponse>(res, {
data: {
orgs: [],
pagination: {
total: 0,
limit,
offset
}
},
success: true,
error: false,
message: "No organizations found for the user",
status: HttpCode.OK
});
}
const organizations = await db
.select()
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds))
.limit(limit)
.offset(offset);
const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds));
.from(orgs);
const totalCount = totalCountResult[0].count;
return response<ListOrgsResponse>(res, {

View 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..."
)
);
}
}

View file

@ -39,7 +39,6 @@ const createHttpResourceSchema = z
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
domainId: z.string()
})
.strict()
@ -203,7 +202,7 @@ async function createHttpResource(
);
}
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
const { name, subdomain, isBaseDomain, http, domainId } =
parsedBody.data;
const [orgDomain] = await db
@ -262,7 +261,7 @@ async function createHttpResource(
name,
subdomain,
http,
protocol,
protocol: "tcp",
ssl: true,
isBaseDomain
})

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -23,10 +23,15 @@ async function queryUsers(resourceId: number) {
return await db
.select({
userId: userResources.userId,
username: users.username,
type: users.type,
idpName: idp.name,
idpId: users.idpId,
email: users.email
})
.from(userResources)
.innerJoin(users, eq(userResources.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userResources.resourceId, resourceId));
}

View file

@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm";
import logger from "@server/logger";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { fromZodError } from "zod-validation-error";
const listUsersSchema = z
@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) {
.select({
id: users.userId,
email: users.email,
username: users.username,
name: users.name,
dateCreated: users.dateCreated,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
type: users.type,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.serverAdmin, false))
.limit(limit)
.offset(offset);

View 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")
);
}
}

View file

@ -17,6 +17,9 @@ async function queryUser(orgId: string, userId: string) {
orgId: userOrgs.orgId,
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { users } from "@server/db/schemas";
import { idp, users } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -13,11 +13,17 @@ async function queryUser(userId: string) {
.select({
userId: users.userId,
email: users.email,
username: users.username,
name: users.name,
type: users.type,
twoFactorEnabled: users.twoFactorEnabled,
emailVerified: users.emailVerified,
serverAdmin: users.serverAdmin
serverAdmin: users.serverAdmin,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(users.userId, userId))
.limit(1);
return user;

View file

@ -9,3 +9,4 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";

View file

@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails";
import SendInviteLink from "@server/emails/templates/SendInviteLink";
import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes";
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
@ -115,7 +116,13 @@ export async function inviteUser(
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.where(
and(
eq(users.email, email),
eq(userOrgs.orgId, orgId),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser.length) {
@ -190,7 +197,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,
@ -242,7 +249,7 @@ export async function inviteUser(
inviteLink,
expiresInDays: (validHours / 24).toString(),
orgName: org[0].name || orgId,
inviterName: req.user?.email
inviterName: req.user?.email || req.user?.username
}),
{
to: email,

View file

@ -1,14 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roles, userOrgs, users } from "@server/db/schemas";
import { idp, roles, userOrgs, users } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { and, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z
.object({
@ -41,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
emailVerified: users.emailVerified,
dateCreated: users.dateCreated,
orgId: userOrgs.orgId,
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId
})
.from(users)
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)
.leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`)
.where(sql`${userOrgs.orgId} = ${orgId}`)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
}
@ -107,7 +114,8 @@ export async function listUsers(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
return response<ListUsersResponse>(res, {
data: {

View file

@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
import moment from "moment";
import { fromError } from "zod-validation-error";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
export async function setupServerAdmin() {
const {
@ -34,7 +35,7 @@ export async function setupServerAdmin() {
if (existing) {
const passwordChanged = !(await verifyPassword(
password,
existing.passwordHash
existing.passwordHash!
));
if (passwordChanged) {
@ -65,6 +66,8 @@ export async function setupServerAdmin() {
await db.insert(users).values({
userId: userId,
email: email,
type: UserType.Internal,
username: email,
passwordHash,
dateCreated: moment().toISOString(),
serverAdmin: true,

View file

@ -0,0 +1,4 @@
export enum UserType {
Internal = "internal",
OIDC = "oidc"
}

View file

@ -8,7 +8,8 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { orgNavItems } from "../navigation";
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
redirect(`/${orgId}/settings`);
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<UserProvider user={user}>
<Layout
orgId={orgId}
navItems={orgNavItems}
>
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
{overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard

View file

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

View file

@ -24,7 +24,7 @@ export function UsersDataTable<TData, TValue>({
searchPlaceholder="Search users..."
searchColumn="email"
onAdd={inviteUser}
addButtonText="Invite User"
addButtonText="Create User"
/>
);
}

View file

@ -11,7 +11,6 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable";
import { useState } from "react";
import InviteUserForm from "./InviteUserForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@ -24,7 +23,13 @@ import { useUserContext } from "@app/hooks/useUserContext";
export type UserRow = {
id: string;
email: string;
email: string | null;
displayUsername: string | null;
username: string;
name: string | null;
idpId: number | null;
idpName: string;
type: string;
status: string;
role: string;
isOwner: boolean;
@ -35,16 +40,11 @@ type UsersTableProps = {
};
export default function UsersTable({ users: u }: UsersTableProps) {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const api = createApiClient(useEnvContext());
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
@ -82,7 +82,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
Manage User
</DropdownMenuItem>
</Link>
{userRow.email !== user?.email && (
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${userRow.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
accessorKey: "email",
accessorKey: "displayUsername",
header: ({ column }) => {
return (
<Button
@ -117,14 +118,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Email
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "status",
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
@ -133,7 +134,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Status
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outlinePrimary"} className="ml-2">
<Button
variant={"outlinePrimary"}
className="ml-2"
>
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<div className="space-y-4">
<p>
Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization?
<b>
{selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username}
</b>{" "}
from the organization?
</p>
<p>
@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p>
<p>
To confirm, please type the email address of the
user below.
To confirm, please type the name of the of the user
below.
</p>
</div>
}
buttonText="Confirm Remove User"
onConfirm={removeUser}
string={selectedUser?.email ?? ""}
string={
selectedUser?.email ||
selectedUser?.name ||
selectedUser?.username ||
""
}
title="Remove User from Organization"
/>
<InviteUserForm
open={isInviteModalOpen}
setOpen={setIsInviteModalOpen}
/>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
setIsInviteModalOpen(true);
router.push(`/${org?.org.orgId}/settings/access/users/create`);
}}
/>
</>

View 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>
</>
);
}

View file

@ -70,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) {
const userRows: UserRow[] = users.map((user) => {
return {
id: user.id,
username: user.username,
displayUsername: user.email || user.name || user.username,
name: user.name,
email: user.email,
type: user.type,
idpId: user.idpId,
idpName: user.idpName || "Internal",
status: "Confirmed",
role: user.isOwner ? "Owner" : user.roleName || "Member",
isOwner: user.isOwner || false

View file

@ -31,7 +31,7 @@ import {
CardTitle
} from "@/components/ui/card";
import { AxiosResponse } from "axios";
import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { redirect, useRouter } from "next/navigation";
import {
SettingsContainer,
@ -43,6 +43,7 @@ import {
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
const GeneralFormSchema = z.object({
name: z.string()
@ -57,6 +58,7 @@ export default function GeneralPage() {
const router = useRouter();
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const { user } = useUserContext();
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
@ -101,7 +103,9 @@ export default function GeneralPage() {
async function pickNewOrgAndNavigate() {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`);
const res = await api.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`
);
if (res.status === 200) {
if (res.data.data.orgs.length > 0) {
@ -237,9 +241,7 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Danger Zone
</SettingsSectionTitle>
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
<SettingsSectionDescription>
Once you delete this org, there is no going back. Please
be certain.

View file

@ -11,7 +11,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
@ -62,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}`);
}
let orgs: ListOrgsResponse["orgs"] = [];
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
cookie
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {

View file

@ -21,10 +21,8 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
@ -58,10 +56,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [selectedResource, setSelectedResource] = useState<ResourceRow | null>();
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
return (
<>
<CreateResourceForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
/>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
@ -328,7 +319,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
columns={columns}
data={resources}
createResource={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/resources/create`);
}}
/>
</>

View file

@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
</FormItem>
)}
/>

View file

@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
</FormItem>
)}
/>

View file

@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
const UsersRolesFormSchema = z.object({
roles: z.array(
@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() {
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: user.email
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
@ -183,7 +186,7 @@ export default function ResourceAuthenticationPage() {
"users",
resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(),
text: i.email
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);
@ -611,117 +614,127 @@ export default function ResourceAuthenticationPage() {
</SettingsSectionBody>
</SettingsSection>
{env.email.emailEnabled && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
SMTP Required
</AlertTitle>
<AlertDescription>
SMTP must be enabled on the server to use one-time password authentication.
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);

View 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");
}}
/>
);
}

View 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} />
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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`);
}

View 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>
</>
);
}

View 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} />
</>
);
}

View file

@ -4,7 +4,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org";
import { ListUserOrgsResponse } from "@server/routers/org";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
@ -31,10 +31,13 @@ export default async function AdminLayout(props: LayoutProps) {
}
const cookie = await authCookieHeader();
let orgs: ListOrgsResponse["orgs"] = [];
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
cookie
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {

View file

@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
export type GlobalUserRow = {
id: string;
email: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
};
@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) {
);
}
},
{
accessorKey: "username",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Username
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
header: ({ column }) => {
@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) {
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Identity Provider
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {
<div className="space-y-4">
<p>
Are you sure you want to permanently delete{" "}
<b>{selected?.email || selected?.id}</b> from
the server?
<b>
{selected?.email ||
selected?.name ||
selected?.username}
</b>{" "}
from the server?
</p>
<p>
@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {
</p>
<p>
To confirm, please type the email of the user
To confirm, please type the name of the user
below.
</p>
</div>
}
buttonText="Confirm Delete User"
onConfirm={async () => deleteUser(selected!.id)}
string={selected.email}
string={
selected.email || selected.name || selected.username
}
title="Delete User from Server"
/>
)}

View file

@ -4,6 +4,8 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
type PageProps = {
params: Promise<{ orgId: string }>;
@ -27,6 +29,11 @@ export default async function UsersPage(props: PageProps) {
return {
id: row.id,
email: row.email,
name: row.name,
username: row.username,
type: row.type,
idpId: row.idpId,
idpName: row.idpName || "Internal",
dateCreated: row.dateCreated,
serverAdmin: row.serverAdmin
};
@ -38,6 +45,13 @@ export default async function UsersPage(props: PageProps) {
title="Manage All Users"
description="View and manage all users in the system"
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">About User Management</AlertTitle>
<AlertDescription>
This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.
</AlertDescription>
</Alert>
<UsersTable users={userRows} />
</>
);

View 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>
);
}

View 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 }}
/>
</>
);
}

View file

@ -8,7 +8,7 @@ import {
CardTitle
} from "@/components/ui/card";
import { createApiClient } from "@app/lib/api";
import LoginForm from "@app/components/LoginForm";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
type DashboardLoginFormProps = {
redirect?: string;
idps?: LoginFormIDP[];
};
export default function DashboardLoginForm({
redirect
redirect,
idps
}: DashboardLoginFormProps) {
const router = useRouter();
// const api = createApiClient(useEnvContext());
@ -51,12 +53,15 @@ export default function DashboardLoginForm({
<h1 className="text-2xl font-bold mt-1">
Welcome to Pangolin
</h1>
<p className="text-sm text-muted-foreground">Log in to get started</p>
<p className="text-sm text-muted-foreground">
Log in to get started
</p>
</div>
</CardHeader>
<CardContent>
<LoginForm
redirect={redirect}
idps={idps}
onLogin={() => {
if (redirect) {
const safe = cleanRedirect(redirect);

View file

@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import db from "@server/db";
import { idp } from "@server/db/schemas";
import { LoginFormIDP } from "@app/components/LoginForm";
export const dynamic = "force-dynamic";
@ -31,10 +34,16 @@ export default async function Page(props: {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
const idps = await db.select().from(idp);
const loginIdps = idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name
})) as LoginFormIDP[];
return (
<>
{isInvite && (
<div className="border rounded-md p-3 mb-4">
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
@ -48,7 +57,7 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={redirectUrl} />
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">

View file

@ -33,7 +33,7 @@ import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import LoginForm from "@app/components/LoginForm";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithWhitelistResponse
@ -81,6 +81,7 @@ type ResourceAuthPortalProps = {
id: number;
};
redirect: string;
idps?: LoginFormIDP[];
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@ -376,31 +377,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
index={
0
}
obscured
/>
<InputOTPSlot
index={
1
}
obscured
/>
<InputOTPSlot
index={
2
}
obscured
/>
<InputOTPSlot
index={
3
}
obscured
/>
<InputOTPSlot
index={
4
}
obscured
/>
<InputOTPSlot
index={
5
}
obscured
/>
</InputOTPGroup>
</InputOTP>
@ -490,7 +497,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<LoginForm
redirect={`/auth/resource/${props.resource.id}`}
idps={props.idps}
redirect={props.redirect}
onLogin={async () =>
await handleSSOAuth()
}

View file

@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound";
import ResourceAccessDenied from "./ResourceAccessDenied";
import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
import { LoginFormIDP } from "@app/components/LoginForm";
import db from "@server/db";
import { idp } from "@server/db/schemas";
export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>;
@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: {
redirect(redirectUrl);
}
// convert the dashboard token into a resource session token
let userIsUnauthorized = false;
if (user && authInfo.sso) {
@ -128,6 +130,12 @@ export default async function ResourceAuthPage(props: {
);
}
const idps = await db.select().from(idp);
const loginIdps = idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name
})) as LoginFormIDP[];
return (
<>
{userIsUnauthorized && isSSOOnly ? (
@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: {
id: authInfo.resourceId
}}
redirect={redirectUrl}
idps={loginIdps}
/>
</div>
)}

View file

@ -50,7 +50,7 @@ export default async function Page(props: {
return (
<>
{isInvite && (
<div className="border rounded-md p-3 mb-4">
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">

View file

@ -36,7 +36,7 @@ export default async function Page(props: {
return (
<>
<VerifyEmailForm
email={user.email}
email={user.email!}
redirect={redirectUrl}
/>
</>

View file

@ -1,4 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap");
@import 'tw-animate-css';
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@ -23,7 +24,7 @@
--border: hsl(20 5.9% 90%);
--input: hsl(20 5.9% 75%);
--ring: hsl(24.6 95% 53.1%);
--radius: 0.50rem;
--radius: 0.75rem;
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);

View file

@ -1,6 +1,13 @@
import type { Metadata } from "next";
import "./globals.css";
import { Figtree, Inter, Red_Hat_Display, Red_Hat_Mono, Red_Hat_Text, Space_Grotesk } from "next/font/google";
import {
Figtree,
Inter,
Red_Hat_Display,
Red_Hat_Mono,
Red_Hat_Text,
Space_Grotesk
} from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";

View file

@ -5,32 +5,42 @@ import {
Users,
Link as LinkIcon,
Waypoints,
Combine
Combine,
Fingerprint,
KeyRound
} from "lucide-react";
export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "Overview",
href: "/{orgId}",
icon: <Home className="h-4 w-4" />
}
];
export const rootNavItems: SidebarNavItem[] = [
{
title: "Home",
href: "/"
// icon: <Home className="h-4 w-4" />
href: "/",
icon: <Home className="h-4 w-4" />
}
];
export const orgNavItems: SidebarNavItem[] = [
{
title: "Sites",
href: "/{orgId}/settings/sites"
// icon: <Combine className="h-4 w-4" />
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
},
{
title: "Resources",
href: "/{orgId}/settings/resources"
// icon: <Waypoints className="h-4 w-4" />
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "Access Control",
href: "/{orgId}/settings/access",
// icon: <Users className="h-4 w-4" />,
icon: <Users className="h-4 w-4" />,
autoExpand: true,
children: [
{
@ -51,20 +61,37 @@ export const orgNavItems: SidebarNavItem[] = [
},
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links"
// icon: <LinkIcon className="h-4 w-4" />
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "API Keys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showEnterprise: true
},
{
title: "Settings",
href: "/{orgId}/settings/general"
// icon: <Settings className="h-4 w-4" />
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
}
];
export const adminNavItems: SidebarNavItem[] = [
{
title: "All Users",
href: "/admin/users"
// icon: <Users className="h-4 w-4" />
href: "/admin/users",
icon: <Users className="h-4 w-4" />
},
{
title: "API Keys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showEnterprise: true
},
{
title: "Identity Providers",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
}
];

View file

@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { ListOrgsResponse } from "@server/routers/org";
import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
@ -36,10 +36,7 @@ export default async function Page(props: {
}
}
if (
!user.emailVerified &&
env.flags.emailVerificationRequired
) {
if (!user.emailVerified && env.flags.emailVerificationRequired) {
if (params.redirect) {
const safe = cleanRedirect(params.redirect);
redirect(`/auth/verify-email?redirect=${safe}`);
@ -48,10 +45,10 @@ export default async function Page(props: {
}
}
let orgs: ListOrgsResponse["orgs"] = [];
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`,
const res = await internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
);
@ -61,24 +58,19 @@ export default async function Page(props: {
} catch (e) {}
if (!orgs.length) {
if (
!env.flags.disableUserCreateOrg ||
user.serverAdmin
) {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
}
}
return (
<UserProvider user={user}>
<Layout
orgs={orgs}
navItems={rootNavItems}
showBreadcrumbs={false}
>
<Layout orgs={orgs} navItems={rootNavItems} showBreadcrumbs={false}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
disableCreateOrg={
env.flags.disableUserCreateOrg && !user.serverAdmin
}
organizations={orgs.map((org) => ({
name: org.name,
id: org.orgId

View file

@ -61,6 +61,9 @@ export default function StepperForm() {
const router = useRouter();
const checkOrgIdAvailability = useCallback(async (value: string) => {
if (loading) {
return;
}
try {
const res = await api.get(`/org/checkId`, {
params: {

View file

@ -18,53 +18,51 @@ export function Breadcrumbs() {
const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = segment;
// Format labels
if (segment === "settings") {
label = "Settings";
} else if (segment === "sites") {
label = "Sites";
} else if (segment === "resources") {
label = "Resources";
} else if (segment === "access") {
label = "Access Control";
} else if (segment === "general") {
label = "General";
} else if (segment === "share-links") {
label = "Shareable Links";
} else if (segment === "users") {
label = "Users";
} else if (segment === "roles") {
label = "Roles";
} else if (segment === "invitations") {
label = "Invitations";
} else if (segment === "connectivity") {
label = "Connectivity";
} else if (segment === "authentication") {
label = "Authentication";
}
// // Format labels
// if (segment === "settings") {
// label = "Settings";
// } else if (segment === "sites") {
// label = "Sites";
// } else if (segment === "resources") {
// label = "Resources";
// } else if (segment === "access") {
// label = "Access Control";
// } else if (segment === "general") {
// label = "General";
// } else if (segment === "share-links") {
// label = "Shareable Links";
// } else if (segment === "users") {
// label = "Users";
// } else if (segment === "roles") {
// label = "Roles";
// } else if (segment === "invitations") {
// label = "Invitations";
// } else if (segment === "connectivity") {
// label = "Connectivity";
// } else if (segment === "authentication") {
// label = "Authentication";
// }
return { label, href };
});
return (
<div className="border-b px-4 py-2 overflow-x-auto scrollbar-hide bg-card">
<nav className="flex items-center space-x-1 text-sm text-muted-foreground whitespace-nowrap">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center whitespace-nowrap">
{index !== 0 && <ChevronRight className="h-4 w-4" />}
<Link
href={crumb.href}
className={cn(
"ml-1 hover:text-foreground whitespace-nowrap",
index === breadcrumbs.length - 1 &&
"text-foreground font-medium"
)}
>
{crumb.label}
</Link>
</div>
))}
</nav>
</div>
<nav className="flex items-center space-x-1 text-muted-foreground">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center flex-nowrap">
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
<Link
href={crumb.href}
className={cn(
"ml-1 hover:text-foreground whitespace-nowrap",
index === breadcrumbs.length - 1 &&
"text-foreground font-medium"
)}
>
{crumb.label}
</Link>
</div>
))}
</nav>
);
}

View file

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

View file

@ -1,16 +1,15 @@
"use client";
import React, { useState } from "react";
import { Header } from "@app/components/Header";
import { SidebarNav } from "@app/components/SidebarNav";
import { TopBar } from "@app/components/TopBar";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListOrgsResponse } from "@server/routers/org";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Separator } from "@app/components/ui/separator";
import { Button } from "@app/components/ui/button";
import { ExternalLink, Menu, X } from "lucide-react";
import { ExternalLink, Menu, X, Server } from "lucide-react";
import Image from "next/image";
import ProfileIcon from "@app/components/ProfileIcon";
import {
Sheet,
SheetContent,
@ -21,11 +20,13 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
interface LayoutProps {
children: React.ReactNode;
orgId?: string;
orgs?: ListOrgsResponse["orgs"];
orgs?: ListUserOrgsResponse["orgs"];
navItems?: Array<{
title: string;
href: string;
@ -54,109 +55,193 @@ export function Layout({
}: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { env } = useEnvContext();
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile Menu Button */}
{showSidebar && (
<div className="md:hidden fixed top-4 left-4 z-50">
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
Navigation Menu
</SheetTitle>
<SheetDescription className="sr-only">
Main navigation menu for the application
</SheetDescription>
{showHeader && (
<div className="flex h-16 items-center border-b px-4 shrink-0">
<Header orgId={orgId} orgs={orgs} />
<div className="flex flex-col h-screen overflow-hidden">
{/* Full width header */}
{showHeader && (
<div className="border-b shrink-0 bg-card">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div className="md:hidden">
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
Navigation Menu
</SheetTitle>
<SheetDescription className="sr-only">
Main navigation menu for the
application
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav
items={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
{!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<Server className="h-4 w-4" />
Server Admin
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
<SidebarNav items={navItems} onItemClick={() => setIsMobileMenuOpen(false)} />
<Link
href="/"
className="flex items-center hidden md:block"
>
<Image
src="/logo/pangolin_orange.svg"
alt="Pangolin Logo"
width={35}
height={35}
/>
</Link>
{showBreadcrumbs && (
<div className="hidden md:block overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end md:justify-between">
<div className="hidden md:flex items-center space-x-3 mr-6">
<Link
href="https://docs.fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Documentation
</Link>
<Link
href="mailto:support@fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
</Link>
</div>
<div>
<ProfileIcon />
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
)}
</div>
{showBreadcrumbs && (
<div className="md:hidden px-4 pb-2 overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav items={navItems} />
</div>
{!isAdminPage && user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
>
<Server className="h-4 w-4" />
Server Admin
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Open Source
<ExternalLink size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
{/* Desktop Sidebar */}
{showSidebar && (
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
{showHeader && (
<div className="flex h-16 items-center border-b px-4 shrink-0">
<Header orgId={orgId} orgs={orgs} />
</div>
</div>
)}
{/* Main content */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
!showSidebar && "w-full"
)}
<div className="flex-1 overflow-y-auto p-4">
<SidebarNav items={navItems} />
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Open Source
<ExternalLink size={12}/>
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
{children}
</div>
</div>
</main>
</div>
)}
{/* Main content */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
!showSidebar && "w-full"
)}
>
{showTopBar && (
<div className="h-16 border-b shrink-0 bg-card">
<div className="flex h-full items-center justify-end px-4">
<TopBar orgId={orgId} orgs={orgs} />
</div>
</div>
)}
{showBreadcrumbs && <Breadcrumbs />}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl">
{children}
</div>
</main>
</div>
</div>
);

View file

@ -25,7 +25,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@ -37,11 +37,19 @@ import {
} from "./ui/input-otp";
import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from 'next/image'
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
export type LoginFormIDP = {
idpId: number;
name: string;
};
type LoginFormProps = {
redirect?: string;
onLogin?: () => void | Promise<void>;
idps?: LoginFormIDP[];
};
const formSchema = z.object({
@ -55,7 +63,7 @@ const mfaSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@ -64,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
@ -130,60 +139,83 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
setLoading(false);
}
async function loginWithIdp(idpId: number) {
try {
const res = await api.post<AxiosResponse<GenerateOidcUrlResponse>>(
`/auth/idp/${idpId}/oidc/generate-url`,
{
redirectUrl: redirect || "/"
}
);
console.log(res);
if (!res) {
setError("An error occurred while logging in");
return;
}
const data = res.data.data;
window.location.href = data.redirectUrl;
} catch (e) {
console.error(formatAxiosError(e));
}
}
return (
<div className="space-y-4">
{!mfaRequested && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="password"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-center">
<Link
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
</Link>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="text-center">
<Link
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
</Link>
</div>
</div>
</div>
</form>
</Form>
</form>
</Form>
</>
)}
{mfaRequested && (
@ -193,7 +225,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
Two-Factor Authentication
</h3>
<p className="text-sm text-muted-foreground">
Enter the code from your authenticator app or one of your single-use backup codes.
Enter the code from your authenticator app or one of
your single-use backup codes.
</p>
</div>
<Form {...mfaForm}>
@ -268,16 +301,47 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
)}
{!mfaRequested && (
<Button
type="submit"
form="form"
className="w-full"
loading={loading}
disabled={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Log In
</Button>
<>
<Button
type="submit"
form="form"
className="w-full"
loading={loading}
disabled={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Log In
</Button>
{hasIdp && (
<>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
Or continue with
</span>
</div>
</div>
{idps.map((idp) => (
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
{idp.name}
</Button>
))}
</>
)}
</>
)}
{mfaRequested && (

View file

@ -17,7 +17,7 @@ import {
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListOrgsResponse } from "@server/routers/org";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
@ -25,7 +25,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
interface OrgSelectorProps {
orgId?: string;
orgs?: ListOrgsResponse["orgs"];
orgs?: ListUserOrgsResponse["orgs"];
}
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
@ -121,4 +121,4 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverContent>
</Popover>
);
}
}

View file

@ -38,7 +38,9 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false);
function getInitials() {
return user.email.substring(0, 1).toUpperCase();
return (user.email || user.name || user.username)
.substring(0, 1)
.toUpperCase();
}
function handleThemeChange(theme: "light" | "dark" | "system") {
@ -66,9 +68,9 @@ export default function ProfileIcon() {
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<div className="flex items-center md:gap-4 grow min-w-0 gap-2 md:gap-0">
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
{user.email}
{user.email || user.name || user.username}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -92,13 +94,17 @@ export default function ProfileIcon() {
Signed in as
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
{user.email || user.name || user.username}
</p>
</div>
{user.serverAdmin && (
{user.serverAdmin ? (
<p className="text-xs leading-none text-muted-foreground mt-2">
Server Admin
</p>
) : (
<p className="text-xs leading-none text-muted-foreground mt-2">
{user.idpName || "Internal"}
</p>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />

View file

@ -1,31 +1,69 @@
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>
return <div className="space-y-6">{children}</div>;
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5">{children}</div>
return <div className="border rounded-lg bg-card p-5">{children}</div>;
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
export function SettingsSectionHeader({
children
}: {
children: React.ReactNode;
}) {
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
return <div className="max-w-xl">{children}</div>
export function SettingsSectionForm({
children
}: {
children: React.ReactNode;
}) {
return <div className="max-w-xl">{children}</div>;
}
export function SettingsSectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">{children}</h2>
export function SettingsSectionTitle({
children
}: {
children: React.ReactNode;
}) {
return (
<h2 className="text-1xl font-bold tracking-tight flex items-center gap-2">
{children}
</h2>
);
}
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
return <p className="text-muted-foreground text-sm">{children}</p>
export function SettingsSectionDescription({
children
}: {
children: React.ReactNode;
}) {
return <p className="text-muted-foreground text-sm">{children}</p>;
}
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {
return <div className="space-y-5">{children}</div>
export function SettingsSectionBody({
children
}: {
children: React.ReactNode;
}) {
return <div className="space-y-5">{children}</div>;
}
export function SettingsSectionFooter({ children }: { children: React.ReactNode }) {
return <div className="flex justify-end space-x-4 mt-8">{children}</div>
export function SettingsSectionFooter({
children
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
}
export function SettingsSectionGrid({
children,
cols
}: {
children: React.ReactNode;
cols: number;
}) {
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
}

View file

@ -5,6 +5,8 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge";
export interface SidebarNavItem {
href: string;
@ -12,6 +14,7 @@ export interface SidebarNavItem {
icon?: React.ReactNode;
children?: SidebarNavItem[];
autoExpand?: boolean;
showEnterprise?: boolean;
}
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
@ -35,25 +38,7 @@ export function SidebarNav({
const userId = params.userId as string;
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
// Initialize expanded items based on autoExpand property
useEffect(() => {
const autoExpanded = new Set<string>();
function findAutoExpanded(items: SidebarNavItem[]) {
items.forEach(item => {
const hydratedHref = hydrateHref(item.href);
if (item.autoExpand) {
autoExpanded.add(hydratedHref);
}
if (item.children) {
findAutoExpanded(item.children);
}
});
}
findAutoExpanded(items);
setExpandedItems(autoExpanded);
}, [items]);
const { user } = useUserContext();
function hydrateHref(val: string): string {
return val
@ -63,8 +48,39 @@ export function SidebarNav({
.replace("{userId}", userId);
}
// Initialize expanded items based on autoExpand property and current path
useEffect(() => {
const autoExpanded = new Set<string>();
function findAutoExpandedAndActivePath(
items: SidebarNavItem[],
parentHrefs: string[] = []
) {
items.forEach((item) => {
const hydratedHref = hydrateHref(item.href);
// Add current item's href to the path
const currentPath = [...parentHrefs, hydratedHref];
// Auto expand if specified or if this item or any child is active
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
// Expand all parent sections when a child is active
currentPath.forEach((href) => autoExpanded.add(href));
}
// Recursively check children
if (item.children) {
findAutoExpandedAndActivePath(item.children, currentPath);
}
});
}
findAutoExpandedAndActivePath(items);
setExpandedItems(autoExpanded);
}, [items, pathname]);
function toggleItem(href: string) {
setExpandedItems(prev => {
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
@ -81,46 +97,67 @@ export function SidebarNav({
const isActive = pathname.startsWith(hydratedHref);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(hydratedHref);
const indent = level * 16; // Base indent for each level
const indent = level * 28; // Base indent for each level
const isEnterprise = item.showEnterprise;
const isDisabled = disabled || isEnterprise;
return (
<div key={hydratedHref}>
<div className="flex items-center group" style={{ marginLeft: `${indent}px` }}>
<Link
href={hydratedHref}
<div
className="flex items-center group"
style={{ marginLeft: `${indent}px` }}
>
<div
className={cn(
"flex items-center py-1 w-full transition-colors",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-60"
"flex items-center w-full transition-colors rounded-md",
isActive && level === 0 && "bg-primary/10"
)}
onClick={(e) => {
if (disabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.title}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 hover:bg-muted rounded-md ml-auto"
disabled={disabled}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
<Link
href={isEnterprise ? "#" : hydratedHref}
className={cn(
"flex items-center w-full px-3 py-2",
isActive
? "text-primary font-medium"
: "text-muted-foreground group-hover:text-foreground",
isDisabled && "cursor-not-allowed"
)}
</button>
)}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div className={cn("flex items-center", isDisabled && "opacity-60")}>
{item.icon && (
<span className="mr-3">{item.icon}</span>
)}
{item.title}
</div>
{isEnterprise && (
<Badge className="ml-2">
Enterprise
</Badge>
)}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
disabled={isDisabled}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</button>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div className="space-y-1 mt-1">
@ -135,7 +172,7 @@ export function SidebarNav({
return (
<nav
className={cn(
"flex flex-col space-y-1",
"flex flex-col space-y-2",
disabled && "pointer-events-none opacity-60",
className
)}

View file

@ -7,6 +7,7 @@ interface SwitchComponentProps {
label: string;
description?: string;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange: (checked: boolean) => void;
}
@ -14,6 +15,7 @@ export function SwitchInput({
id,
label,
description,
disabled,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
@ -24,6 +26,7 @@ export function SwitchInput({
id={id}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
<Label htmlFor={id}>{label}</Label>
</div>

View file

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

View file

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-bottom-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg",
className
)}
{...props}

View file

@ -10,24 +10,29 @@ import {
import { Button } from "@/components/ui/button";
interface InfoPopupProps {
text: string;
text?: string;
info: string;
trigger?: React.ReactNode;
}
export function InfoPopup({ text, info }: InfoPopupProps) {
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
const defaultTrigger = (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4" />
<span className="sr-only">Show info</span>
</Button>
);
return (
<div className="flex items-center space-x-2">
<span>{text}</span>
{text && <span>{text}</span>}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4" />
<span className="sr-only">Show info</span>
</Button>
{trigger ?? defaultTrigger}
</PopoverTrigger>
<PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p>

View file

@ -8,8 +8,8 @@ import { cn } from "@app/lib/cn"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean }
>(({ className, containerClassName, obscured = false, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
@ -32,8 +32,8 @@ InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean }
>(({ index, className, obscured = false, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
@ -47,7 +47,7 @@ const InputOTPSlot = React.forwardRef<
)}
{...props}
>
{char}
{char && obscured ? "•" : char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />

View file

@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}

View file

@ -29,7 +29,7 @@ const toastVariants = cva(
{
variants: {
variant: {
default: "border bg-background text-foreground",
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},

View file

@ -9,10 +9,10 @@ const patterns: PatternConfig[] = [
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }
];
export function cleanRedirect(input: string): string {
export function cleanRedirect(input: string, fallback?: string): string {
if (!input || typeof input !== "string") {
return "/";
}
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
return isAccepted ? input : "/";
return isAccepted ? input : fallback || "/";
}