Merge branch 'auth-providers' into dev

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

292
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

40
server/lib/crypto.ts Normal file
View file

@ -0,0 +1,40 @@
import * as crypto from "crypto";
const ALGORITHM = "aes-256-gcm";
export function encrypt(value: string, key: string): string {
const iv = crypto.randomBytes(12);
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
const encrypted = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final()
]);
const authTag = cipher.getAuthTag();
return [
iv.toString("base64"),
encrypted.toString("base64"),
authTag.toString("base64")
].join(":");
}
export function decrypt(encryptedValue: string, key: string): string {
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
const iv = Buffer.from(ivB64, "base64");
const encrypted = Buffer.from(encryptedB64, "base64");
const authTag = Buffer.from(authTagB64, "base64");
const keyBuffer = Buffer.from(key, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString("utf8");
}

View file

@ -0,0 +1,8 @@
import config from "@server/lib/config";
export function generateOidcRedirectUrl(idpId: number) {
const dashboardUrl = config.getRawConfig().app.dashboard_url;
const redirectPath = `/auth/idp/${idpId}/oidc/callback`;
const redirectUrl = new URL(redirectPath, dashboardUrl).toString();
return redirectUrl;
}

View file

@ -15,3 +15,4 @@ export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole"; export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess"; export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin"; export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";

View file

@ -0,0 +1,44 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyIsLoggedInUser(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const reqUserId =
req.params.userId || req.body.userId || req.query.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
// allow server admins to access any user
if (req.user?.serverAdmin) {
return next();
}
if (reqUserId !== userId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User only has access to their own account"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error checking if user has access to this user"
)
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,132 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z.object({}).strict();
const bodySchema = z
.object({
name: z.string().nonempty(),
clientId: z.string().nonempty(),
clientSecret: z.string().nonempty(),
authUrl: z.string().url(),
tokenUrl: z.string().url(),
identifierPath: z.string().nonempty(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional()
})
.strict();
export type CreateIdpResponse = {
idpId: number;
redirectUrl: string;
};
registry.registerPath({
method: "put",
path: "/idp/oidc",
description: "Create an OIDC IdP.",
tags: [OpenAPITags.Idp],
request: {
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createOidcIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision
} = parsedBody.data;
const key = config.getRawConfig().server.secret;
const encryptedSecret = encrypt(clientSecret, key);
const encryptedClientId = encrypt(clientId, key);
let idpId: number | undefined;
await db.transaction(async (trx) => {
const [idpRes] = await trx
.insert(idp)
.values({
name,
autoProvision,
type: "oidc"
})
.returning();
idpId = idpRes.idpId;
await trx.insert(idpOidcConfig).values({
idpId: idpRes.idpId,
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath
});
});
const redirectUrl = generateOidcRedirectUrl(idpId as number);
return response<CreateIdpResponse>(res, {
data: {
idpId: idpId as number,
redirectUrl
},
success: true,
error: false,
message: "Idp created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,89 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
registry.registerPath({
method: "delete",
path: "/idp/{idpId}",
description: "Delete IDP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
// Check if IDP exists
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"IdP not found"
)
);
}
// Delete the IDP and its related records in a transaction
await db.transaction(async (trx) => {
// Delete OIDC config if it exists
await trx
.delete(idpOidcConfig)
.where(eq(idpOidcConfig.idpId, idpId));
// Delete the IDP itself
await trx
.delete(idp)
.where(eq(idp.idpId, idpId));
});
return response<null>(res, {
data: null,
success: true,
error: false,
message: "IdP deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,148 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import cookie from "cookie";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
redirectUrl: z.string()
})
.strict();
export type GenerateOidcUrlResponse = {
redirectUrl: string;
};
export async function generateOidcUrl(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { redirectUrl: postAuthRedirectUrl } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP not found for the organization"
)
);
}
const parsedScopes = existingIdp.idpOidcConfig.scopes
.split(" ")
.map((scope) => {
return scope.trim();
})
.filter((scope) => {
return scope.length > 0;
});
const key = config.getRawConfig().server.secret;
const decryptedClientId = decrypt(
existingIdp.idpOidcConfig.clientId,
key
);
const decryptedClientSecret = decrypt(
existingIdp.idpOidcConfig.clientSecret,
key
);
const redirectUrl = generateOidcRedirectUrl(idpId);
const client = new arctic.OAuth2Client(
decryptedClientId,
decryptedClientSecret,
redirectUrl
);
const codeVerifier = arctic.generateCodeVerifier();
const state = arctic.generateState();
const url = client.createAuthorizationURLWithPKCE(
existingIdp.idpOidcConfig.authUrl,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
parsedScopes
);
const stateJwt = jsonwebtoken.sign(
{
redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe
state,
codeVerifier
},
config.getRawConfig().server.secret
);
res.cookie("p_oidc_state", stateJwt, {
path: "/",
httpOnly: true,
secure: req.protocol === "https",
expires: new Date(Date.now() + 60 * 10 * 1000),
sameSite: "lax"
});
return response<GenerateOidcUrlResponse>(res, {
data: {
redirectUrl: url.toString()
},
success: true,
error: false,
message: "Idp auth url generated",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,97 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
async function query(idpId: number) {
const [res] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.limit(1);
return res;
}
export type GetIdpResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/idp/{idpId}",
description: "Get an IDP by its IDP ID.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema
},
responses: {}
});
export async function getIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const idpRes = await query(idpId);
if (!idpRes) {
return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found"));
}
const key = config.getRawConfig().server.secret;
if (idpRes.idp.type === "oidc") {
const clientSecret = idpRes.idpOidcConfig!.clientSecret;
const clientId = idpRes.idpOidcConfig!.clientId;
idpRes.idpOidcConfig!.clientSecret = decrypt(
clientSecret,
key
);
idpRes.idpOidcConfig!.clientId = decrypt(
clientId,
key
);
}
return response<GetIdpResponse>(res, {
data: idpRes,
success: true,
error: false,
message: "Idp retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,7 @@
export * from "./createOidcIdp";
export * from "./updateOidcIdp";
export * from "./deleteIdp";
export * from "./listIdps";
export * from "./generateOidcUrl";
export * from "./validateOidcCallback";
export * from "./getIdp";

View file

@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { idp } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const querySchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
async function query(limit: number, offset: number) {
const res = await db
.select({
idpId: idp.idpId,
name: idp.name,
type: idp.type,
})
.from(idp)
.groupBy(idp.idpId)
.limit(limit)
.offset(offset);
return res;
}
export type ListIdpsResponse = {
idps: Array<{
idpId: number;
name: string;
type: string;
}>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
registry.registerPath({
method: "get",
path: "/idp",
description: "List all IDP in the system.",
tags: [OpenAPITags.Idp],
request: {
query: querySchema
},
responses: {}
});
export async function listIdps(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const list = await query(limit, offset);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(idp);
return response<ListIdpsResponse>(res, {
data: {
idps: list,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Idps retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,184 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { idp, idpOidcConfig } from "@server/db/schemas";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z
.object({
name: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
authUrl: z.string().optional(),
tokenUrl: z.string().optional(),
identifierPath: z.string().optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().optional(),
autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
})
.strict();
export type UpdateIdpResponse = {
idpId: number;
};
registry.registerPath({
method: "post",
path: "/idp/:idpId/oidc",
description: "Update an OIDC IdP.",
tags: [OpenAPITags.Idp],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOidcIdp(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const {
clientId,
clientSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath,
name,
autoProvision,
defaultRoleMapping,
defaultOrgMapping
} = parsedBody.data;
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()
.from(idp)
.where(eq(idp.idpId, idpId));
if (!existingIdp) {
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
}
if (existingIdp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP is not an OIDC provider"
)
);
}
const key = config.getRawConfig().server.secret;
const encryptedSecret = clientSecret
? encrypt(clientSecret, key)
: undefined;
const encryptedClientId = clientId ? encrypt(clientId, key) : undefined;
await db.transaction(async (trx) => {
const idpData = {
name,
autoProvision,
defaultRoleMapping,
defaultOrgMapping
};
// only update if at least one key is not undefined
let keysToUpdate = Object.keys(idpData).filter(
(key) => idpData[key as keyof typeof idpData] !== undefined
);
if (keysToUpdate.length > 0) {
await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId));
}
const configData = {
clientId: encryptedClientId,
clientSecret: encryptedSecret,
authUrl,
tokenUrl,
scopes,
identifierPath,
emailPath,
namePath
};
keysToUpdate = Object.keys(configData).filter(
(key) =>
configData[key as keyof typeof configData] !== undefined
);
if (keysToUpdate.length > 0) {
// Update OIDC config
await trx
.update(idpOidcConfig)
.set(configData)
.where(eq(idpOidcConfig.idpId, idpId));
}
});
return response<UpdateIdpResponse>(res, {
data: {
idpId
},
success: true,
error: false,
message: "IdP updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,250 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, users } from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath";
import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
createSession,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { response } from "@server/lib";
const paramsSchema = z
.object({
idpId: z.coerce.number()
})
.strict();
const bodySchema = z.object({
code: z.string().nonempty(),
state: z.string().nonempty(),
storedState: z.string().nonempty()
});
export type ValidateOidcUrlCallbackResponse = {
redirectUrl: string;
};
export async function validateOidcCallback(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { storedState, code, state: expectedState } = parsedBody.data;
const [existingIdp] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId)));
if (!existingIdp) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IdP not found for the organization"
)
);
}
const key = config.getRawConfig().server.secret;
const decryptedClientId = decrypt(
existingIdp.idpOidcConfig.clientId,
key
);
const decryptedClientSecret = decrypt(
existingIdp.idpOidcConfig.clientSecret,
key
);
const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId);
const client = new arctic.OAuth2Client(
decryptedClientId,
decryptedClientSecret,
redirectUrl
);
const statePayload = jsonwebtoken.verify(
storedState,
config.getRawConfig().server.secret,
function (err, decoded) {
if (err) {
logger.error("Error verifying state JWT", { err });
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid state JWT"
)
);
}
return decoded;
}
);
const stateObj = z
.object({
redirectUrl: z.string(),
state: z.string(),
codeVerifier: z.string()
})
.safeParse(statePayload);
if (!stateObj.success) {
logger.error("Error parsing state JWT");
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(stateObj.error).toString()
)
);
}
const {
codeVerifier,
state,
redirectUrl: postAuthRedirectUrl
} = stateObj.data;
if (state !== expectedState) {
logger.error("State mismatch", { expectedState, state });
return next(
createHttpError(HttpCode.BAD_REQUEST, "State mismatch")
);
}
const tokens = await client.validateAuthorizationCode(
existingIdp.idpOidcConfig.tokenUrl,
code,
codeVerifier
);
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken);
const userIdentifier = jmespath.search(
claims,
existingIdp.idpOidcConfig.identifierPath
);
if (!userIdentifier) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User identifier not found in the ID token"
)
);
}
logger.debug("User identifier", { userIdentifier });
let email = null;
let name = null;
try {
if (existingIdp.idpOidcConfig.emailPath) {
email = jmespath.search(
claims,
existingIdp.idpOidcConfig.emailPath
);
}
if (existingIdp.idpOidcConfig.namePath) {
name = jmespath.search(
claims,
existingIdp.idpOidcConfig.namePath || ""
);
}
} catch (error) {}
logger.debug("User email", { email });
logger.debug("User name", { name });
const [existingUser] = await db
.select()
.from(users)
.where(
and(
eq(users.username, userIdentifier),
eq(users.idpId, existingIdp.idp.idpId)
)
);
if (existingIdp.idp.autoProvision) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Auto provisioning is not supported"
)
);
} else {
if (!existingUser) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"User not provisioned in the system"
)
);
}
const token = generateSessionToken();
const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

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

View file

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

View file

@ -0,0 +1,141 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Org, orgs, userOrgs } from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, inArray, eq } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listOrgsParamsSchema = z.object({
userId: z.string()
});
const listOrgsSchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
registry.registerPath({
method: "get",
path: "/user/:userId/orgs",
description: "List all organizations for a user.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
query: listOrgsSchema
},
responses: {}
});
export type ListUserOrgsResponse = {
orgs: Org[];
pagination: { total: number; limit: number; offset: number };
};
export async function listUserOrgs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listOrgsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listOrgsParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const { userId } = parsedParams.data;
const userOrganizations = await db
.select({
orgId: userOrgs.orgId,
roleId: userOrgs.roleId
})
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
const userOrgIds = userOrganizations.map((org) => org.orgId);
if (!userOrgIds || userOrgIds.length === 0) {
return response<ListUserOrgsResponse>(res, {
data: {
orgs: [],
pagination: {
total: 0,
limit,
offset
}
},
success: true,
error: false,
message: "No organizations found for the user",
status: HttpCode.OK
});
}
const organizations = await db
.select()
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds))
.limit(limit)
.offset(offset);
const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs)
.where(inArray(orgs.orgId, userOrgIds));
const totalCount = totalCountResult[0].count;
return response<ListUserOrgsResponse>(res, {
data: {
orgs: organizations,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Organizations retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred..."
)
);
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,207 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db/schemas";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z
.object({
orgId: z.string().nonempty()
})
.strict();
const bodySchema = z
.object({
email: z.string().email().optional(),
username: z.string().nonempty(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),
idpId: z.number().optional(),
roleId: z.number()
})
.strict();
export type CreateOrgUserResponse = {};
registry.registerPath({
method: "put",
path: "/org/{orgId}/user",
description: "Create an organization user.",
tags: [OpenAPITags.User, OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createOrgUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleId } = parsedBody.data;
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId));
if (!role) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Role ID not found")
);
}
if (type === "internal") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Internal users are not supported yet"
)
);
} else if (type === "oidc") {
if (!idpId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IDP ID is required for OIDC users"
)
);
}
const [idpRes] = await db
.select()
.from(idp)
.innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(eq(idp.idpId, idpId));
if (!idpRes) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found")
);
}
if (idpRes.idp.type !== "oidc") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IDP ID is not of type OIDC"
)
);
}
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.username, username));
if (existingUser) {
const [existingOrgUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, existingUser.userId)
)
);
if (existingOrgUser) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User already exists in this organization"
)
);
}
await db
.insert(userOrgs)
.values({
orgId,
userId: existingUser.userId,
roleId: role.roleId
})
.returning();
} else {
const userId = generateId(15);
const [newUser] = await db
.insert(users)
.values({
userId: userId,
email,
username,
name,
type: "oidc",
idpId,
dateCreated: new Date().toISOString(),
emailVerified: true
})
.returning();
await db
.insert(userOrgs)
.values({
orgId,
userId: newUser.userId,
roleId: role.roleId
})
.returning();
}
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
);
}
return response<CreateOrgUserResponse>(res, {
data: {},
success: true,
error: false,
message: "Org user created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,369 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
type InviteUserFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
];
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
}
});
useEffect(() => {
if (open) {
setSendEmail(env.email.emailEnabled);
form.reset();
setInviteLink(null);
setExpiresInDays(1);
}
}, [open, env.email.emailEnabled, form]);
useEffect(() => {
if (!open) {
return;
}
async function fetchRoles() {
const res = await api
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${org?.org.orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
sendEmail: sendEmail
} as InviteUserBody
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
}
});
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited."
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Invite User</CredenzaTitle>
<CredenzaDescription>
Give new users access to your organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{!inviteLink && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="invite-user-form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to user
</label>
</div>
)}
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(option) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has been sent to the user
with the access link below. They
must access the link to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has been invited. They must
access the link below to accept the
invitation.
</p>
)}
<p>
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
loading={loading}
disabled={inviteLink !== null || loading}
>
Create Invitation
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

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

View file

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

View file

@ -0,0 +1,793 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
type UserType = "internal" | "oidc";
interface UserTypeOption {
id: UserType;
title: string;
description: string;
}
interface IdpOption {
idpId: number;
name: string;
type: string;
}
const internalFormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
email: z
.string()
.email({ message: "Invalid email address" })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: "Please select a role" }),
idpId: z.string().min(1, { message: "Please select an identity provider" })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return "Generic OAuth2/OIDC provider.";
default:
return type;
}
};
export default function Page() {
const { orgId } = useParams();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [userType, setUserType] = useState<UserType | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
];
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
}
});
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
resolver: zodResolver(externalFormSchema),
defaultValues: {
username: "",
email: "",
name: "",
roleId: "",
idpId: ""
}
});
useEffect(() => {
if (userType === "internal") {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setExpiresInDays(1);
} else if (userType === "oidc") {
externalForm.reset();
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
useEffect(() => {
if (!userType) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
if (userType === "internal") {
setDataLoaded(true);
}
}
}
async function fetchIdps() {
const res = await api
.get<AxiosResponse<ListIdpsResponse>>("/idp")
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch identity providers",
description: formatAxiosError(
e,
"An error occurred while fetching identity providers"
)
});
});
if (res?.status === 200) {
setIdps(res.data.data.idps);
setDataLoaded(true);
}
}
setDataLoaded(false);
fetchRoles();
if (userType !== "internal") {
fetchIdps();
}
}, [userType]);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
sendEmail: sendEmail
} as InviteUserBody
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
}
});
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited."
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
async function onSubmitExternal(
values: z.infer<typeof externalFormSchema>
) {
setLoading(true);
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
email: values.email,
name: values.name,
type: "oidc",
idpId: parseInt(values.idpId),
roleId: parseInt(values.roleId)
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create user",
description: formatAxiosError(
e,
"An error occurred while creating the user"
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: "User created",
description: "The user has been successfully created."
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: "Internal User",
description: "Invite a user to join your organization directly."
},
{
id: "oidc",
title: "External User",
description: "Create a user with an external identity provider."
}
];
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Create User"
description="Follow the steps below to create a new user"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
See All Users
</Button>
</div>
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to create the user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
onChange={(value) => {
setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{userType === "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to
user
</label>
</div>
)}
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has
been sent to the
user with the
access link
below. They must
access the link
to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has
been invited.
They must access
the link below
to accept the
invitation.
</p>
)}
<p>
The invite will
expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays ===
1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{userType !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Identity Provider
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the identity provider for the
external user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
No identity providers are
configured. Please configure an
identity provider before creating
external users.
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
id: idp.idpId.toString(),
title: idp.name,
description:
formatIdpType(
idp.type
)
})
)}
defaultValue={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
const idp =
idps.find(
(idp) =>
idp.idpId.toString() ===
value
);
setSelectedIdp(
idp || null
);
}}
cols={3}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...externalForm}>
<form
onSubmit={externalForm.handleSubmit(
onSubmitExternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
Username
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
This must
match the
unique
username
that exists
in the
selected
identity
provider.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
Cancel
</Button>
{userType && dataLoaded && (
<Button
type="submit"
form="create-user-form"
loading={loading}
disabled={
loading ||
(userType === "internal" && inviteLink !== null)
}
>
Create User
</Button>
)}
</div>
</div>
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
const UsersRolesFormSchema = z.object({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() {
setAllUsers( setAllUsers(
usersResponse.data.data.users.map((user) => ({ usersResponse.data.data.users.map((user) => ({
id: user.id.toString(), id: user.id.toString(),
text: user.email text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
})) }))
); );
@ -183,7 +186,7 @@ export default function ResourceAuthenticationPage() {
"users", "users",
resourceUsersResponse.data.data.users.map((i) => ({ resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(), id: i.userId.toString(),
text: i.email text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
})) }))
); );
@ -611,7 +614,6 @@ export default function ResourceAuthenticationPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
{env.email.emailEnabled && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@ -623,14 +625,26 @@ export default function ResourceAuthenticationPage() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
SMTP Required
</AlertTitle>
<AlertDescription>
SMTP must be enabled on the server to use one-time password authentication.
</AlertDescription>
</Alert>
)}
<SwitchInput <SwitchInput
id="whitelist-toggle" id="whitelist-toggle"
label="Email Whitelist" label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled} defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled} onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/> />
{whitelistEnabled && ( {whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}> <Form {...whitelistForm}>
<form id="whitelist-form"> <form id="whitelist-form">
<FormField <FormField
@ -721,7 +735,6 @@ export default function ResourceAuthenticationPage() {
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
)}
</SettingsContainer> </SettingsContainer>
</> </>
); );

View file

@ -1,6 +1,14 @@
"use client"; "use client";
import { Button, buttonVariants } from "@app/components/ui/button"; import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { import {
Form, Form,
FormControl, FormControl,
@ -10,48 +18,22 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { import { useEffect, useState } from "react";
Credenza, import { Controller, useForm } from "react-hook-form";
CredenzaBody, import { zodResolver } from "@hookform/resolvers/zod";
CredenzaClose, import { Input } from "@app/components/ui/input";
CredenzaContent, import { Button } from "@app/components/ui/button";
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { CheckIcon } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons";
import CustomDomainInput from "./[resourceId]/CustomDomainInput";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schemas";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn"; import { toast } from "@app/hooks/useToast";
import { Switch } from "@app/components/ui/switch"; import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schemas";
import { StrategySelect } from "@app/components/StrategySelect";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -60,118 +42,189 @@ import {
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import Link from "next/link";
import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain"; import { ListDomainsResponse } from "@server/routers/domain";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { StrategySelect } from "@app/components/StrategySelect"; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
const createResourceFormSchema = z const baseResourceFormSchema = z.object({
.object({
subdomain: z.string().optional(),
domainId: z.string().min(1).optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean()
protocol: z.string(), });
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional() const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
z.object({
isBaseDomain: z.literal(true),
domainId: z.string().min(1)
}),
z.object({
isBaseDomain: z.literal(false),
domainId: z.string().min(1),
subdomain: z.string().pipe(subdomainSchema)
}) })
.refine( ]);
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
);
type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>; const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
proxyPort: z.number().int().min(1).max(65535)
});
type CreateResourceFormProps = { type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
open: boolean; type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
setOpen: (open: boolean) => void; type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
};
export default function CreateResourceForm({ type ResourceType = "http" | "raw";
open,
setOpen
}: CreateResourceFormProps) {
const [formKey, setFormKey] = useState(0);
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); interface ResourceTypeOption {
const params = useParams(); id: ResourceType;
title: string;
description: string;
disabled?: boolean;
}
const orgId = params.orgId; export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter(); const router = useRouter();
const { org } = useOrgContext(); const [loadingPage, setLoadingPage] = useState(true);
const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [baseDomains, setBaseDomains] = useState< const [baseDomains, setBaseDomains] = useState<
{ domainId: string; baseDomain: string }[] { domainId: string; baseDomain: string }[]
>([]); >([]);
const [showSnippets, setShowSnippets] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain"
);
const [loadingPage, setLoadingPage] = useState(true);
const form = useForm<CreateResourceFormValues>({ const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
resolver: zodResolver(createResourceFormSchema), {
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number.",
disabled: !env.flags.allowRawResources
}
];
const baseForm = useForm<BaseResourceFormValues>({
resolver: zodResolver(baseResourceFormSchema),
defaultValues: { defaultValues: {
subdomain: "",
domainId: "",
name: "", name: "",
http: true, http: true
protocol: "tcp"
} }
}); });
function reset() { const httpForm = useForm<HttpResourceFormValues>({
form.reset(); resolver: zodResolver(httpResourceFormSchema),
setSites([]); defaultValues: {
setShowSnippets(false); subdomain: "",
setResourceId(null); domainId: "",
isBaseDomain: false
}
});
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: {
protocol: "tcp",
proxyPort: undefined
}
});
async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues();
const isHttp = baseData.http;
try {
const payload = {
name: baseData.name,
siteId: baseData.siteId,
http: baseData.http
};
if (isHttp) {
const httpData = httpForm.getValues();
if (httpData.isBaseDomain) {
Object.assign(payload, {
domainId: httpData.domainId,
isBaseDomain: true
});
} else {
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
isBaseDomain: false
});
}
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
protocol: tcpUdpData.protocol,
proxyPort: tcpUdpData.proxyPort
});
}
const res = await api
.put<
AxiosResponse<Resource>
>(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating resource",
description: formatAxiosError(
e,
"An error occurred when creating the resource"
)
});
});
if (res && res.status === 201) {
const id = res.data.data.resourceId;
router.push(`/${orgId}/settings/resources/${id}`);
}
} catch (e) {
console.error("Error creating resource:", e);
toast({
variant: "destructive",
title: "Error creating resource",
description: "An unexpected error occurred"
});
}
setCreateLoading(false);
} }
useEffect(() => { useEffect(() => {
if (!open) { const load = async () => {
return; setLoadingPage(true);
}
reset();
const fetchSites = async () => { const fetchSites = async () => {
const res = await api const res = await api
.get<AxiosResponse<ListSitesResponse>>(`/org/${orgId}/sites/`) .get<
AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites/`)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@ -187,7 +240,10 @@ export default function CreateResourceForm({
setSites(res.data.data.sites); setSites(res.data.data.sites);
if (res.data.data.sites.length > 0) { if (res.data.data.sites.length > 0) {
form.setValue("siteId", res.data.data.sites[0].siteId); baseForm.setValue(
"siteId",
res.data.data.sites[0].siteId
);
} }
} }
}; };
@ -212,119 +268,55 @@ export default function CreateResourceForm({
const domains = res.data.data.domains; const domains = res.data.data.domains;
setBaseDomains(domains); setBaseDomains(domains);
if (domains.length) { if (domains.length) {
form.setValue("domainId", domains[0].domainId); httpForm.setValue("domainId", domains[0].domainId);
setFormKey((k) => k + 1);
} }
} }
}; };
const load = async () => {
setLoadingPage(true);
await fetchSites(); await fetchSites();
await fetchDomains(); await fetchDomains();
await new Promise((r) => setTimeout(r, 200));
setLoadingPage(false); setLoadingPage(false);
}; };
load(); load();
}, [open]); }, []);
async function onSubmit(data: CreateResourceFormValues) {
const res = await api
.put<AxiosResponse<Resource>>(
`/org/${orgId}/site/${data.siteId}/resource/`,
{
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
domainId: data.http ? data.domainId : undefined,
http: data.http,
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId,
isBaseDomain: data.http ? data.isBaseDomain : undefined
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating resource",
description: formatAxiosError(
e,
"An error occurred when creating the resource"
)
});
});
if (res && res.status === 201) {
const id = res.data.data.resourceId;
setResourceId(id);
if (data.http) {
goToResource(id);
} else {
setShowSnippets(true);
router.refresh();
}
}
}
function goToResource(id?: number) {
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
}
const launchOptions = [
{
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number."
}
];
return ( return (
<> <>
<Credenza <div className="flex justify-between">
open={open} <HeaderTitle
onOpenChange={(val) => { title="Create Resource"
setOpen(val); description="Follow the steps below to create a new resource"
setLoading(false); />
<Button
// reset all values variant="outline"
form.reset(); onClick={() => {
router.push(`/${orgId}/settings/resources`);
}} }}
> >
<CredenzaContent> See All Resources
<CredenzaHeader> </Button>
<CredenzaTitle>Create Resource</CredenzaTitle> </div>
<CredenzaDescription>
Create a new resource to proxy requests to your app {!loadingPage && (
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div> <div>
{!showSnippets && ( <SettingsContainer>
<Form {...form} key={formKey}> <SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Resource Information
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...baseForm}>
<form <form
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4" className="space-y-4"
id="create-resource-form" id="base-resource-form"
> >
<FormField <FormField
control={form.control} control={baseForm.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@ -335,12 +327,17 @@ export default function CreateResourceForm({
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
This is the display
name for the
resource.
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={baseForm.control}
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
@ -395,7 +392,7 @@ export default function CreateResourceForm({
site.siteId site.siteId
} }
onSelect={() => { onSelect={() => {
form.setValue( baseForm.setValue(
"siteId", "siteId",
site.siteId site.siteId
); );
@ -430,35 +427,61 @@ export default function CreateResourceForm({
</FormItem> </FormItem>
)} )}
/> />
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{!env.flags.allowRawResources || ( <SettingsSection>
<div className="space-y-2"> <SettingsSectionHeader>
<FormLabel> <SettingsSectionTitle>
Resource Type Resource Type
</FormLabel> </SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to access your
resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect <StrategySelect
options={launchOptions} options={resourceTypes}
defaultValue="http" defaultValue="http"
onChange={(value) => onChange={(value) => {
form.setValue( baseForm.setValue(
"http", "http",
value === "http" value === "http"
) );
} }}
cols={2}
/> />
<FormDescription> </SettingsSectionBody>
You cannot change the </SettingsSection>
type of resource after
creation.
</FormDescription>
</div>
)}
{form.watch("http") && {baseForm.watch("http") ? (
env.flags <SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
HTTPS Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over HTTPS
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...httpForm}>
<form
className="space-y-4"
id="http-settings-form"
>
{env.flags
.allowBaseDomainResources && ( .allowBaseDomainResources && (
<FormField <FormField
control={form.control} control={
httpForm.control
}
name="isBaseDomain" name="isBaseDomain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@ -467,20 +490,15 @@ export default function CreateResourceForm({
</FormLabel> </FormLabel>
<Select <Select
value={ value={
domainType field.value
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain" ? "basedomain"
: "subdomain" : "subdomain"
); }
form.setValue( onValueChange={(
"isBaseDomain", value
val === ) => {
field.onChange(
value ===
"basedomain" "basedomain"
); );
}} }}
@ -506,19 +524,18 @@ export default function CreateResourceForm({
/> />
)} )}
{form.watch("http") && ( {!httpForm.watch(
<> "isBaseDomain"
{domainType === ) && (
"subdomain" ? ( <FormItem>
<div className="w-fill space-y-2">
<FormLabel> <FormLabel>
Subdomain Subdomain
</FormLabel> </FormLabel>
<div className="flex"> <div className="flex space-x-0">
<div className="w-1/2"> <div className="w-1/2">
<FormField <FormField
control={ control={
form.control httpForm.control
} }
name="subdomain" name="subdomain"
render={({ render={({
@ -539,7 +556,7 @@ export default function CreateResourceForm({
<div className="w-1/2"> <div className="w-1/2">
<FormField <FormField
control={ control={
form.control httpForm.control
} }
name="domainId" name="domainId"
render={({ render={({
@ -590,20 +607,26 @@ export default function CreateResourceForm({
/> />
</div> </div>
</div> </div>
</div> <FormDescription>
) : ( The subdomain where
your resource will
be accessible.
</FormDescription>
</FormItem>
)}
{httpForm.watch(
"isBaseDomain"
) && (
<FormField <FormField
control={ control={
form.control httpForm.control
} }
name="domainId" name="domainId"
render={({ render={({ field }) => (
field
}) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
Base Base Domain
Domain
</FormLabel> </FormLabel>
<Select <Select
onValueChange={ onValueChange={
@ -645,13 +668,31 @@ export default function CreateResourceForm({
)} )}
/> />
)} )}
</> </form>
)} </Form>
</SettingsSectionForm>
{!form.watch("http") && ( </SettingsSectionBody>
<> </SettingsSection>
<FormField ) : (
control={form.control} <SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
TCP/UDP Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource will be
accessed over TCP/UDP
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tcpUdpForm}>
<form
className="space-y-4"
id="tcp-udp-settings-form"
>
<Controller
control={tcpUdpForm.control}
name="protocol" name="protocol"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@ -659,12 +700,10 @@ export default function CreateResourceForm({
Protocol Protocol
</FormLabel> </FormLabel>
<Select <Select
value={
field.value
}
onValueChange={ onValueChange={
field.onChange field.onChange
} }
{...field}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@ -684,8 +723,9 @@ export default function CreateResourceForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={tcpUdpForm.control}
name="proxyPort" name="proxyPort"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@ -711,7 +751,7 @@ export default function CreateResourceForm({
.target .target
.value .value
) )
: null : undefined
) )
} }
/> />
@ -719,92 +759,50 @@ export default function CreateResourceForm({
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
The external The external
port number port number to
to proxy proxy requests.
requests.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
/> />
</>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)} )}
</SettingsContainer>
{showSnippets && ( <div className="flex justify-end space-x-2 mt-8">
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in
Docker Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Pangolin/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Learn how to configure TCP/UDP
resources
</span>
<SquareArrowOutUpRight size={14} />
</Link>
</div>
)}
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{!showSnippets && (
<Button <Button
type="submit" type="button"
form="create-resource-form" variant="outline"
loading={loading} onClick={() =>
disabled={loading} router.push(`/${orgId}/settings/resources`)
}
>
Cancel
</Button>
<Button
type="button"
onClick={async () => {
const isHttp = baseForm.watch("http");
const baseValid = await baseForm.trigger();
const settingsValid = isHttp
? await httpForm.trigger()
: await tcpUdpForm.trigger();
if (baseValid && settingsValid) {
onSubmit();
}
}}
loading={createLoading}
> >
Create Resource Create Resource
</Button> </Button>
</div>
</div>
)} )}
{showSnippets && (
<Button
loading={loading}
onClick={() => goToResource()}
>
Go to Resource
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }

View file

@ -0,0 +1,31 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useRouter } from "next/navigation";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function IdpDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const router = useRouter();
return (
<DataTable
columns={columns}
data={data}
title="Identity Providers"
searchPlaceholder="Search identity providers..."
searchColumn="name"
addButtonText="Add Identity Provider"
onAdd={() => {
router.push("/admin/idp/create");
}}
/>
);
}

View file

@ -0,0 +1,214 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { IdpDataTable } from "./AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
export type IdpRow = {
idpId: number;
name: string;
type: string;
orgCount: number;
};
type Props = {
idps: IdpRow[];
};
export default function IdpTable({ idps }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const router = useRouter();
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/idp/${idpId}`);
toast({
title: "Success",
description: "Identity provider deleted successfully"
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "oidc":
return "OAuth2/OIDC";
default:
return type;
}
};
const columns: ColumnDef<IdpRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/admin/idp/${r.idpId}/general`}
>
<DropdownMenuItem>
View settings
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(r);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "idpId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedIdp && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to permanently delete the
identity provider <b>{selectedIdp.name}</b>?
</p>
<p>
<b>
This will remove the identity provider and
all associated configurations. Users who
authenticate through this provider will no
longer be able to log in.
</b>
</p>
<p>
To confirm, please type the name of the identity
provider below.
</p>
</div>
}
buttonText="Confirm Delete Identity Provider"
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title="Delete Identity Provider"
/>
)}
<IdpDataTable columns={columns} data={idps} />
</>
);
}

View file

@ -0,0 +1,507 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import { useRouter, useParams, redirect } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter,
SettingsSectionGrid
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@app/components/ui/badge";
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
clientId: z.string().min(1, { message: "Client ID is required." }),
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
identifierPath: z
.string()
.min(1, { message: "Identifier Path is required." }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: "Scopes are required." }),
autoProvision: z.boolean().default(false)
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
emailPath: "email",
namePath: "name",
scopes: "openid profile email",
autoProvision: true
}
});
useEffect(() => {
const loadIdp = async () => {
try {
const res = await api.get(`/idp/${idpId}`);
if (res.status === 200) {
const data = res.data.data;
form.reset({
name: data.idp.name,
clientId: data.idpOidcConfig.clientId,
clientSecret: data.idpOidcConfig.clientSecret,
authUrl: data.idpOidcConfig.authUrl,
tokenUrl: data.idpOidcConfig.tokenUrl,
identifierPath: data.idpOidcConfig.identifierPath,
emailPath: data.idpOidcConfig.emailPath,
namePath: data.idpOidcConfig.namePath,
scopes: data.idpOidcConfig.scopes,
autoProvision: data.idp.autoProvision
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
router.push("/admin/idp");
} finally {
setInitialLoading(false);
}
};
loadIdp();
}, [idpId, api, form, router]);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
const payload = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
authUrl: data.authUrl,
tokenUrl: data.tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
scopes: data.scopes
};
const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
toast({
title: "Success",
description: "Identity provider updated successfully"
});
router.refresh();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
if (initialLoading) {
return null;
}
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
Redirect URL
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Redirect URL
</AlertTitle>
<AlertDescription>
This is the URL to which users will be
redirected after authentication. You need to
configure this URL in your identity provider
settings.
</AlertDescription>
</Alert>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this
identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={true}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<Badge className="ml-2">
Enterprise
</Badge>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints and
credentials
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
Client ID
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Token URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from
the ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath
syntax to extract values from
the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's email in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's name in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
Scopes
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
<div className="flex justify-end mt-8">
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save Settings
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,57 @@
import { internal } from "@app/lib/api";
import { GetIdpResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ idpId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let idp = null;
try {
const res = await internal.get<AxiosResponse<GetIdpResponse>>(
`/idp/${params.idpId}`,
await authCookieHeader()
);
idp = res.data.data;
} catch {
redirect("/admin/idp");
}
const navItems = [
{
title: "General",
href: `/admin/idp/${params.idpId}/general`
}
];
return (
<>
<SettingsSectionTitle
title={`${idp.idp.name} Settings`}
description="Configure the settings for your identity provider"
/>
<div className="space-y-6">
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</>
);
}

View file

@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function IdpPage(props: {
params: Promise<{ idpId: string }>;
}) {
const params = await props.params;
redirect(`/admin/idp/${params.idpId}/general`);
}

View file

@ -0,0 +1,516 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionGrid,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
type: z.enum(["oidc"]),
clientId: z.string().min(1, { message: "Client ID is required." }),
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
identifierPath: z
.string()
.min(1, { message: "Identifier Path is required." }),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z.string().min(1, { message: "Scopes are required." }),
autoProvision: z.boolean().default(false)
});
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc";
title: string;
description: string;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: "Configure an OpenID Connect identity provider"
}
];
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
autoProvision: false
}
});
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true);
try {
const payload = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
authUrl: data.authUrl,
tokenUrl: data.tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
scopes: data.scopes
};
const res = await api.put("/idp/oidc", payload);
if (res.status === 201) {
toast({
title: "Success",
description: "Identity provider created successfully"
});
router.push(`/admin/idp/${res.data.data.idpId}`);
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setCreateLoading(false);
}
}
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Create Identity Provider"
description="Configure a new identity provider for user authentication"
/>
<Button
variant="outline"
onClick={() => {
router.push("/admin/idp");
}}
>
See All Identity Providers
</Button>
</div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this
identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={true}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
<Badge className="ml-2">
Enterprise
</Badge>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Provider Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the type of identity provider you want to
configure
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints
and credentials
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
Client ID
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Token URL
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 token
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Important Information
</AlertTitle>
<AlertDescription>
After creating the identity provider,
you will need to configure the callback
URL in your identity provider's
settings. The callback URL will be
provided after successful creation.
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information
from the ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath
syntax to extract values from
the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's email in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's name in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
Scopes
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push("/admin/idp");
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={createLoading}
loading={createLoading}
onClick={form.handleSubmit(onSubmit)}
>
Create Identity Provider
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,28 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "./AdminIdpTable";
export default async function IdpPage() {
let idps: IdpRow[] = [];
try {
const res = await internal.get<AxiosResponse<{ idps: IdpRow[] }>>(
`/idp`,
await authCookieHeader()
);
idps = res.data.data.idps;
} catch (e) {
console.error(e);
}
return (
<>
<SettingsSectionTitle
title="Manage Identity Providers"
description="View and manage identity providers in the system"
/>
<IdpTable idps={idps} />
</>
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,118 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
type ValidateOidcTokenParams = {
orgId: string;
idpId: string;
code: string | undefined;
expectedState: string | undefined;
stateCookie: string | undefined;
idp: { name: string };
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function validate() {
setLoading(true);
console.log("Validating OIDC token", {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
});
try {
const res = await api.post<
AxiosResponse<ValidateOidcUrlCallbackResponse>
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
code: props.code,
state: props.expectedState,
storedState: props.stateCookie
});
console.log("Validate OIDC token response", res.data);
const redirectUrl = res.data.data.redirectUrl;
if (!redirectUrl) {
router.push("/");
}
setLoading(false);
await new Promise((resolve) => setTimeout(resolve, 100));
if (redirectUrl.startsWith("http")) {
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
} else {
router.push(res.data.data.redirectUrl);
}
} catch (e) {
setError(formatAxiosError(e, "Error validating OIDC token"));
} finally {
setLoading(false);
}
}
validate();
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Connecting to {props.idp.name}</CardTitle>
<CardDescription>Validating your identity</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Connecting...</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>Connected</span>
</div>
)}
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
There was a problem connecting to{" "}
{props.idp.name}. Please contact your
administrator.
</span>
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,42 @@
import { cookies } from "next/headers";
import ValidateOidcToken from "./ValidateOidcToken";
import { idp } from "@server/db/schemas";
import db from "@server/db";
import { eq } from "drizzle-orm";
export default async function Page(props: {
params: Promise<{ orgId: string; idpId: string }>;
searchParams: Promise<{
code: string;
state: string;
}>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const allCookies = await cookies();
const stateCookie = allCookies.get("p_oidc_state")?.value;
// query db directly in server component because just need the name
const [idpRes] = await db
.select({ name: idp.name })
.from(idp)
.where(eq(idp.idpId, parseInt(params.idpId!)));
if (!idpRes) {
return <div>IdP not found</div>;
}
return (
<>
<ValidateOidcToken
orgId={params.orgId}
idpId={params.idpId}
code={searchParams.code}
expectedState={searchParams.state}
stateCookie={stateCookie}
idp={{ name: idpRes.name }}
/>
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +0,0 @@
"use client";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
interface HeaderProps {
orgId?: string;
orgs?: any;
}
export function Header({ orgId, orgs }: HeaderProps) {
const { env } = useEnvContext();
return (
<div className="flex items-center justify-between w-full">
<Link href="/" className="flex items-center space-x-2">
<Image
src="/logo/pangolin_orange.svg"
alt="Pangolin Logo"
width={34}
height={34}
/>
<span className="font-[Space_Grotesk] font-bold text-2xl text-neutral-500">Pangolin</span>
</Link>
</div>
);
}
export default Header;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,37 +0,0 @@
"use client";
import ProfileIcon from "@app/components/ProfileIcon";
import Link from "next/link";
interface TopBarProps {
orgId?: string;
orgs?: any;
}
export function TopBar({ orgId, orgs }: TopBarProps) {
return (
<div className="flex items-center justify-end md:justify-between w-full h-full">
<div className="hidden md:flex items-center space-x-4">
<Link
href="https://docs.fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Documentation
</Link>
<Link
href="mailto:support@fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
</Link>
</div>
<div>
<ProfileIcon />
</div>
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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