diff --git a/package-lock.json b/package-lock.json index 20bc0462..812635e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c1f9fd64..2cca81ec 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,18 @@ "@react-email/components": "0.0.36", "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.20.6", + "arctic": "^3.6.0", "axios": "1.8.4", "better-sqlite3": "11.7.0", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.0.4", + "cookie": "^1.0.2", "cookie-parser": "1.4.7", + "cookies": "^0.9.1", "cors": "2.8.5", "drizzle-orm": "0.38.3", "eslint": "9.17.0", @@ -62,7 +66,9 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.1", + "jmespath": "^0.16.0", "js-yaml": "4.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.469.0", "moment": "2.30.1", "next": "15.2.4", @@ -82,7 +88,7 @@ "semver": "7.6.3", "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", - "tailwindcss-animate": "1.0.7", + "tw-animate-css": "^1.2.5", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -98,7 +104,9 @@ "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", "@types/express": "5.0.0", + "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", "@types/react": "19.1.1", @@ -112,7 +120,7 @@ "esbuild-node-externals": "1.18.0", "postcss": "^8", "react-email": "4.0.6", - "tailwindcss": "^4.1.3", + "tailwindcss": "^4.1.4", "tsc-alias": "1.8.10", "tsx": "4.19.3", "typescript": "^5", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 009d5c21..251abb45 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -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( diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 1c594545..b92759b6 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -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; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -455,3 +493,4 @@ export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; +export type Idp = InferSelectModel; diff --git a/server/lib/config.ts b/server/lib/config.ts index f6f4c447..8bac6809 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -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(), diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts new file mode 100644 index 00000000..e1e9c2b1 --- /dev/null +++ b/server/lib/crypto.ts @@ -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"); +} diff --git a/server/lib/idp/generateRedirectUrl.ts b/server/lib/idp/generateRedirectUrl.ts new file mode 100644 index 00000000..4eea973e --- /dev/null +++ b/server/lib/idp/generateRedirectUrl.ts @@ -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; +} diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index b02f5b18..f5a9cdc1 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -14,4 +14,5 @@ export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; -export * from "./verifyUserIsServerAdmin"; \ No newline at end of file +export * from "./verifyUserIsServerAdmin"; +export * from "./verifyIsLoggedInUser"; diff --git a/server/middlewares/verifyIsLoggedInUser.ts b/server/middlewares/verifyIsLoggedInUser.ts new file mode 100644 index 00000000..bee066b7 --- /dev/null +++ b/server/middlewares/verifyIsLoggedInUser.ts @@ -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" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 8a02e886..43e84e56 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -11,5 +11,6 @@ export enum OpenAPITags { Invitation = "Invitation", Target = "Target", Rule = "Rule", - AccessToken = "Access Token" + AccessToken = "Access Token", + Idp = "Identity Provider" } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 3be9ef2e..3b1e4c2f 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -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()); diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 45644461..b10dd9b2 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -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" } diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index aa4f0d53..eda637fa 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -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) { diff --git a/server/routers/auth/requestEmailVerificationCode.ts b/server/routers/auth/requestEmailVerificationCode.ts index 47747a95..0cc8825c 100644 --- a/server/routers/auth/requestEmailVerificationCode.ts +++ b/server/routers/auth/requestEmailVerificationCode.ts @@ -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(res, { data: { diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 20a6511a..087352f0 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -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() }); diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index c60904ce..a4f8bc4a 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -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) diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 833850ce..564a1378 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -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() diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index a349d79d..db4ec1a1 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -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" } diff --git a/server/routers/external.ts b/server/routers/external.ts index 9c747e01..addd922b 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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); diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts new file mode 100644 index 00000000..2591de10 --- /dev/null +++ b/server/routers/idp/createOidcIdp.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts new file mode 100644 index 00000000..79edd547 --- /dev/null +++ b/server/routers/idp/deleteIdp.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts new file mode 100644 index 00000000..6d111451 --- /dev/null +++ b/server/routers/idp/generateOidcUrl.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts new file mode 100644 index 00000000..794daade --- /dev/null +++ b/server/routers/idp/getIdp.ts @@ -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>>; + +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 { + 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(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") + ); + } +} diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts new file mode 100644 index 00000000..185effde --- /dev/null +++ b/server/routers/idp/index.ts @@ -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"; diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts new file mode 100644 index 00000000..76d0be87 --- /dev/null +++ b/server/routers/idp/listIdps.ts @@ -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 { + 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`count(*)` }) + .from(idp); + + return response(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") + ); + } +} diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts new file mode 100644 index 00000000..4eba73d2 --- /dev/null +++ b/server/routers/idp/updateOidcIdp.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts new file mode 100644 index 00000000..006c14a4 --- /dev/null +++ b/server/routers/idp/validateOidcCallback.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 04ff1362..5623823d 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -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"; diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index f299e3f2..611b928e 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -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(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`cast(count(*) as integer)` }) - .from(orgs) - .where(inArray(orgs.orgId, userOrgIds)); + .from(orgs); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts new file mode 100644 index 00000000..64d0871f --- /dev/null +++ b/server/routers/org/listUserOrgs.ts @@ -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 { + 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(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`cast(count(*) as integer)` }) + .from(orgs) + .where(inArray(orgs.orgId, userOrgIds)); + const totalCount = totalCountResult[0].count; + + return response(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..." + ) + ); + } +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d43a4fdd..af6807b9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -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 }) diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index 20411b1a..4699ec8b 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -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)); } diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 2d95756d..6de12be9 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -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); diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts new file mode 100644 index 00000000..3ca2a5a3 --- /dev/null +++ b/server/routers/user/createOrgUser.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 2763f809..f03cf0f0 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -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, diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index 31c7d8a5..2f80be90 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -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; diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 8e8fd391..49278c14 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -9,3 +9,4 @@ export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./listInvitations"; export * from "./removeInvitation"; +export * from "./createOrgUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index eb9cdb61..042942ab 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -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, diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index c4215181..fd2291d5 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -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`count(*)` }) - .from(users); + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); return response(res, { data: { diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts index 6ec6784c..9a84852a 100644 --- a/server/setup/setupServerAdmin.ts +++ b/server/setup/setupServerAdmin.ts @@ -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, diff --git a/server/types/UserTypes.ts b/server/types/UserTypes.ts new file mode 100644 index 00000000..954d84f9 --- /dev/null +++ b/server/types/UserTypes.ts @@ -0,0 +1,4 @@ +export enum UserType { + Internal = "internal", + OIDC = "oidc" +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 438ebbe4..5f91fb62 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -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>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( - + {overview && (
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(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>({ - 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 - >(`/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) { - setLoading(true); - - const res = await api - .post>( - `/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 ( - <> - { - setOpen(val); - if (!val) { - setInviteLink(null); - setLoading(false); - setExpiresInDays(1); - form.reset(); - } - }} - > - - - Invite User - - Give new users access to your organization - - - -
- {!inviteLink && ( -
- - ( - - Email - - - - - - )} - /> - - {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) - } - /> - -
- )} - - ( - - Role - - - - )} - /> - ( - - - Valid For - - - - - )} - /> - - - )} - - {inviteLink && ( -
- {sendEmail && ( -

- An email has been sent to the user - with the access link below. They - must access the link to accept the - invitation. -

- )} - {!sendEmail && ( -

- The user has been invited. They must - access the link below to accept the - invitation. -

- )} -

- The invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === 1 - ? "day" - : "days"} - - . -

- -
- )} -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx index 1ce169e0..643d8641 100644 --- a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx @@ -24,7 +24,7 @@ export function UsersDataTable({ searchPlaceholder="Search users..." searchColumn="email" onAdd={inviteUser} - addButtonText="Invite User" + addButtonText="Create User" /> ); } diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 29529d66..8036cc84 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -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(null); - const [users, setUsers] = useState(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 - {userRow.email !== user?.email && ( + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${userRow.idpId}` && ( { setIsDeleteModalOpen( @@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { } }, { - accessorKey: "email", + accessorKey: "displayUsername", header: ({ column }) => { return ( ); } }, { - accessorKey: "status", + accessorKey: "idpName", header: ({ column }) => { return ( ); @@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {

Are you sure you want to remove{" "} - {selectedUser?.email} from the organization? + + {selectedUser?.email || + selectedUser?.name || + selectedUser?.username} + {" "} + from the organization?

@@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {

- To confirm, please type the email address of the - user below. + To confirm, please type the name of the of the user + below.

} buttonText="Confirm Remove User" onConfirm={removeUser} - string={selectedUser?.email ?? ""} + string={ + selectedUser?.email || + selectedUser?.name || + selectedUser?.username || + "" + } title="Remove User from Organization" /> - - { - setIsInviteModalOpen(true); + router.push(`/${org?.org.orgId}/settings/access/users/create`); }} /> diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx new file mode 100644 index 00000000..c270b350 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -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("internal"); + const [inviteLink, setInviteLink] = useState(null); + const [loading, setLoading] = useState(false); + const [expiresInDays, setExpiresInDays] = useState(1); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [idps, setIdps] = useState([]); + const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); + const [selectedIdp, setSelectedIdp] = useState(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>({ + resolver: zodResolver(internalFormSchema), + defaultValues: { + email: "", + validForHours: "72", + roleId: "" + } + }); + + const externalForm = useForm>({ + 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>(`/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>("/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 + ) { + setLoading(true); + + const res = await api + .post>( + `/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 + ) { + 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 = [ + { + 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 ( + <> +
+ + +
+ +
+ + + + + User Type + + + Determine how you want to create the user + + + + { + setUserType(value as UserType); + if (value === "internal") { + internalForm.reset(); + } else if (value === "oidc") { + externalForm.reset(); + setSelectedIdp(null); + } + }} + cols={2} + /> + + + + {userType === "internal" && dataLoaded && ( + <> + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Email + + + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
+ )} + + ( + + + Valid For + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + {inviteLink && ( +
+ {sendEmail && ( +

+ An email has + been sent to the + user with the + access link + below. They must + access the link + to accept the + invitation. +

+ )} + {!sendEmail && ( +

+ The user has + been invited. + They must access + the link below + to accept the + invitation. +

+ )} +

+ The invite will + expire in{" "} + + {expiresInDays}{" "} + {expiresInDays === + 1 + ? "day" + : "days"} + + . +

+ +
+ )} + + +
+
+
+ + )} + + {userType !== "internal" && dataLoaded && ( + <> + + + + Identity Provider + + + Select the identity provider for the + external user + + + + {idps.length === 0 ? ( +

+ No identity providers are + configured. Please configure an + identity provider before creating + external users. +

+ ) : ( +
+ ( + + ({ + 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} + /> + + + )} + /> + + )} +
+
+ + {idps.length > 0 && ( + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Username + + + + +

+ This must + match the + unique + username + that exists + in the + selected + identity + provider. +

+ +
+ )} + /> + + ( + + + Email + (Optional) + + + + + + + )} + /> + + ( + + + Name + (Optional) + + + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + +
+
+
+ )} + + )} +
+ +
+ + {userType && dataLoaded && ( + + )} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 8049ff96..f82cfdb0 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -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 diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 959a32a6..49f3d834 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -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>(`/orgs`); + const res = await api.get>( + `/user/${user.userId}/orgs` + ); if (res.status === 200) { if (res.data.data.orgs.length > 0) { @@ -237,9 +241,7 @@ export default function GeneralPage() { - - Danger Zone - + Danger Zone Once you delete this org, there is no going back. Please be certain. diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 206f52f9..ac5e552b 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -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>(`/orgs`, cookie) + internal.get>( + `/user/${user.userId}/orgs`, + cookie + ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index fa83a761..2428472e 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -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(); + const [selectedResource, setSelectedResource] = useState(); const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) @@ -282,11 +278,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { return ( <> - - {selectedResource && ( { - setIsCreateModalOpen(true); + router.push(`/${orgId}/settings/resources/create`); }} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx index e4bdd1b4..3bf2966a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx @@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({ /> - - Users will be able to access - this resource by entering this - password. It must be at least 4 - characters long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx index 58a997bf..31ccbea6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx @@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
- - Users will be able to access - this resource by entering this - PIN code. It must be at least 6 - digits long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 8f8e584c..0b0535e8 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -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() { - {env.email.emailEnabled && ( - - - - One-time Passwords - - - Require email-based authentication for resource - access - - - - + + + + One-time Passwords + + + Require email-based authentication for resource + access + + + + {!env.email.emailEnabled && ( + + + + SMTP Required + + + SMTP must be enabled on the server to use one-time password authentication. + + + )} + - {whitelistEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - 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} - /> - - - Press enter to add an - email after typing it in - the input field. - - - )} - /> - - - )} -
- - - -
- )} + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + 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} + /> + + + Press enter to add an + email after typing it in + the input field. + + + )} + /> + + + )} +
+ + + +
); diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/create/page.tsx similarity index 57% rename from src/app/[orgId]/settings/resources/CreateResourceForm.tsx rename to src/app/[orgId]/settings/resources/create/page.tsx index 9df51e92..d500a809 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -1,6 +1,14 @@ "use client"; -import { Button, buttonVariants } from "@app/components/ui/button"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; import { Form, FormControl, @@ -10,48 +18,22 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; +import { useEffect, useState } from "react"; +import { Controller, 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 { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; -import { CheckIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import CustomDomainInput from "./[resourceId]/CustomDomainInput"; -import { AxiosResponse } from "axios"; -import { Resource } from "@server/db/schemas"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { cn } from "@app/lib/cn"; -import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { Resource } from "@server/db/schemas"; +import { StrategySelect } from "@app/components/StrategySelect"; import { Select, SelectContent, @@ -60,222 +42,74 @@ import { SelectValue } from "@app/components/ui/select"; import { subdomainSchema } from "@server/lib/schemas"; -import Link from "next/link"; -import { SquareArrowOutUpRight } from "lucide-react"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { Label } from "@app/components/ui/label"; import { ListDomainsResponse } from "@server/routers/domain"; import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { cn } from "@app/lib/cn"; -const createResourceFormSchema = z - .object({ - subdomain: z.string().optional(), - domainId: z.string().min(1).optional(), - name: z.string().min(1).max(255), - siteId: z.number(), - http: z.boolean(), - protocol: z.string(), - proxyPort: z.number().optional(), - isBaseDomain: z.boolean().optional() +const baseResourceFormSchema = z.object({ + name: z.string().min(1).max(255), + siteId: z.number(), + http: z.boolean() +}); + +const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [ + z.object({ + isBaseDomain: z.literal(true), + domainId: z.string().min(1) + }), + z.object({ + isBaseDomain: z.literal(false), + domainId: z.string().min(1), + subdomain: z.string().pipe(subdomainSchema) }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: "Invalid port number", - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: "Invalid subdomain", - path: ["subdomain"] - } - ); +]); -type CreateResourceFormValues = z.infer; +const tcpUdpResourceFormSchema = z.object({ + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535) +}); -type CreateResourceFormProps = { - open: boolean; - setOpen: (open: boolean) => void; -}; +type BaseResourceFormValues = z.infer; +type HttpResourceFormValues = z.infer; +type TcpUdpResourceFormValues = z.infer; -export default function CreateResourceForm({ - open, - setOpen -}: CreateResourceFormProps) { - const [formKey, setFormKey] = useState(0); - const api = createApiClient(useEnvContext()); +type ResourceType = "http" | "raw"; - const [loading, setLoading] = useState(false); - const params = useParams(); +interface ResourceTypeOption { + id: ResourceType; + title: string; + description: string; + disabled?: boolean; +} - const orgId = params.orgId; +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); const router = useRouter(); - const { org } = useOrgContext(); - const { env } = useEnvContext(); - + const [loadingPage, setLoadingPage] = useState(true); const [sites, setSites] = useState([]); const [baseDomains, setBaseDomains] = useState< { domainId: string; baseDomain: string }[] >([]); - const [showSnippets, setShowSnippets] = useState(false); - const [resourceId, setResourceId] = useState(null); - const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( - "subdomain" - ); - const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); - const form = useForm({ - resolver: zodResolver(createResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - name: "", - http: true, - protocol: "tcp" - } - }); - - function reset() { - form.reset(); - setSites([]); - setShowSnippets(false); - setResourceId(null); - } - - useEffect(() => { - if (!open) { - return; - } - - reset(); - - const fetchSites = async () => { - const res = await api - .get>(`/org/${orgId}/sites/`) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error fetching sites", - description: formatAxiosError( - e, - "An error occurred when fetching the sites" - ) - }); - }); - - if (res?.status === 200) { - setSites(res.data.data.sites); - - if (res.data.data.sites.length > 0) { - form.setValue("siteId", res.data.data.sites[0].siteId); - } - } - }; - - const fetchDomains = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${orgId}/domains/`) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error fetching domains", - description: formatAxiosError( - e, - "An error occurred when fetching the domains" - ) - }); - }); - - if (res?.status === 200) { - const domains = res.data.data.domains; - setBaseDomains(domains); - if (domains.length) { - form.setValue("domainId", domains[0].domainId); - setFormKey((k) => k + 1); - } - } - }; - - const load = async () => { - setLoadingPage(true); - - await fetchSites(); - await fetchDomains(); - await new Promise((r) => setTimeout(r, 200)); - - setLoadingPage(false); - }; - - load(); - }, [open]); - - async function onSubmit(data: CreateResourceFormValues) { - const res = await api - .put>( - `/org/${orgId}/site/${data.siteId}/resource/`, - { - name: data.name, - subdomain: data.http ? data.subdomain : undefined, - domainId: data.http ? data.domainId : undefined, - http: data.http, - protocol: data.protocol, - proxyPort: data.http ? undefined : data.proxyPort, - siteId: data.siteId, - isBaseDomain: data.http ? data.isBaseDomain : undefined - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error creating resource", - description: formatAxiosError( - e, - "An error occurred when creating the resource" - ) - }); - }); - - if (res && res.status === 201) { - const id = res.data.data.resourceId; - setResourceId(id); - - if (data.http) { - goToResource(id); - } else { - setShowSnippets(true); - router.refresh(); - } - } - } - - function goToResource(id?: number) { - // navigate to the resource page - router.push(`/${orgId}/settings/resources/${id || resourceId}`); - } - - const launchOptions = [ + const resourceTypes: ReadonlyArray = [ { id: "http", title: "HTTPS Resource", @@ -286,45 +120,203 @@ export default function CreateResourceForm({ id: "raw", title: "Raw TCP/UDP Resource", description: - "Proxy requests to your app over TCP/UDP using a port number." + "Proxy requests to your app over TCP/UDP using a port number.", + disabled: !env.flags.allowRawResources } ]; + const baseForm = useForm({ + resolver: zodResolver(baseResourceFormSchema), + defaultValues: { + name: "", + http: true + } + }); + + const httpForm = useForm({ + resolver: zodResolver(httpResourceFormSchema), + defaultValues: { + subdomain: "", + domainId: "", + isBaseDomain: false + } + }); + + const tcpUdpForm = useForm({ + resolver: zodResolver(tcpUdpResourceFormSchema), + defaultValues: { + protocol: "tcp", + proxyPort: undefined + } + }); + + async function onSubmit() { + setCreateLoading(true); + + const baseData = baseForm.getValues(); + const isHttp = baseData.http; + + try { + const payload = { + name: baseData.name, + siteId: baseData.siteId, + http: baseData.http + }; + + if (isHttp) { + const httpData = httpForm.getValues(); + if (httpData.isBaseDomain) { + Object.assign(payload, { + domainId: httpData.domainId, + isBaseDomain: true + }); + } else { + Object.assign(payload, { + subdomain: httpData.subdomain, + domainId: httpData.domainId, + isBaseDomain: false + }); + } + } else { + const tcpUdpData = tcpUdpForm.getValues(); + Object.assign(payload, { + protocol: tcpUdpData.protocol, + proxyPort: tcpUdpData.proxyPort + }); + } + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating resource", + description: formatAxiosError( + e, + "An error occurred when creating the resource" + ) + }); + }); + + if (res && res.status === 201) { + const id = res.data.data.resourceId; + router.push(`/${orgId}/settings/resources/${id}`); + } + } catch (e) { + console.error("Error creating resource:", e); + toast({ + variant: "destructive", + title: "Error creating resource", + description: "An unexpected error occurred" + }); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + const fetchSites = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/sites/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching sites", + description: formatAxiosError( + e, + "An error occurred when fetching the sites" + ) + }); + }); + + if (res?.status === 200) { + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + baseForm.setValue( + "siteId", + res.data.data.sites[0].siteId + ); + } + } + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + if (domains.length) { + httpForm.setValue("domainId", domains[0].domainId); + } + } + }; + + await fetchSites(); + await fetchDomains(); + + setLoadingPage(false); + }; + + load(); + }, []); + return ( <> - { - setOpen(val); - setLoading(false); +
+ + +
- // reset all values - form.reset(); - }} - > - - - Create Resource - - Create a new resource to proxy requests to your app - - - - {loadingPage ? ( - - ) : ( -
- {!showSnippets && ( -
+ {!loadingPage && ( +
+ + + + + Resource Information + + + + + ( @@ -335,12 +327,17 @@ export default function CreateResourceForm({ + + This is the display + name for the + resource. + )} /> ( @@ -395,7 +392,7 @@ export default function CreateResourceForm({ site.siteId } onSelect={() => { - form.setValue( + baseForm.setValue( "siteId", site.siteId ); @@ -430,35 +427,61 @@ export default function CreateResourceForm({ )} /> + + + + + - {!env.flags.allowRawResources || ( -
- - Resource Type - - - form.setValue( - "http", - value === "http" - ) - } - /> - - You cannot change the - type of resource after - creation. - -
- )} + + + + Resource Type + + + Determine how you want to access your + resource + + + + { + baseForm.setValue( + "http", + value === "http" + ); + }} + cols={2} + /> + + - {form.watch("http") && - env.flags + {baseForm.watch("http") ? ( + + + + HTTPS Settings + + + Configure how your resource will be + accessed over HTTPS + + + + +
+ + {env.flags .allowBaseDomainResources && ( ( @@ -467,20 +490,15 @@ export default function CreateResourceForm({ + + + + )} + /> +
+
+ ( + + + + + - - - )} - /> -
-
- ( - - - - - )} - /> -
+ + {baseDomains.map( + ( + option + ) => ( + + . + { + option.baseDomain + } + + ) + )} + + + + + )} + />
- ) : ( - ( - - - Base - Domain - - - - - )} - /> - )} - - )} + + The subdomain where + your resource will + be accessible. + + + )} - {!form.watch("http") && ( - <> + {httpForm.watch( + "isBaseDomain" + ) && ( ( - Protocol + Base Domain )} /> - ( - - - Port Number - + )} + + + + + + ) : ( + + + + TCP/UDP Settings + + + Configure how your resource will be + accessed over TCP/UDP + + + + +
+ + ( + + + Protocol + + - field.onChange( - e - .target - .value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> + + + - - - The external - port number - to proxy - requests. - - - )} - /> - - )} - - - )} - - {showSnippets && ( -
-
-
-

- Traefik: Add Entrypoints -

- + + TCP + + + UDP + + + + + + )} /> -
-
-
-
-

- Gerbil: Expose Ports in - Docker Compose -

- ( + + + Port Number + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + The external + port number to + proxy requests. + + + )} /> -
-
+ + + + + + )} + - - - Learn how to configure TCP/UDP - resources - - - -
- )} - - )} -
- - - - - {!showSnippets && ( - - )} +
+ + - )} - - - + if (baseValid && settingsValid) { + onSubmit(); + } + }} + loading={createLoading} + > + Create Resource + +
+ + )} ); } diff --git a/src/app/admin/idp/AdminIdpDataTable.tsx b/src/app/admin/idp/AdminIdpDataTable.tsx new file mode 100644 index 00000000..8d64ce0b --- /dev/null +++ b/src/app/admin/idp/AdminIdpDataTable.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; +} + +export function IdpDataTable({ + columns, + data +}: DataTableProps) { + const router = useRouter(); + + return ( + { + router.push("/admin/idp/create"); + }} + /> + ); +} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx new file mode 100644 index 00000000..b2415280 --- /dev/null +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -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(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[] = [ + { + id: "dots", + cell: ({ row }) => { + const r = row.original; + + return ( + + + + + + + + View settings + + + { + setSelectedIdp(r); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "idpId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return ( + {getTypeDisplay(type)} + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const siteRow = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selectedIdp && ( + { + setIsDeleteModalOpen(val); + setSelectedIdp(null); + }} + dialog={ +
+

+ Are you sure you want to permanently delete the + identity provider {selectedIdp.name}? +

+

+ + This will remove the identity provider and + all associated configurations. Users who + authenticate through this provider will no + longer be able to log in. + +

+

+ To confirm, please type the name of the identity + provider below. +

+
+ } + buttonText="Confirm Delete Identity Provider" + onConfirm={async () => deleteIdp(selectedIdp.idpId)} + string={selectedIdp.name} + title="Delete Identity Provider" + /> + )} + + + + ); +} diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx new file mode 100644 index 00000000..7e23db1b --- /dev/null +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -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; + +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({ + 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 ( + <> + + + + + General Information + + + Configure the basic information for your identity + provider + + + + + + + Redirect URL + + + + + + + + + + + About Redirect URL + + + This is the URL to which users will be + redirected after authentication. You need to + configure this URL in your identity provider + settings. + + + +
+ + ( + + Name + + + + + A display name for this + identity provider + + + + )} + /> + +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + Enterprise + +
+ + When enabled, users will be + automatically created in the system upon + first login with the ability to map + users to roles and organizations. + + + +
+
+
+ + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints and + credentials + + + + +
+ + ( + + + Client ID + + + + + + The OAuth2 client ID + from your identity + provider + + + + )} + /> + + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity + provider + + + + )} + /> + + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> + + ( + + + Token URL + + + + + + The OAuth2 token + endpoint URL + + + + )} + /> + + +
+
+
+ + + + + Token Configuration + + + Configure how to extract user information from + the ID token + + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath + syntax to extract values from + the ID token. + + Learn more about JMESPath{" "} + + + + + + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID + token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the + user's email in the ID + token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the + user's name in the ID + token + + + + )} + /> + + ( + + + Scopes + + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+
+
+
+ +
+ +
+ + ); +} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx new file mode 100644 index 00000000..193cbe4e --- /dev/null +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -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>( + `/idp/${params.idpId}`, + await authCookieHeader() + ); + idp = res.data.data; + } catch { + redirect("/admin/idp"); + } + + const navItems = [ + { + title: "General", + href: `/admin/idp/${params.idpId}/general` + } + ]; + + return ( + <> + + +
+ {children} +
+ + ); +} diff --git a/src/app/admin/idp/[idpId]/page.tsx b/src/app/admin/idp/[idpId]/page.tsx new file mode 100644 index 00000000..a8701e74 --- /dev/null +++ b/src/app/admin/idp/[idpId]/page.tsx @@ -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`); +} diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx new file mode 100644 index 00000000..45b30ac1 --- /dev/null +++ b/src/app/admin/idp/create/page.tsx @@ -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; + +interface ProviderTypeOption { + id: "oidc"; + title: string; + description: string; +} + +const providerTypes: ReadonlyArray = [ + { + 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({ + 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 ( + <> +
+ + +
+ + + + + + General Information + + + Configure the basic information for your identity + provider + + + + +
+ + ( + + Name + + + + + A display name for this + identity provider + + + + )} + /> + +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + Enterprise + +
+ + When enabled, users will be + automatically created in the system upon + first login with the ability to map + users to roles and organizations. + + + +
+
+
+ + + + + Provider Type + + + Select the type of identity provider you want to + configure + + + + { + form.setValue("type", value as "oidc"); + }} + cols={3} + /> + + + + {form.watch("type") === "oidc" && ( + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints + and credentials + + + +
+ + ( + + + Client ID + + + + + + The OAuth2 client ID + from your identity + provider + + + + )} + /> + + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity + provider + + + + )} + /> + + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> + + ( + + + Token URL + + + + + + The OAuth2 token + endpoint URL + + + + )} + /> + + + + + + + Important Information + + + 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. + + +
+
+ + + + + Token Configuration + + + Configure how to extract user information + from the ID token + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath + syntax to extract values from + the ID token. + + Learn more about JMESPath{" "} + + + + + + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID + token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the + user's email in the ID + token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the + user's name in the ID + token + + + + )} + /> + + ( + + + Scopes + + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+
+ )} +
+ +
+ + +
+ + ); +} diff --git a/src/app/admin/idp/page.tsx b/src/app/admin/idp/page.tsx new file mode 100644 index 00000000..54657c2d --- /dev/null +++ b/src/app/admin/idp/page.tsx @@ -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>( + `/idp`, + await authCookieHeader() + ); + idps = res.data.data.idps; + } catch (e) { + console.error(e); + } + + return ( + <> + + + + ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index ddef0a05..fdc6c8e7 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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>(`/orgs`, cookie) + internal.get>( + `/user/${user.userId}/orgs`, + cookie + ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index 0ead375d..12a6145c 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -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 ( + + ); + } + }, { accessorKey: "email", header: ({ column }) => { @@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) { ); } }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + header: ({ column }) => { + return ( + + ); + } + }, { id: "actions", cell: ({ row }) => { @@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {

Are you sure you want to permanently delete{" "} - {selected?.email || selected?.id} from - the server? + + {selected?.email || + selected?.name || + selected?.username} + {" "} + from the server?

@@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {

- To confirm, please type the email of the user + To confirm, please type the name of the user below.

} buttonText="Confirm Delete User" onConfirm={async () => deleteUser(selected!.id)} - string={selected.email} + string={ + selected.email || selected.name || selected.username + } title="Delete User from Server" /> )} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index a8ab19a2..6e2290cb 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -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" /> + + + About User Management + + 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. + + ); diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx new file mode 100644 index 00000000..c946869b --- /dev/null +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -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(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 + >(`/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 ( +
+ + + Connecting to {props.idp.name} + Validating your identity + + + {loading && ( +
+ + Connecting... +
+ )} + {!loading && !error && ( +
+ + Connected +
+ )} + {error && ( + + + + + There was a problem connecting to{" "} + {props.idp.name}. Please contact your + administrator. + + {error} + + + )} +
+
+
+ ); +} diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx new file mode 100644 index 00000000..cba74790 --- /dev/null +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -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
IdP not found
; + } + + return ( + <> + + + ); +} diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/app/auth/login/DashboardLoginForm.tsx index 715a0fb9..b15dd518 100644 --- a/src/app/auth/login/DashboardLoginForm.tsx +++ b/src/app/auth/login/DashboardLoginForm.tsx @@ -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({

Welcome to Pangolin

-

Log in to get started

+

+ Log in to get started +

{ if (redirect) { const safe = cleanRedirect(redirect); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e10c18ce..8227c1a0 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -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 && ( -
+

@@ -48,7 +57,7 @@ export default async function Page(props: {

)} - + {(!signUpDisabled || isInvite) && (

diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 2480cd67..c7eca2c7 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -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 /> @@ -490,7 +497,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 006faa45..af31de98 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -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} />

)} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 69e023da..7f2205b4 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -50,7 +50,7 @@ export default async function Page(props: { return ( <> {isInvite && ( -
+

diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 033fa75d..10ad809f 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -36,7 +36,7 @@ export default async function Page(props: { return ( <> diff --git a/src/app/globals.css b/src/app/globals.css index 7afe6079..e2a6e31a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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%); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61bbb601..1d8deaed 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 9075f155..ea33b294 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -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: + } +]; + export const rootNavItems: SidebarNavItem[] = [ { title: "Home", - href: "/" - // icon: + href: "/", + icon: } ]; export const orgNavItems: SidebarNavItem[] = [ { title: "Sites", - href: "/{orgId}/settings/sites" - // icon: + href: "/{orgId}/settings/sites", + icon: }, { title: "Resources", - href: "/{orgId}/settings/resources" - // icon: + href: "/{orgId}/settings/resources", + icon: }, { title: "Access Control", href: "/{orgId}/settings/access", - // icon: , + icon: , autoExpand: true, children: [ { @@ -51,20 +61,37 @@ export const orgNavItems: SidebarNavItem[] = [ }, { title: "Shareable Links", - href: "/{orgId}/settings/share-links" - // icon: + href: "/{orgId}/settings/share-links", + icon: + }, + { + title: "API Keys", + href: "/{orgId}/settings/api-keys", + icon: , + showEnterprise: true }, { title: "Settings", - href: "/{orgId}/settings/general" - // icon: + href: "/{orgId}/settings/general", + icon: } ]; export const adminNavItems: SidebarNavItem[] = [ { title: "All Users", - href: "/admin/users" - // icon: + href: "/admin/users", + icon: + }, + { + title: "API Keys", + href: "/{orgId}/settings/api-keys", + icon: , + showEnterprise: true + }, + { + title: "Identity Providers", + href: "/admin/idp", + icon: } ]; diff --git a/src/app/page.tsx b/src/app/page.tsx index 3535c65f..6cab7cbd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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>( - `/orgs`, + const res = await internal.get>( + `/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 ( - +
({ name: org.name, id: org.orgId diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 1e416d26..5420748c 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -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: { diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 9db2db2a..3f170fbe 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -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 ( -
- -
+ ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 943f65ed..00000000 --- a/src/components/Header.tsx +++ /dev/null @@ -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 ( -
- - Pangolin Logo - Pangolin - -
- ); -} - -export default Header; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c54aca28..1c697ce4 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 ( -
- {/* Mobile Menu Button */} - {showSidebar && ( -
- - - - - - - Navigation Menu - - - Main navigation menu for the application - - {showHeader && ( -
-
+
+ {/* Full width header */} + {showHeader && ( +
+
+
+ {showSidebar && ( +
+ + + + + + + Navigation Menu + + + Main navigation menu for the + application + +
+
+ + setIsMobileMenuOpen( + false + ) + } + /> +
+ {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + Server Admin + +
+ )} +
+
+ + + {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
)} -
- setIsMobileMenuOpen(false)} /> + + Pangolin Logo + + {showBreadcrumbs && ( +
+ +
+ )} +
+ {showTopBar && ( +
+
+ + Documentation + + + Support + +
+
+ +
-
- - + )} +
+ {showBreadcrumbs && ( +
+ +
+ )} +
+ )} + +
+ {/* Desktop Sidebar */} + {showSidebar && ( +
+
+
+ +
+ {!isAdminPage && user.serverAdmin && ( +
+ + + Server Admin + +
+ )} +
+
+ + +
+
+ + Open Source + + +
{env?.app?.version && (
v{env.app.version}
)}
- - -
- )} - - {/* Desktop Sidebar */} - {showSidebar && ( -
- {showHeader && ( -
-
+
+ )} + + {/* Main content */} +
- -
-
- - -
-
- - Open Source - - -
- {env?.app?.version && ( -
- v{env.app.version} -
- )} + > +
+
+ {children}
-
+
- )} - - {/* Main content */} -
- {showTopBar && ( -
-
- -
-
- )} - {showBreadcrumbs && } -
-
- {children} -
-
); diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 3be11528..9a60a217 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -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; + 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(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>( + `/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 (
{!mfaRequested && ( -
- - ( - - Email - - - - - - )} - /> - -
+ <> + + ( - Password + Email - + )} /> -
- - Forgot your password? - +
+ ( + + Password + + + + + + )} + /> + +
+ + Forgot your password? + +
-
- - + + + )} {mfaRequested && ( @@ -193,7 +225,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { Two-Factor Authentication

- 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.

@@ -268,16 +301,47 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { )} {!mfaRequested && ( - + <> + + + {hasIdp && ( + <> +
+
+ +
+
+ + Or continue with + +
+
+ + {idps.map((idp) => ( + + ))} + + )} + )} {mfaRequested && ( diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index c6a66725..626156cf 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -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) { ); -} \ No newline at end of file +} diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 4b20afee..20139dea 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -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() { -
+
- {user.email} + {user.email || user.name || user.username} @@ -92,13 +94,17 @@ export default function ProfileIcon() { Signed in as

- {user.email} + {user.email || user.name || user.username}

- {user.serverAdmin && ( + {user.serverAdmin ? (

Server Admin

+ ) : ( +

+ {user.idpName || "Internal"} +

)} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index c540c748..f2d828e5 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,31 +1,69 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
; } export function SettingsSection({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
; } -export function SettingsSectionHeader({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionHeader({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionForm({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionForm({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionTitle({ children }: { children: React.ReactNode }) { - return

{children}

+export function SettingsSectionTitle({ + children +}: { + children: React.ReactNode; +}) { + return ( +

+ {children} +

+ ); } -export function SettingsSectionDescription({ children }: { children: React.ReactNode }) { - return

{children}

+export function SettingsSectionDescription({ + children +}: { + children: React.ReactNode; +}) { + return

{children}

; } -export function SettingsSectionBody({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionBody({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionFooter({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionFooter({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function SettingsSectionGrid({ + children, + cols +}: { + children: React.ReactNode; + cols: number; +}) { + return
{children}
; } diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 39c9ac07..31dd2ce2 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -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 { @@ -35,25 +38,7 @@ export function SidebarNav({ const userId = params.userId as string; const [expandedItems, setExpandedItems] = useState>(new Set()); - // Initialize expanded items based on autoExpand property - useEffect(() => { - const autoExpanded = new Set(); - - 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(); + + 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 (
-
- +
{ - if (disabled) { - e.preventDefault(); - } else if (onItemClick) { - onItemClick(); - } - }} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} > - {item.icon && {item.icon}} - {item.title} - - {hasChildren && ( - + )} +
{hasChildren && isExpanded && (
@@ -135,7 +172,7 @@ export function SidebarNav({ return (
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx deleted file mode 100644 index 2862da43..00000000 --- a/src/components/TopBar.tsx +++ /dev/null @@ -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 ( -
-
- - Documentation - - - Support - -
-
- -
-
- ); -} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 48e36db4..33dc0438 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< + + Show info + + ); + return (
- {text} + {text && {text}} - + {trigger ?? defaultTrigger}

{info}

diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx index f13ce8a6..57cfe388 100644 --- a/src/components/ui/input-otp.tsx +++ b/src/components/ui/input-otp.tsx @@ -8,8 +8,8 @@ import { cn } from "@app/lib/cn" const InputOTP = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, containerClassName, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { obscured?: boolean } +>(({ className, containerClassName, obscured = false, ...props }, ref) => ( , - 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 && (
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 625d181d..a0e91293 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -10,7 +10,7 @@ const Textarea = React.forwardRef( return (