mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-22 21:33:42 +02:00
add api key code and oidc auto provision code
This commit is contained in:
parent
4819f410e6
commit
599d0a52bf
84 changed files with 7021 additions and 151 deletions
31
package-lock.json
generated
31
package-lock.json
generated
|
@ -46,6 +46,7 @@
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
|
@ -94,6 +95,7 @@
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
|
@ -4478,7 +4480,7 @@
|
||||||
"version": "7.6.12",
|
"version": "7.6.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
||||||
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
|
@ -4525,6 +4527,13 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/crypto-js": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
|
@ -4619,7 +4628,7 @@
|
||||||
"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",
|
||||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
|
@ -4653,7 +4662,7 @@
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
||||||
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
|
@ -4663,7 +4672,7 @@
|
||||||
"version": "19.1.2",
|
"version": "19.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
|
@ -6271,11 +6280,17 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
|
@ -9584,7 +9599,7 @@
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
@ -15962,7 +15977,6 @@
|
||||||
"version": "4.1.4",
|
"version": "4.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
|
@ -16330,7 +16344,6 @@
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
@ -16362,7 +16375,7 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|
|
@ -57,6 +57,7 @@
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"drizzle-orm": "0.38.3",
|
"drizzle-orm": "0.38.3",
|
||||||
"eslint": "9.17.0",
|
"eslint": "9.17.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
|
@ -105,6 +106,7 @@
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"@types/cookie-parser": "1.4.8",
|
"@types/cookie-parser": "1.4.8",
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
"@types/jmespath": "^0.15.2",
|
"@types/jmespath": "^0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
|
|
|
@ -77,7 +77,15 @@ export enum ActionsEnum {
|
||||||
createIdpOrg = "createIdpOrg",
|
createIdpOrg = "createIdpOrg",
|
||||||
deleteIdpOrg = "deleteIdpOrg",
|
deleteIdpOrg = "deleteIdpOrg",
|
||||||
listIdpOrgs = "listIdpOrgs",
|
listIdpOrgs = "listIdpOrgs",
|
||||||
updateIdpOrg = "updateIdpOrg"
|
updateIdpOrg = "updateIdpOrg",
|
||||||
|
checkOrgId = "checkOrgId",
|
||||||
|
createApiKey = "createApiKey",
|
||||||
|
deleteApiKey = "deleteApiKey",
|
||||||
|
setApiKeyActions = "setApiKeyActions",
|
||||||
|
setApiKeyOrgs = "setApiKeyOrgs",
|
||||||
|
listApiKeyActions = "listApiKeyActions",
|
||||||
|
listApiKeys = "listApiKeys",
|
||||||
|
getApiKey = "getApiKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
export * from "./proSchema";
|
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
// This file is licensed under the Fossorial Commercial License.
|
|
||||||
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
//
|
|
||||||
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
|
||||||
|
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
export const licenseKey = sqliteTable("licenseKey", {
|
|
||||||
licenseKeyId: text("licenseKeyId").primaryKey().notNull(),
|
|
||||||
instanceId: text("instanceId").notNull(),
|
|
||||||
token: text("token").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const hostMeta = sqliteTable("hostMeta", {
|
|
||||||
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
|
||||||
createdAt: integer("createdAt").notNull()
|
|
||||||
});
|
|
|
@ -458,6 +458,57 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||||
scopes: text("scopes").notNull()
|
scopes: text("scopes").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const licenseKey = sqliteTable("licenseKey", {
|
||||||
|
licenseKeyId: text("licenseKeyId").primaryKey().notNull(),
|
||||||
|
instanceId: text("instanceId").notNull(),
|
||||||
|
token: text("token").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hostMeta = sqliteTable("hostMeta", {
|
||||||
|
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
||||||
|
createdAt: integer("createdAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiKeys = sqliteTable("apiKeys", {
|
||||||
|
apiKeyId: text("apiKeyId").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
apiKeyHash: text("apiKeyHash").notNull(),
|
||||||
|
lastChars: text("lastChars").notNull(),
|
||||||
|
createdAt: text("dateCreated").notNull(),
|
||||||
|
isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiKeyActions = sqliteTable("apiKeyActions", {
|
||||||
|
apiKeyId: text("apiKeyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||||
|
actionId: text("actionId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => actions.actionId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
||||||
|
apiKeyId: text("apiKeyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const idpOrg = sqliteTable("idpOrg", {
|
||||||
|
idpId: integer("idpId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
roleMapping: text("roleMapping"),
|
||||||
|
orgMapping: text("orgMapping")
|
||||||
|
});
|
||||||
|
|
||||||
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>;
|
||||||
|
@ -494,3 +545,6 @@ 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>;
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
|
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
|
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||||
|
|
|
@ -4,7 +4,9 @@ import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
import { Session, User, UserOrg } from "./db/schemas/schema";
|
import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas";
|
||||||
|
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||||
|
import license from "./license/license.js";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await runSetupFunctions();
|
await runSetupFunctions();
|
||||||
|
@ -14,10 +16,16 @@ async function startServers() {
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
const nextServer = await createNextServer();
|
const nextServer = await createNextServer();
|
||||||
|
|
||||||
|
let integrationServer;
|
||||||
|
if (await license.isUnlocked()) {
|
||||||
|
integrationServer = createIntegrationApiServer();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiServer,
|
apiServer,
|
||||||
nextServer,
|
nextServer,
|
||||||
internalServer,
|
internalServer,
|
||||||
|
integrationServer
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,9 +33,11 @@ async function startServers() {
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
|
apiKey?: ApiKey;
|
||||||
user?: User;
|
user?: User;
|
||||||
session?: Session;
|
session?: Session;
|
||||||
userOrg?: UserOrg;
|
userOrg?: UserOrg;
|
||||||
|
apiKeyOrg?: ApiKeyOrg;
|
||||||
userOrgRoleId?: number;
|
userOrgRoleId?: number;
|
||||||
userOrgId?: string;
|
userOrgId?: string;
|
||||||
userOrgIds?: string[];
|
userOrgIds?: string[];
|
||||||
|
|
112
server/integrationApiServer.ts
Normal file
112
server/integrationApiServer.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
errorHandlerMiddleware,
|
||||||
|
notFoundMiddleware,
|
||||||
|
verifyValidLicense
|
||||||
|
} from "@server/middlewares";
|
||||||
|
import { authenticated, unauthenticated } from "@server/routers/integration";
|
||||||
|
import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
||||||
|
import { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import swaggerUi from "swagger-ui-express";
|
||||||
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
|
import { registry } from "./openApi";
|
||||||
|
|
||||||
|
const dev = process.env.ENVIRONMENT !== "prod";
|
||||||
|
const externalPort = config.getRawConfig().server.integration_port;
|
||||||
|
|
||||||
|
export function createIntegrationApiServer() {
|
||||||
|
const apiServer = express();
|
||||||
|
|
||||||
|
apiServer.use(verifyValidLicense);
|
||||||
|
|
||||||
|
if (config.getRawConfig().server.trust_proxy) {
|
||||||
|
apiServer.set("trust proxy", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiServer.use(cors());
|
||||||
|
|
||||||
|
if (!dev) {
|
||||||
|
apiServer.use(helmet());
|
||||||
|
apiServer.use(csrfProtectionMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiServer.use(cookieParser());
|
||||||
|
apiServer.use(express.json());
|
||||||
|
|
||||||
|
apiServer.use(
|
||||||
|
"/v1/docs",
|
||||||
|
swaggerUi.serve,
|
||||||
|
swaggerUi.setup(getOpenApiDocumentation())
|
||||||
|
);
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
const prefix = `/v1`;
|
||||||
|
apiServer.use(logIncomingMiddleware);
|
||||||
|
apiServer.use(prefix, unauthenticated);
|
||||||
|
apiServer.use(prefix, authenticated);
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
apiServer.use(notFoundMiddleware);
|
||||||
|
apiServer.use(errorHandlerMiddleware);
|
||||||
|
|
||||||
|
// Create HTTP server
|
||||||
|
const httpServer = apiServer.listen(externalPort, (err?: any) => {
|
||||||
|
if (err) throw err;
|
||||||
|
logger.info(
|
||||||
|
`Integration API server is running on http://localhost:${externalPort}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return httpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenApiDocumentation() {
|
||||||
|
const bearerAuth = registry.registerComponent(
|
||||||
|
"securitySchemes",
|
||||||
|
"Bearer Auth",
|
||||||
|
{
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const def of registry.definitions) {
|
||||||
|
if (def.type === "route") {
|
||||||
|
def.route.security = [
|
||||||
|
{
|
||||||
|
[bearerAuth.name]: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/",
|
||||||
|
description: "Health check",
|
||||||
|
tags: [],
|
||||||
|
request: {},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||||
|
|
||||||
|
return generator.generateDocument({
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: {
|
||||||
|
version: "v1",
|
||||||
|
title: "Pangolin Integration API"
|
||||||
|
},
|
||||||
|
servers: [{ url: "/v1" }]
|
||||||
|
});
|
||||||
|
}
|
|
@ -60,6 +60,10 @@ const configSchema = z.object({
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
server: z.object({
|
server: z.object({
|
||||||
|
integration_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema.optional()),
|
||||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||||
|
@ -96,14 +100,7 @@ const configSchema = z.object({
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("SERVER_SECRET"))
|
.transform(getEnvOrYaml("SERVER_SECRET"))
|
||||||
.pipe(
|
.pipe(z.string().min(8))
|
||||||
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(),
|
||||||
|
@ -267,6 +264,8 @@ export class Config {
|
||||||
: "false";
|
: "false";
|
||||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
|
license.setServerSecret(parsedConfig.data.server.secret);
|
||||||
|
|
||||||
this.checkKeyStatus();
|
this.checkKeyStatus();
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
|
@ -274,7 +273,6 @@ export class Config {
|
||||||
|
|
||||||
private async checkKeyStatus() {
|
private async checkKeyStatus() {
|
||||||
const licenseStatus = await license.check();
|
const licenseStatus = await license.check();
|
||||||
console.log("License status", licenseStatus);
|
|
||||||
if (!licenseStatus.isHostLicensed) {
|
if (!licenseStatus.isHostLicensed) {
|
||||||
this.checkSupporterKey();
|
this.checkSupporterKey();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,40 +1,12 @@
|
||||||
import * as crypto from "crypto";
|
import CryptoJS from "crypto-js";
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-gcm";
|
|
||||||
|
|
||||||
export function encrypt(value: string, key: string): string {
|
export function encrypt(value: string, key: string): string {
|
||||||
const iv = crypto.randomBytes(12);
|
const ciphertext = CryptoJS.AES.encrypt(value, key).toString();
|
||||||
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
|
return ciphertext;
|
||||||
|
|
||||||
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 {
|
export function decrypt(encryptedValue: string, key: string): string {
|
||||||
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
|
const bytes = CryptoJS.AES.decrypt(encryptedValue, key);
|
||||||
|
const originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||||
const iv = Buffer.from(ivB64, "base64");
|
return originalText;
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,8 @@ import NodeCache from "node-cache";
|
||||||
import { validateJWT } from "./licenseJwt";
|
import { validateJWT } from "./licenseJwt";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import { setHostMeta } from "@server/setup/setHostMeta";
|
||||||
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
export type LicenseStatus = {
|
export type LicenseStatus = {
|
||||||
isHostLicensed: boolean; // Are there any license keys?
|
isHostLicensed: boolean; // Are there any license keys?
|
||||||
|
@ -21,6 +23,7 @@ export type LicenseStatus = {
|
||||||
|
|
||||||
export type LicenseKeyCache = {
|
export type LicenseKeyCache = {
|
||||||
licenseKey: string;
|
licenseKey: string;
|
||||||
|
licenseKeyEncrypted: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
iat?: Date;
|
iat?: Date;
|
||||||
type?: "LICENSE" | "SITES";
|
type?: "LICENSE" | "SITES";
|
||||||
|
@ -69,6 +72,7 @@ export class License {
|
||||||
|
|
||||||
private ephemeralKey!: string;
|
private ephemeralKey!: string;
|
||||||
private statusKey = "status";
|
private statusKey = "status";
|
||||||
|
private serverSecret!: string;
|
||||||
|
|
||||||
private publicKey = `-----BEGIN PUBLIC KEY-----
|
private publicKey = `-----BEGIN PUBLIC KEY-----
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
||||||
|
@ -100,6 +104,10 @@ LQIDAQAB
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setServerSecret(secret: string) {
|
||||||
|
this.serverSecret = secret;
|
||||||
|
}
|
||||||
|
|
||||||
public async forceRecheck() {
|
public async forceRecheck() {
|
||||||
this.statusCache.flushAll();
|
this.statusCache.flushAll();
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
|
@ -129,8 +137,7 @@ LQIDAQAB
|
||||||
hostId: this.hostId,
|
hostId: this.hostId,
|
||||||
isHostLicensed: true,
|
isHostLicensed: true,
|
||||||
isLicenseValid: false,
|
isLicenseValid: false,
|
||||||
maxSites: undefined,
|
maxSites: undefined
|
||||||
usedSites: 150
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -143,8 +150,6 @@ LQIDAQAB
|
||||||
// Invalidate all
|
// Invalidate all
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
|
|
||||||
logger.debug("Checking license status...");
|
|
||||||
|
|
||||||
const allKeysRes = await db.select().from(licenseKey);
|
const allKeysRes = await db.select().from(licenseKey);
|
||||||
|
|
||||||
if (allKeysRes.length === 0) {
|
if (allKeysRes.length === 0) {
|
||||||
|
@ -152,24 +157,37 @@ LQIDAQAB
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let foundHostKey = false;
|
||||||
// Validate stored license keys
|
// Validate stored license keys
|
||||||
for (const key of allKeysRes) {
|
for (const key of allKeysRes) {
|
||||||
try {
|
try {
|
||||||
const payload = validateJWT<TokenPayload>(
|
// Decrypt the license key and token
|
||||||
|
const decryptedKey = decrypt(
|
||||||
|
key.licenseKeyId,
|
||||||
|
this.serverSecret
|
||||||
|
);
|
||||||
|
const decryptedToken = decrypt(
|
||||||
key.token,
|
key.token,
|
||||||
|
this.serverSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = validateJWT<TokenPayload>(
|
||||||
|
decryptedToken,
|
||||||
this.publicKey
|
this.publicKey
|
||||||
);
|
);
|
||||||
|
|
||||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
|
||||||
key.licenseKeyId,
|
licenseKey: decryptedKey,
|
||||||
{
|
licenseKeyEncrypted: key.licenseKeyId,
|
||||||
licenseKey: key.licenseKeyId,
|
|
||||||
valid: payload.valid,
|
valid: payload.valid,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
numSites: payload.quantity,
|
numSites: payload.quantity,
|
||||||
iat: new Date(payload.iat * 1000)
|
iat: new Date(payload.iat * 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.type === "LICENSE") {
|
||||||
|
foundHostKey = true;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error validating license key: ${key.licenseKeyId}`
|
`Error validating license key: ${key.licenseKeyId}`
|
||||||
|
@ -180,15 +198,21 @@ LQIDAQAB
|
||||||
key.licenseKeyId,
|
key.licenseKeyId,
|
||||||
{
|
{
|
||||||
licenseKey: key.licenseKeyId,
|
licenseKey: key.licenseKeyId,
|
||||||
|
licenseKeyEncrypted: key.licenseKeyId,
|
||||||
valid: false
|
valid: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!foundHostKey && allKeysRes.length) {
|
||||||
|
logger.debug("No host license key found");
|
||||||
|
status.isHostLicensed = false;
|
||||||
|
}
|
||||||
|
|
||||||
const keys = allKeysRes.map((key) => ({
|
const keys = allKeysRes.map((key) => ({
|
||||||
licenseKey: key.licenseKeyId,
|
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
|
||||||
instanceId: key.instanceId
|
instanceId: decrypt(key.instanceId, this.serverSecret)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||||
|
@ -251,12 +275,22 @@ LQIDAQAB
|
||||||
cached.numSites = payload.quantity;
|
cached.numSites = payload.quantity;
|
||||||
cached.iat = new Date(payload.iat * 1000);
|
cached.iat = new Date(payload.iat * 1000);
|
||||||
|
|
||||||
|
// Encrypt the updated token before storing
|
||||||
|
const encryptedKey = encrypt(
|
||||||
|
key.licenseKey,
|
||||||
|
this.serverSecret
|
||||||
|
);
|
||||||
|
const encryptedToken = encrypt(
|
||||||
|
licenseKeyRes,
|
||||||
|
this.serverSecret
|
||||||
|
);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(licenseKey)
|
.update(licenseKey)
|
||||||
.set({
|
.set({
|
||||||
token: licenseKeyRes
|
token: encryptedToken
|
||||||
})
|
})
|
||||||
.where(eq(licenseKey.licenseKeyId, key.licenseKey));
|
.where(eq(licenseKey.licenseKeyId, encryptedKey));
|
||||||
|
|
||||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||||
key.licenseKey,
|
key.licenseKey,
|
||||||
|
@ -300,10 +334,13 @@ LQIDAQAB
|
||||||
}
|
}
|
||||||
|
|
||||||
public async activateLicenseKey(key: string) {
|
public async activateLicenseKey(key: string) {
|
||||||
|
// Encrypt the license key before storing
|
||||||
|
const encryptedKey = encrypt(key, this.serverSecret);
|
||||||
|
|
||||||
const [existingKey] = await db
|
const [existingKey] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(licenseKey)
|
.from(licenseKey)
|
||||||
.where(eq(licenseKey.licenseKeyId, key))
|
.where(eq(licenseKey.licenseKeyId, encryptedKey))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingKey) {
|
if (existingKey) {
|
||||||
|
@ -380,11 +417,15 @@ LQIDAQAB
|
||||||
throw new Error("Invalid license key");
|
throw new Error("Invalid license key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const encryptedToken = encrypt(licenseKeyRes, this.serverSecret);
|
||||||
|
// Encrypt the instanceId before storing
|
||||||
|
const encryptedInstanceId = encrypt(instanceId!, this.serverSecret);
|
||||||
|
|
||||||
// Store the license key in the database
|
// Store the license key in the database
|
||||||
await db.insert(licenseKey).values({
|
await db.insert(licenseKey).values({
|
||||||
licenseKeyId: key,
|
licenseKeyId: encryptedKey,
|
||||||
token: licenseKeyRes,
|
token: encryptedToken,
|
||||||
instanceId: instanceId!
|
instanceId: encryptedInstanceId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw Error(`Error validating license key: ${error}`);
|
throw Error(`Error validating license key: ${error}`);
|
||||||
|
@ -400,13 +441,21 @@ LQIDAQAB
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
}[]
|
}[]
|
||||||
): Promise<ValidateLicenseAPIResponse> {
|
): Promise<ValidateLicenseAPIResponse> {
|
||||||
|
// Decrypt the instanceIds before sending to the server
|
||||||
|
const decryptedKeys = keys.map((key) => ({
|
||||||
|
licenseKey: key.licenseKey,
|
||||||
|
instanceId: key.instanceId
|
||||||
|
? decrypt(key.instanceId, this.serverSecret)
|
||||||
|
: key.instanceId
|
||||||
|
}));
|
||||||
|
|
||||||
const response = await fetch(this.validationServerUrl, {
|
const response = await fetch(this.validationServerUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
licenseKeys: keys,
|
licenseKeys: decryptedKeys,
|
||||||
ephemeralKey: this.ephemeralKey,
|
ephemeralKey: this.ephemeralKey,
|
||||||
instanceName: this.hostId
|
instanceName: this.hostId
|
||||||
})
|
})
|
||||||
|
@ -418,6 +467,8 @@ LQIDAQAB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setHostMeta();
|
||||||
|
|
||||||
const [info] = await db.select().from(hostMeta).limit(1);
|
const [info] = await db.select().from(hostMeta).limit(1);
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
|
|
|
@ -16,3 +16,7 @@ export * from "./verifyUserInRole";
|
||||||
export * from "./verifyAccessTokenAccess";
|
export * from "./verifyAccessTokenAccess";
|
||||||
export * from "./verifyUserIsServerAdmin";
|
export * from "./verifyUserIsServerAdmin";
|
||||||
export * from "./verifyIsLoggedInUser";
|
export * from "./verifyIsLoggedInUser";
|
||||||
|
export * from "./integration";
|
||||||
|
export * from "./verifyValidLicense";
|
||||||
|
export * from "./verifyUserHasAction";
|
||||||
|
export * from "./verifyApiKeyAccess";
|
||||||
|
|
17
server/middlewares/integration/index.ts
Normal file
17
server/middlewares/integration/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
export * from "./verifyApiKey";
|
||||||
|
export * from "./verifyApiKeyOrgAccess";
|
||||||
|
export * from "./verifyApiKeyHasAction";
|
||||||
|
export * from "./verifyApiKeySiteAccess";
|
||||||
|
export * from "./verifyApiKeyResourceAccess";
|
||||||
|
export * from "./verifyApiKeyTargetAccess";
|
||||||
|
export * from "./verifyApiKeyRoleAccess";
|
||||||
|
export * from "./verifyApiKeyUserAccess";
|
||||||
|
export * from "./verifyApiKeySetResourceUsers";
|
||||||
|
export * from "./verifyAccessTokenAccess";
|
||||||
|
export * from "./verifyApiKeyIsRoot";
|
||||||
|
export * from "./verifyApiKeyApiKeyAccess";
|
115
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
115
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceAccessToken, resources, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyAccessTokenAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const accessTokenId = req.params.accessTokenId;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [accessToken] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceAccessToken)
|
||||||
|
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Access token with ID ${accessTokenId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceId = accessToken.resourceId;
|
||||||
|
|
||||||
|
if (!resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Access token with ID ${accessTokenId} does not have a resource ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API key is linked to the resource's organization
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
const apiKeyOrgResult = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, resource.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (apiKeyOrgResult.length > 0) {
|
||||||
|
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (e) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying access token access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
65
server/middlewares/integration/verifyApiKey.ts
Normal file
65
server/middlewares/integration/verifyApiKey.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { apiKeys } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
|
||||||
|
export async function verifyApiKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "API key required")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = authHeader.split(" ")[1]; // Get the token part after "Bearer"
|
||||||
|
const [apiKeyId, apiKeySecret] = key.split(".");
|
||||||
|
|
||||||
|
const [apiKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.apiKeyId, apiKeyId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretHash = apiKey.apiKeyHash;
|
||||||
|
const valid = await verifyPassword(apiKeySecret, secretHash);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.apiKey = apiKey;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred checking API key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
86
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
86
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeys, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyApiKeyAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const {apiKey: callerApiKey } = req;
|
||||||
|
|
||||||
|
const apiKeyId =
|
||||||
|
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||||
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
|
if (!callerApiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [callerApiKeyOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!callerApiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`API key with ID ${apiKeyId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [otherApiKeyOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!otherApiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
`API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying key access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
61
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
61
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { apiKeyActions } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export function verifyApiKeyHasAction(action: ActionsEnum) {
|
||||||
|
return async function (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
if (!req.apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
"API Key not authenticated"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [actionRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId),
|
||||||
|
eq(apiKeyActions.actionId, action)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!actionRes) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have permission perform this action"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error verifying key action access:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying key action access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
44
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
44
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
|
||||||
|
export async function verifyApiKeyIsRoot(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { apiKey } = req;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.isRoot) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have root access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred checking API key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
66
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
66
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function verifyApiKeyOrgAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKeyId = req.apiKey?.apiKeyId;
|
||||||
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
const apiKeyOrgRes = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (e) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying organization access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
90
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
90
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resources, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyResourceAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const resourceId =
|
||||||
|
req.params.resourceId || req.body.resourceId || req.query.resourceId;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Retrieve the resource
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API key is linked to the resource's organization
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
const apiKeyOrgResult = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, resource.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (apiKeyOrgResult.length > 0) {
|
||||||
|
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying resource access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
132
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
132
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { roles, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function verifyApiKeyRoleAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const singleRoleId = parseInt(
|
||||||
|
req.params.roleId || req.body.roleId || req.query.roleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roleIds } = req.body;
|
||||||
|
const allRoleIds =
|
||||||
|
roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
||||||
|
|
||||||
|
if (allRoleIds.length === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesData = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(inArray(roles.roleId, allRoleIds));
|
||||||
|
|
||||||
|
if (rolesData.length !== allRoleIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"One or more roles not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgIds = new Set(rolesData.map((role) => role.orgId));
|
||||||
|
|
||||||
|
for (const role of rolesData) {
|
||||||
|
const apiKeyOrgAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, role.orgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (apiKeyOrgAccess.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
`Key does not have access to organization for role ID ${role.roleId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgIds.size > 1) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Roles must belong to the same organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = orgIds.values().next().value;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Roles do not have an organization ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
// Retrieve the API key's organization link if not already set
|
||||||
|
const apiKeyOrgRes = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (apiKeyOrgRes.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error verifying role access:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying role access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { userOrgs } from "@server/db/schemas";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeySetResourceUsers(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const userIds = req.body.userIds;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userIds) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgId = req.apiKeyOrg.orgId;
|
||||||
|
const userOrgsData = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(userOrgs.userId, userIds),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userOrgsData.length !== userIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to one or more specified users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error checking if key has access to the specified users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
94
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
94
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import {
|
||||||
|
sites,
|
||||||
|
apiKeyOrg
|
||||||
|
} from "@server/db/schemas";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeySiteAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const siteId = parseInt(
|
||||||
|
req.params.siteId || req.body.siteId || req.query.siteId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(siteId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (site.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site[0].orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Site with ID ${siteId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
const apiKeyOrgRes = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, site[0].orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying site access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
117
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
117
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resources, targets, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyTargetAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const targetId = parseInt(req.params.targetId);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(targetId)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select()
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.targetId, targetId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Target with ID ${targetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceId = target.resourceId;
|
||||||
|
if (!resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Target with ID ${targetId} does not have a resource ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resource.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Resource with ID ${resourceId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
const apiKeyOrgResult = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, resource.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (apiKeyOrgResult.length > 0) {
|
||||||
|
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying target access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
72
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
72
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { userOrgs } from "@server/db/schemas";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyUserAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
const reqUserId =
|
||||||
|
req.params.userId || req.body.userId || req.query.userId;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reqUserId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have organization access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = req.apiKeyOrg.orgId;
|
||||||
|
|
||||||
|
const [userOrgRecord] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userOrgRecord) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error checking if key has access to this user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
104
server/middlewares/verifyApiKeyAccess.ts
Normal file
104
server/middlewares/verifyApiKeyAccess.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db/schemas";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
export async function verifyApiKeyAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const apiKeyId =
|
||||||
|
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||||
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [apiKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId))
|
||||||
|
.where(
|
||||||
|
and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKey.apiKeys) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`API key with ID ${apiKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeyOrg.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`API key with ID ${apiKeyId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
const userOrgRole = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.userOrg = userOrgRole[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoleId = req.userOrg.roleId;
|
||||||
|
req.userOrgRoleId = userOrgRoleId;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying key access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
33
server/middlewares/verifyValidLicense.ts
Normal file
33
server/middlewares/verifyValidLicense.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import license from "@server/license/license";
|
||||||
|
|
||||||
|
export async function verifyValidLicense(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const unlocked = await license.isUnlocked();
|
||||||
|
if (!unlocked) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "License is not valid")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (e) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying license"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,5 +12,7 @@ export enum OpenAPITags {
|
||||||
Target = "Target",
|
Target = "Target",
|
||||||
Rule = "Rule",
|
Rule = "Rule",
|
||||||
AccessToken = "Access Token",
|
AccessToken = "Access Token",
|
||||||
Idp = "Identity Provider"
|
Idp = "Identity Provider",
|
||||||
|
Client = "Client",
|
||||||
|
ApiKey = "API Key"
|
||||||
}
|
}
|
||||||
|
|
133
server/routers/apiKeys/createOrgApiKey.ts
Normal file
133
server/routers/apiKeys/createOrgApiKey.ts
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import db from "@server/db";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiKeyOrg, apiKeys } from "@server/db/schemas";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import moment from "moment";
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateIdFromEntropySize
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOrgApiKeyBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type CreateOrgApiKeyResponse = {
|
||||||
|
apiKeyId: string;
|
||||||
|
name: string;
|
||||||
|
apiKey: string;
|
||||||
|
lastChars: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/org/{orgId}/api-key",
|
||||||
|
description: "Create a new API key scoped to the organization.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.ApiKey],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createOrgApiKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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 { orgId } = parsedParams.data;
|
||||||
|
const { name } = parsedBody.data;
|
||||||
|
|
||||||
|
const apiKeyId = generateId(15);
|
||||||
|
const apiKey = generateIdFromEntropySize(25);
|
||||||
|
const apiKeyHash = await hashPassword(apiKey);
|
||||||
|
const lastChars = apiKey.slice(-4);
|
||||||
|
const createdAt = moment().toISOString();
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.insert(apiKeys).values({
|
||||||
|
name,
|
||||||
|
apiKeyId,
|
||||||
|
apiKeyHash,
|
||||||
|
createdAt,
|
||||||
|
lastChars
|
||||||
|
});
|
||||||
|
|
||||||
|
await trx.insert(apiKeyOrg).values({
|
||||||
|
apiKeyId,
|
||||||
|
orgId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return response<CreateOrgApiKeyResponse>(res, {
|
||||||
|
data: {
|
||||||
|
apiKeyId,
|
||||||
|
apiKey,
|
||||||
|
name,
|
||||||
|
lastChars,
|
||||||
|
createdAt
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key created",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create API key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
105
server/routers/apiKeys/createRootApiKey.ts
Normal file
105
server/routers/apiKeys/createRootApiKey.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import db from "@server/db";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiKeyOrg, apiKeys, orgs } from "@server/db/schemas";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import moment from "moment";
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateIdFromEntropySize
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateRootApiKeyBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type CreateRootApiKeyResponse = {
|
||||||
|
apiKeyId: string;
|
||||||
|
name: string;
|
||||||
|
apiKey: string;
|
||||||
|
lastChars: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createRootApiKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = parsedBody.data;
|
||||||
|
|
||||||
|
const apiKeyId = generateId(15);
|
||||||
|
const apiKey = generateIdFromEntropySize(25);
|
||||||
|
const apiKeyHash = await hashPassword(apiKey);
|
||||||
|
const lastChars = apiKey.slice(-4);
|
||||||
|
const createdAt = moment().toISOString();
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.insert(apiKeys).values({
|
||||||
|
apiKeyId,
|
||||||
|
name,
|
||||||
|
apiKeyHash,
|
||||||
|
createdAt,
|
||||||
|
lastChars,
|
||||||
|
isRoot: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const allOrgs = await trx.select().from(orgs);
|
||||||
|
|
||||||
|
for (const org of allOrgs) {
|
||||||
|
await trx.insert(apiKeyOrg).values({
|
||||||
|
apiKeyId,
|
||||||
|
orgId: org.orgId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return response<CreateRootApiKeyResponse>(res, {
|
||||||
|
data: {
|
||||||
|
apiKeyId,
|
||||||
|
name,
|
||||||
|
apiKey,
|
||||||
|
lastChars,
|
||||||
|
createdAt
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key created",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create API key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
81
server/routers/apiKeys/deleteApiKey.ts
Normal file
81
server/routers/apiKeys/deleteApiKey.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeys } 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";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/org/{orgId}/api-key/{apiKeyId}",
|
||||||
|
description: "Delete an API key.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.ApiKey],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteApiKey(
|
||||||
|
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 { apiKeyId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [apiKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.apiKeyId, apiKeyId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`API Key with ID ${apiKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
104
server/routers/apiKeys/deleteOrgApiKey.ts
Normal file
104
server/routers/apiKeys/deleteOrgApiKey.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeyOrg, apiKeys } from "@server/db/schemas";
|
||||||
|
import { and, 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";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty(),
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteOrgApiKey(
|
||||||
|
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 { apiKeyId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [apiKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.apiKeyId, apiKeyId))
|
||||||
|
.innerJoin(
|
||||||
|
apiKeyOrg,
|
||||||
|
and(
|
||||||
|
eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`API Key with ID ${apiKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey.apiKeys.isRoot) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Cannot delete root API key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiKeyOrgs = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(eq(apiKeyOrg.apiKeyId, apiKeyId));
|
||||||
|
|
||||||
|
if (apiKeyOrgs.length === 0) {
|
||||||
|
await trx.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API removed from organization",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
81
server/routers/apiKeys/getApiKey.ts
Normal file
81
server/routers/apiKeys/getApiKey.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeys } 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";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(apiKeyId: string) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
apiKeyId: apiKeys.apiKeyId,
|
||||||
|
lastChars: apiKeys.lastChars,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
isRoot: apiKeys.isRoot,
|
||||||
|
name: apiKeys.name
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.apiKeyId, apiKeyId))
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetApiKeyResponse = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof query>>[0]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function getApiKey(
|
||||||
|
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 { apiKeyId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [apiKey] = await query(apiKeyId);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`API Key with ID ${apiKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetApiKeyResponse>(res, {
|
||||||
|
data: apiKey,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
16
server/routers/apiKeys/index.ts
Normal file
16
server/routers/apiKeys/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
export * from "./createRootApiKey";
|
||||||
|
export * from "./deleteApiKey";
|
||||||
|
export * from "./getApiKey";
|
||||||
|
export * from "./listApiKeyActions";
|
||||||
|
export * from "./listOrgApiKeys";
|
||||||
|
export * from "./listApiKeyActions";
|
||||||
|
export * from "./listRootApiKeys";
|
||||||
|
export * from "./setApiKeyActions";
|
||||||
|
export * from "./setApiKeyOrgs";
|
||||||
|
export * from "./createOrgApiKey";
|
||||||
|
export * from "./deleteOrgApiKey";
|
118
server/routers/apiKeys/listApiKeyActions.ts
Normal file
118
server/routers/apiKeys/listApiKeyActions.ts
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = 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())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryActions(apiKeyId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
actionId: actions.actionId
|
||||||
|
})
|
||||||
|
.from(apiKeyActions)
|
||||||
|
.where(eq(apiKeyActions.apiKeyId, apiKeyId))
|
||||||
|
.innerJoin(actions, eq(actions.actionId, apiKeyActions.actionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListApiKeyActionsResponse = {
|
||||||
|
actions: Awaited<ReturnType<typeof queryActions>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/api-key/{apiKeyId}/actions",
|
||||||
|
description:
|
||||||
|
"List all actions set for an API key.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.ApiKey],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: querySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listApiKeyActions(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
const { apiKeyId } = parsedParams.data;
|
||||||
|
|
||||||
|
const baseQuery = queryActions(apiKeyId);
|
||||||
|
|
||||||
|
const actionsList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
|
||||||
|
return response<ListApiKeyActionsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
actions: actionsList,
|
||||||
|
pagination: {
|
||||||
|
total: actionsList.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API keys retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
121
server/routers/apiKeys/listOrgApiKeys.ts
Normal file
121
server/routers/apiKeys/listOrgApiKeys.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeyOrg, apiKeys } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const querySchema = 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())
|
||||||
|
});
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryApiKeys(orgId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
apiKeyId: apiKeys.apiKeyId,
|
||||||
|
orgId: apiKeyOrg.orgId,
|
||||||
|
lastChars: apiKeys.lastChars,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
name: apiKeys.name
|
||||||
|
})
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(and(eq(apiKeyOrg.orgId, orgId), eq(apiKeys.isRoot, false)))
|
||||||
|
.innerJoin(apiKeys, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListOrgApiKeysResponse = {
|
||||||
|
apiKeys: Awaited<ReturnType<typeof queryApiKeys>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/api-keys",
|
||||||
|
description: "List all API keys for an organization",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.ApiKey],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: querySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listOrgApiKeys(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const baseQuery = queryApiKeys(orgId);
|
||||||
|
|
||||||
|
const apiKeysList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
|
||||||
|
return response<ListOrgApiKeysResponse>(res, {
|
||||||
|
data: {
|
||||||
|
apiKeys: apiKeysList,
|
||||||
|
pagination: {
|
||||||
|
total: apiKeysList.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API keys retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
90
server/routers/apiKeys/listRootApiKeys.ts
Normal file
90
server/routers/apiKeys/listRootApiKeys.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeys } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const querySchema = 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())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryApiKeys() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
apiKeyId: apiKeys.apiKeyId,
|
||||||
|
lastChars: apiKeys.lastChars,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
name: apiKeys.name
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.isRoot, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListRootApiKeysResponse = {
|
||||||
|
apiKeys: Awaited<ReturnType<typeof queryApiKeys>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listRootApiKeys(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const baseQuery = queryApiKeys();
|
||||||
|
|
||||||
|
const apiKeysList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
|
||||||
|
return response<ListRootApiKeysResponse>(res, {
|
||||||
|
data: {
|
||||||
|
apiKeys: apiKeysList,
|
||||||
|
pagination: {
|
||||||
|
total: apiKeysList.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API keys retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
141
server/routers/apiKeys/setApiKeyActions.ts
Normal file
141
server/routers/apiKeys/setApiKeyActions.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { actions, apiKeyActions } from "@server/db/schemas";
|
||||||
|
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 { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
actionIds: z
|
||||||
|
.array(z.string().nonempty())
|
||||||
|
.transform((v) => Array.from(new Set(v)))
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/org/{orgId}/api-key/{apiKeyId}/actions",
|
||||||
|
description:
|
||||||
|
"Set actions for an API key. This will replace any existing actions.",
|
||||||
|
tags: [OpenAPITags.Org, OpenAPITags.ApiKey],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setApiKeyActions(
|
||||||
|
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 { actionIds: newActionIds } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKeyId } = parsedParams.data;
|
||||||
|
|
||||||
|
const actionsExist = await db
|
||||||
|
.select()
|
||||||
|
.from(actions)
|
||||||
|
.where(inArray(actions.actionId, newActionIds));
|
||||||
|
|
||||||
|
if (actionsExist.length !== newActionIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"One or more actions do not exist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const existingActions = await trx
|
||||||
|
.select()
|
||||||
|
.from(apiKeyActions)
|
||||||
|
.where(eq(apiKeyActions.apiKeyId, apiKeyId));
|
||||||
|
|
||||||
|
const existingActionIds = existingActions.map((a) => a.actionId);
|
||||||
|
|
||||||
|
const actionIdsToAdd = newActionIds.filter(
|
||||||
|
(id) => !existingActionIds.includes(id)
|
||||||
|
);
|
||||||
|
const actionIdsToRemove = existingActionIds.filter(
|
||||||
|
(id) => !newActionIds.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (actionIdsToRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(apiKeyActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyActions.apiKeyId, apiKeyId),
|
||||||
|
inArray(apiKeyActions.actionId, actionIdsToRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionIdsToAdd.length > 0) {
|
||||||
|
const insertValues = actionIdsToAdd.map((actionId) => ({
|
||||||
|
apiKeyId,
|
||||||
|
actionId
|
||||||
|
}));
|
||||||
|
await trx.insert(apiKeyActions).values(insertValues);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key actions updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
122
server/routers/apiKeys/setApiKeyOrgs.ts
Normal file
122
server/routers/apiKeys/setApiKeyOrgs.ts
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { apiKeyOrg, orgs } from "@server/db/schemas";
|
||||||
|
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 { eq, and, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
orgIds: z
|
||||||
|
.array(z.string().nonempty())
|
||||||
|
.transform((v) => Array.from(new Set(v)))
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
apiKeyId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function setApiKeyOrgs(
|
||||||
|
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 { orgIds: newOrgIds } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKeyId } = parsedParams.data;
|
||||||
|
|
||||||
|
// make sure all orgs exist
|
||||||
|
const allOrgs = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, newOrgIds));
|
||||||
|
|
||||||
|
if (allOrgs.length !== newOrgIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"One or more orgs do not exist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const existingOrgs = await trx
|
||||||
|
.select({ orgId: apiKeyOrg.orgId })
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(eq(apiKeyOrg.apiKeyId, apiKeyId));
|
||||||
|
|
||||||
|
const existingOrgIds = existingOrgs.map((a) => a.orgId);
|
||||||
|
|
||||||
|
const orgIdsToAdd = newOrgIds.filter(
|
||||||
|
(id) => !existingOrgIds.includes(id)
|
||||||
|
);
|
||||||
|
const orgIdsToRemove = existingOrgIds.filter(
|
||||||
|
(id) => !newOrgIds.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgIdsToRemove.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKeyId),
|
||||||
|
inArray(apiKeyOrg.orgId, orgIdsToRemove)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgIdsToAdd.length > 0) {
|
||||||
|
const insertValues = orgIdsToAdd.map((orgId) => ({
|
||||||
|
apiKeyId,
|
||||||
|
orgId
|
||||||
|
}));
|
||||||
|
await trx.insert(apiKeyOrg).values(insertValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "API key orgs updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import * as supporterKey from "./supporterKey";
|
||||||
import * as accessToken from "./accessToken";
|
import * as accessToken from "./accessToken";
|
||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
import * as license from "./license";
|
import * as license from "./license";
|
||||||
|
import * as apiKeys from "./apiKeys";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
|
@ -27,7 +28,9 @@ import {
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
getUserOrgs,
|
getUserOrgs,
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
verifyIsLoggedInUser
|
verifyIsLoggedInUser,
|
||||||
|
verifyApiKeyAccess,
|
||||||
|
verifyValidLicense
|
||||||
} 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";
|
||||||
|
@ -522,6 +525,38 @@ authenticated.post(
|
||||||
|
|
||||||
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||||
|
|
||||||
|
authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps);
|
||||||
|
|
||||||
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
idp.createIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
idp.updateIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
idp.deleteIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/idp/:idpId/org",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
idp.listIdpOrgPolicies
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
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);
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
|
|
||||||
|
@ -549,6 +584,100 @@ authenticated.post(
|
||||||
license.recheckStatus
|
license.recheckStatus
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/api-key/:apiKeyId`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.getApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
`/api-key`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.createRootApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/api-key/:apiKeyId`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.deleteApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/api-keys`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.listRootApiKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/api-key/:apiKeyId/actions`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.listApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/api-key/:apiKeyId/actions`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
apiKeys.setApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/api-keys`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listApiKeys),
|
||||||
|
apiKeys.listOrgApiKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyApiKeyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
||||||
|
apiKeys.setApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyApiKeyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listApiKeyActions),
|
||||||
|
apiKeys.listApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
`/org/:orgId/api-key`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createApiKey),
|
||||||
|
apiKeys.createOrgApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyApiKeyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteApiKey),
|
||||||
|
apiKeys.deleteOrgApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId`,
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyApiKeyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getApiKey),
|
||||||
|
apiKeys.getApiKey
|
||||||
|
);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|
129
server/routers/idp/createIdpOrgPolicy.ts
Normal file
129
server/routers/idp/createIdpOrgPolicy.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
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 config from "@server/lib/config";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { idp, idpOrg } from "@server/db/schemas";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
idpId: z.coerce.number(),
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
orgMapping: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateIdpOrgPolicyResponse = {};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/idp/{idpId}/org/{orgId}",
|
||||||
|
description: "Create an IDP policy for an existing IDP on an organization.",
|
||||||
|
tags: [OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createIdpOrgPolicy(
|
||||||
|
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 { idpId, orgId } = parsedParams.data;
|
||||||
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.leftJoin(
|
||||||
|
idpOrg,
|
||||||
|
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||||
|
)
|
||||||
|
.where(eq(idp.idpId, idpId));
|
||||||
|
|
||||||
|
if (!existing?.idp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"An IDP with this ID does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.idpOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"An IDP org policy already exists."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(idpOrg).values({
|
||||||
|
idpId,
|
||||||
|
orgId,
|
||||||
|
roleMapping,
|
||||||
|
orgMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
return response<CreateIdpOrgPolicyResponse>(res, {
|
||||||
|
data: {},
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
@ -67,6 +67,11 @@ export async function deleteIdp(
|
||||||
.delete(idpOidcConfig)
|
.delete(idpOidcConfig)
|
||||||
.where(eq(idpOidcConfig.idpId, idpId));
|
.where(eq(idpOidcConfig.idpId, idpId));
|
||||||
|
|
||||||
|
// Delete IDP-org mappings
|
||||||
|
await trx
|
||||||
|
.delete(idpOrg)
|
||||||
|
.where(eq(idpOrg.idpId, idpId));
|
||||||
|
|
||||||
// Delete the IDP itself
|
// Delete the IDP itself
|
||||||
await trx
|
await trx
|
||||||
.delete(idp)
|
.delete(idp)
|
||||||
|
|
95
server/routers/idp/deleteIdpOrgPolicy.ts
Normal file
95
server/routers/idp/deleteIdpOrgPolicy.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
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, idpOrg } from "@server/db/schemas";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
idpId: z.coerce.number(),
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/idp/{idpId}/org/{orgId}",
|
||||||
|
description: "Create an OIDC IdP for an organization.",
|
||||||
|
tags: [OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteIdpOrgPolicy(
|
||||||
|
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, orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.leftJoin(idpOrg, eq(idpOrg.orgId, orgId))
|
||||||
|
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing.idp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"An IDP with this ID does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.idpOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"A policy for this IDP and org does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(idpOrg)
|
||||||
|
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||||
|
|
||||||
|
return response<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Policy deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as arctic from "arctic";
|
import * as arctic from "arctic";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
|
@ -27,6 +27,10 @@ const bodySchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
|
return url.endsWith('/') ? url : `${url}/`;
|
||||||
|
};
|
||||||
|
|
||||||
export type GenerateOidcUrlResponse = {
|
export type GenerateOidcUrlResponse = {
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
};
|
};
|
||||||
|
@ -106,12 +110,13 @@ export async function generateOidcUrl(
|
||||||
const codeVerifier = arctic.generateCodeVerifier();
|
const codeVerifier = arctic.generateCodeVerifier();
|
||||||
const state = arctic.generateState();
|
const state = arctic.generateState();
|
||||||
const url = client.createAuthorizationURLWithPKCE(
|
const url = client.createAuthorizationURLWithPKCE(
|
||||||
existingIdp.idpOidcConfig.authUrl,
|
ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl),
|
||||||
state,
|
state,
|
||||||
arctic.CodeChallengeMethod.S256,
|
arctic.CodeChallengeMethod.S256,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
parsedScopes
|
parsedScopes
|
||||||
);
|
);
|
||||||
|
logger.debug("Generated OIDC URL", { url });
|
||||||
|
|
||||||
const stateJwt = jsonwebtoken.sign(
|
const stateJwt = jsonwebtoken.sign(
|
||||||
{
|
{
|
||||||
|
|
|
@ -5,3 +5,7 @@ export * from "./listIdps";
|
||||||
export * from "./generateOidcUrl";
|
export * from "./generateOidcUrl";
|
||||||
export * from "./validateOidcCallback";
|
export * from "./validateOidcCallback";
|
||||||
export * from "./getIdp";
|
export * from "./getIdp";
|
||||||
|
export * from "./createIdpOrgPolicy";
|
||||||
|
export * from "./deleteIdpOrgPolicy";
|
||||||
|
export * from "./listIdpOrgPolicies";
|
||||||
|
export * from "./updateIdpOrgPolicy";
|
||||||
|
|
121
server/routers/idp/listIdpOrgPolicies.ts
Normal file
121
server/routers/idp/listIdpOrgPolicies.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { idpOrg } from "@server/db/schemas";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
idpId: z.coerce.number()
|
||||||
|
});
|
||||||
|
|
||||||
|
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(idpId: number, limit: number, offset: number) {
|
||||||
|
const res = await db
|
||||||
|
.select()
|
||||||
|
.from(idpOrg)
|
||||||
|
.where(eq(idpOrg.idpId, idpId))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListIdpOrgPoliciesResponse = {
|
||||||
|
policies: NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/idp/{idpId}/org",
|
||||||
|
description: "List all org policies on an IDP.",
|
||||||
|
tags: [OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: querySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listIdpOrgPolicies(
|
||||||
|
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 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(idpId, limit, offset);
|
||||||
|
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(idpOrg)
|
||||||
|
.where(eq(idpOrg.idpId, idpId));
|
||||||
|
|
||||||
|
return response<ListIdpOrgPoliciesResponse>(res, {
|
||||||
|
data: {
|
||||||
|
policies: list,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Policies retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { idp } from "@server/db/schemas";
|
import { domains, idp, orgDomains, users, idpOrg } 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";
|
||||||
|
@ -33,8 +33,10 @@ async function query(limit: number, offset: number) {
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
type: idp.type,
|
type: idp.type,
|
||||||
|
orgCount: sql<number>`count(${idpOrg.orgId})`
|
||||||
})
|
})
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||||
.groupBy(idp.idpId)
|
.groupBy(idp.idpId)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
@ -46,6 +48,7 @@ export type ListIdpsResponse = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
orgCount: number;
|
||||||
}>;
|
}>;
|
||||||
pagination: {
|
pagination: {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
233
server/routers/idp/oidcAutoProvision.ts
Normal file
233
server/routers/idp/oidcAutoProvision.ts
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
generateId,
|
||||||
|
generateSessionToken,
|
||||||
|
serializeSessionCookie
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import jmespath from "jmespath";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
export async function oidcAutoProvision({
|
||||||
|
idp,
|
||||||
|
claims,
|
||||||
|
existingUser,
|
||||||
|
userIdentifier,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
req,
|
||||||
|
res
|
||||||
|
}: {
|
||||||
|
idp: Idp;
|
||||||
|
claims: any;
|
||||||
|
existingUser?: User;
|
||||||
|
userIdentifier: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
req: Request;
|
||||||
|
res: Response;
|
||||||
|
}) {
|
||||||
|
const allOrgs = await db.select().from(orgs);
|
||||||
|
|
||||||
|
const defaultRoleMapping = idp.defaultRoleMapping;
|
||||||
|
const defaultOrgMapping = idp.defaultOrgMapping;
|
||||||
|
|
||||||
|
let userOrgInfo: { orgId: string; roleId: number }[] = [];
|
||||||
|
for (const org of allOrgs) {
|
||||||
|
const [idpOrgRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(idpOrg)
|
||||||
|
.where(
|
||||||
|
and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
let roleId: number | undefined = undefined;
|
||||||
|
|
||||||
|
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
|
||||||
|
const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId);
|
||||||
|
|
||||||
|
if (hydratedOrgMapping) {
|
||||||
|
logger.debug("Hydrated Org Mapping", {
|
||||||
|
hydratedOrgMapping
|
||||||
|
});
|
||||||
|
const orgId = jmespath.search(claims, hydratedOrgMapping);
|
||||||
|
logger.debug("Extraced Org ID", { orgId });
|
||||||
|
if (orgId !== true && orgId !== org.orgId) {
|
||||||
|
// user not allowed to access this org
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping;
|
||||||
|
if (roleMapping) {
|
||||||
|
logger.debug("Role Mapping", { roleMapping });
|
||||||
|
const roleName = jmespath.search(claims, roleMapping);
|
||||||
|
|
||||||
|
if (!roleName) {
|
||||||
|
logger.error("Role name not found in the ID token", {
|
||||||
|
roleName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [roleRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(
|
||||||
|
and(eq(roles.orgId, org.orgId), eq(roles.name, roleName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roleRes) {
|
||||||
|
logger.error("Role not found", {
|
||||||
|
orgId: org.orgId,
|
||||||
|
roleName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
roleId = roleRes.roleId;
|
||||||
|
|
||||||
|
userOrgInfo.push({
|
||||||
|
orgId: org.orgId,
|
||||||
|
roleId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("User org info", { userOrgInfo });
|
||||||
|
|
||||||
|
let existingUserId = existingUser?.userId;
|
||||||
|
|
||||||
|
// sync the user with the orgs and roles
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
let userId = existingUser?.userId;
|
||||||
|
|
||||||
|
// create user if not exists
|
||||||
|
if (!existingUser) {
|
||||||
|
userId = generateId(15);
|
||||||
|
|
||||||
|
await trx.insert(users).values({
|
||||||
|
userId,
|
||||||
|
username: userIdentifier,
|
||||||
|
email: email || null,
|
||||||
|
name: name || null,
|
||||||
|
type: UserType.OIDC,
|
||||||
|
idpId: idp.idpId,
|
||||||
|
emailVerified: true, // OIDC users are always verified
|
||||||
|
dateCreated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// set the name and email
|
||||||
|
await trx
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
username: userIdentifier,
|
||||||
|
email: email || null,
|
||||||
|
name: name || null
|
||||||
|
})
|
||||||
|
.where(eq(users.userId, userId!));
|
||||||
|
}
|
||||||
|
|
||||||
|
existingUserId = userId;
|
||||||
|
|
||||||
|
// get all current user orgs
|
||||||
|
const currentUserOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.userId, userId!));
|
||||||
|
|
||||||
|
// Delete orgs that are no longer valid
|
||||||
|
const orgsToDelete = currentUserOrgs.filter(
|
||||||
|
(currentOrg) =>
|
||||||
|
!userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsToDelete.length > 0) {
|
||||||
|
await trx.delete(userOrgs).where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId!),
|
||||||
|
inArray(
|
||||||
|
userOrgs.orgId,
|
||||||
|
orgsToDelete.map((org) => org.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update roles for existing orgs where the role has changed
|
||||||
|
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
|
||||||
|
const newOrg = userOrgInfo.find(
|
||||||
|
(newOrg) => newOrg.orgId === currentOrg.orgId
|
||||||
|
);
|
||||||
|
return newOrg && newOrg.roleId !== currentOrg.roleId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orgsToUpdate.length > 0) {
|
||||||
|
for (const org of orgsToUpdate) {
|
||||||
|
const newRole = userOrgInfo.find(
|
||||||
|
(newOrg) => newOrg.orgId === org.orgId
|
||||||
|
);
|
||||||
|
if (newRole) {
|
||||||
|
await trx
|
||||||
|
.update(userOrgs)
|
||||||
|
.set({ roleId: newRole.roleId })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId!),
|
||||||
|
eq(userOrgs.orgId, org.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new orgs that don't exist yet
|
||||||
|
const orgsToAdd = userOrgInfo.filter(
|
||||||
|
(newOrg) =>
|
||||||
|
!currentUserOrgs.some(
|
||||||
|
(currentOrg) => currentOrg.orgId === newOrg.orgId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgsToAdd.length > 0) {
|
||||||
|
await trx.insert(userOrgs).values(
|
||||||
|
orgsToAdd.map((org) => ({
|
||||||
|
userId: userId!,
|
||||||
|
orgId: org.orgId,
|
||||||
|
roleId: org.roleId,
|
||||||
|
dateCreated: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = generateSessionToken();
|
||||||
|
const sess = await createSession(token, existingUserId!);
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
const cookie = serializeSessionCookie(
|
||||||
|
token,
|
||||||
|
isSecure,
|
||||||
|
new Date(sess.expiresAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateOrgMapping(
|
||||||
|
orgMapping: string | null,
|
||||||
|
orgId: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!orgMapping) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return orgMapping.split("{{orgId}}").join(orgId);
|
||||||
|
}
|
131
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
131
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
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 { eq, and } from "drizzle-orm";
|
||||||
|
import { idp, idpOrg } from "@server/db/schemas";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
idpId: z.coerce.number(),
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
orgMapping: z.string().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type UpdateIdpOrgPolicyResponse = {};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/idp/{idpId}/org/{orgId}",
|
||||||
|
description: "Update an IDP org policy.",
|
||||||
|
tags: [OpenAPITags.Idp],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateIdpOrgPolicy(
|
||||||
|
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, orgId } = parsedParams.data;
|
||||||
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
// Check if IDP and policy exist
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.leftJoin(
|
||||||
|
idpOrg,
|
||||||
|
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||||
|
)
|
||||||
|
.where(eq(idp.idpId, idpId));
|
||||||
|
|
||||||
|
if (!existing?.idp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"An IDP with this ID does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.idpOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"A policy for this IDP and org does not exist."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the policy
|
||||||
|
await db
|
||||||
|
.update(idpOrg)
|
||||||
|
.set({
|
||||||
|
roleMapping,
|
||||||
|
orgMapping
|
||||||
|
})
|
||||||
|
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||||
|
|
||||||
|
return response<UpdateIdpOrgPolicyResponse>(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Policy updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,7 +42,7 @@ export type UpdateIdpResponse = {
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/idp/:idpId/oidc",
|
path: "/idp/{idpId}/oidc",
|
||||||
description: "Update an OIDC IdP.",
|
description: "Update an OIDC IdP.",
|
||||||
tags: [OpenAPITags.Idp],
|
tags: [OpenAPITags.Idp],
|
||||||
request: {
|
request: {
|
||||||
|
|
|
@ -1,24 +1,30 @@
|
||||||
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 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 logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { idp, idpOidcConfig, users } from "@server/db/schemas";
|
import { idp, idpOidcConfig, users } from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import * as arctic from "arctic";
|
import * as arctic from "arctic";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import jmespath from "jmespath";
|
import jmespath from "jmespath";
|
||||||
import jsonwebtoken from "jsonwebtoken";
|
import jsonwebtoken from "jsonwebtoken";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
serializeSessionCookie
|
serializeSessionCookie
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
import { response } from "@server/lib";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import { oidcAutoProvision } from "./oidcAutoProvision";
|
||||||
|
import license from "@server/license/license";
|
||||||
|
|
||||||
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
|
return url.endsWith('/') ? url : `${url}/`;
|
||||||
|
};
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -148,7 +154,7 @@ export async function validateOidcCallback(
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await client.validateAuthorizationCode(
|
const tokens = await client.validateAuthorizationCode(
|
||||||
existingIdp.idpOidcConfig.tokenUrl,
|
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
||||||
code,
|
code,
|
||||||
codeVerifier
|
codeVerifier
|
||||||
);
|
);
|
||||||
|
@ -204,12 +210,24 @@ export async function validateOidcCallback(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIdp.idp.autoProvision) {
|
if (existingIdp.idp.autoProvision) {
|
||||||
|
if (!(await license.isUnlocked())) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.FORBIDDEN,
|
||||||
"Auto provisioning is not supported"
|
"Auto-provisioning is not available"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
await oidcAutoProvision({
|
||||||
|
idp: existingIdp.idp,
|
||||||
|
userIdentifier,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
claims,
|
||||||
|
existingUser,
|
||||||
|
req,
|
||||||
|
res
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return next(
|
return next(
|
||||||
|
|
499
server/routers/integration.ts
Normal file
499
server/routers/integration.ts
Normal file
|
@ -0,0 +1,499 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import * as site from "./site";
|
||||||
|
import * as org from "./org";
|
||||||
|
import * as resource from "./resource";
|
||||||
|
import * as domain from "./domain";
|
||||||
|
import * as target from "./target";
|
||||||
|
import * as user from "./user";
|
||||||
|
import * as role from "./role";
|
||||||
|
// import * as client from "./client";
|
||||||
|
import * as accessToken from "./accessToken";
|
||||||
|
import * as apiKeys from "./apiKeys";
|
||||||
|
import * as idp from "./idp";
|
||||||
|
import {
|
||||||
|
verifyApiKey,
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction,
|
||||||
|
verifyApiKeySiteAccess,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyTargetAccess,
|
||||||
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyApiKeyUserAccess,
|
||||||
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyApiKeyAccessTokenAccess,
|
||||||
|
verifyApiKeyIsRoot
|
||||||
|
} from "@server/middlewares";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
|
||||||
|
export const unauthenticated = Router();
|
||||||
|
|
||||||
|
unauthenticated.get("/", (_, res) => {
|
||||||
|
res.status(HttpCode.OK).json({ message: "Healthy" });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authenticated = Router();
|
||||||
|
authenticated.use(verifyApiKey);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/checkId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.checkOrgId),
|
||||||
|
org.checkId
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createOrg),
|
||||||
|
org.createOrg
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/orgs",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listOrgs),
|
||||||
|
org.listOrgs
|
||||||
|
); // TODO we need to check the orgs here
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getOrg),
|
||||||
|
org.getOrg
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateOrg),
|
||||||
|
org.updateOrg
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteOrg),
|
||||||
|
org.deleteOrg
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/site",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createSite),
|
||||||
|
site.createSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/sites",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listSites),
|
||||||
|
site.listSites
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/site/:niceId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getSite),
|
||||||
|
site.getSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/pick-site-defaults",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createSite),
|
||||||
|
site.pickSiteDefaults
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId",
|
||||||
|
verifyApiKeySiteAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getSite),
|
||||||
|
site.getSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site/:siteId",
|
||||||
|
verifyApiKeySiteAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateSite),
|
||||||
|
site.updateSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/site/:siteId",
|
||||||
|
verifyApiKeySiteAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteSite),
|
||||||
|
site.deleteSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/site/:siteId/resource",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||||
|
resource.createResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId/resources",
|
||||||
|
verifyApiKeySiteAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResources),
|
||||||
|
resource.listResources
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resources",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResources),
|
||||||
|
resource.listResources
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/domains",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listOrgDomains),
|
||||||
|
domain.listDomains
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/create-invite",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.inviteUser),
|
||||||
|
user.inviteUser
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/roles",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceRoles),
|
||||||
|
resource.listResourceRoles
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/users",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceUsers),
|
||||||
|
resource.listResourceUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getResource),
|
||||||
|
resource.getResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||||
|
resource.updateResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource/:resourceId",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteResource),
|
||||||
|
resource.deleteResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource/:resourceId/target",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||||
|
target.createTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/targets",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listTargets),
|
||||||
|
target.listTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource/:resourceId/rule",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
|
||||||
|
resource.createResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/rules",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listResourceRules),
|
||||||
|
resource.listResourceRules
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
|
||||||
|
resource.updateResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteResourceRule),
|
||||||
|
resource.deleteResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/target/:targetId",
|
||||||
|
verifyApiKeyTargetAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getTarget),
|
||||||
|
target.getTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/target/:targetId",
|
||||||
|
verifyApiKeyTargetAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateTarget),
|
||||||
|
target.updateTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/target/:targetId",
|
||||||
|
verifyApiKeyTargetAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteTarget),
|
||||||
|
target.deleteTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/role",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createRole),
|
||||||
|
role.createRole
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/roles",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listRoles),
|
||||||
|
role.listRoles
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/role/:roleId",
|
||||||
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteRole),
|
||||||
|
role.deleteRole
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/role/:roleId/add/:userId",
|
||||||
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyApiKeyUserAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.addUserRole),
|
||||||
|
user.addUserRole
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/roles",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
|
resource.setResourceRoles
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/users",
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
|
resource.setResourceUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/password`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
|
||||||
|
resource.setResourcePassword
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/pincode`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
|
||||||
|
resource.setResourcePincode
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/whitelist`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
|
resource.setResourceWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/resource/:resourceId/whitelist`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist),
|
||||||
|
resource.getResourceWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/transfer`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||||
|
resource.transferResource
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/resource/:resourceId/access-token`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
|
||||||
|
accessToken.generateAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/access-token/:accessTokenId`,
|
||||||
|
verifyApiKeyAccessTokenAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteAcessToken),
|
||||||
|
accessToken.deleteAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/access-tokens`,
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listAccessTokens),
|
||||||
|
accessToken.listAccessTokens
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/resource/:resourceId/access-tokens`,
|
||||||
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listAccessTokens),
|
||||||
|
accessToken.listAccessTokens
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user/:userId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getOrgUser),
|
||||||
|
user.getOrgUser
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/users",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listUsers),
|
||||||
|
user.listUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/user/:userId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyUserAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.removeUser),
|
||||||
|
user.removeUserOrg
|
||||||
|
);
|
||||||
|
|
||||||
|
// authenticated.put(
|
||||||
|
// "/newt",
|
||||||
|
// verifyApiKeyHasAction(ActionsEnum.createNewt),
|
||||||
|
// newt.createNewt
|
||||||
|
// );
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/api-keys`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listApiKeys),
|
||||||
|
apiKeys.listOrgApiKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
|
||||||
|
apiKeys.setApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listApiKeyActions),
|
||||||
|
apiKeys.listApiKeyActions
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
`/org/:orgId/api-key`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createApiKey),
|
||||||
|
apiKeys.createOrgApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/org/:orgId/api-key/:apiKeyId`,
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteApiKey),
|
||||||
|
apiKeys.deleteApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/idp/oidc",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||||
|
idp.createOidcIdp
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/idp/:idpId/oidc",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||||
|
idp.updateOidcIdp
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/idp/:idpId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
|
||||||
|
idp.deleteIdp
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/idp",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listIdps),
|
||||||
|
idp.listIdps
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/idp/:idpId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getIdp),
|
||||||
|
idp.getIdp
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
|
||||||
|
idp.createIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
|
||||||
|
idp.updateIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/idp/:idpId/org/:orgId",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg),
|
||||||
|
idp.deleteIdpOrgPolicy
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/idp/:idpId/org",
|
||||||
|
verifyApiKeyIsRoot,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listIdpOrgs),
|
||||||
|
idp.listIdpOrgPolicies
|
||||||
|
);
|
|
@ -14,6 +14,8 @@ import db from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { licenseKey } from "@server/db/schemas";
|
import { licenseKey } from "@server/db/schemas";
|
||||||
import license, { LicenseStatus } from "@server/license/license";
|
import license, { LicenseStatus } from "@server/license/license";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -31,7 +31,7 @@ const listOrgsSchema = z.object({
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/user/:userId/orgs",
|
path: "/user/{userId}/orgs",
|
||||||
description: "List all organizations for a user.",
|
description: "List all organizations for a user.",
|
||||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||||
request: {
|
request: {
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { copyInConfig } from "./copyInConfig";
|
||||||
import { setupServerAdmin } from "./setupServerAdmin";
|
import { setupServerAdmin } from "./setupServerAdmin";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { clearStaleData } from "./clearStaleData";
|
import { clearStaleData } from "./clearStaleData";
|
||||||
import { setHostMeta } from "./setHostMeta";
|
|
||||||
|
|
||||||
export async function runSetupFunctions() {
|
export async function runSetupFunctions() {
|
||||||
try {
|
try {
|
||||||
await setHostMeta();
|
|
||||||
await copyInConfig(); // copy in the config to the db as needed
|
await copyInConfig(); // copy in the config to the db as needed
|
||||||
await setupServerAdmin();
|
await setupServerAdmin();
|
||||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||||
|
|
|
@ -20,6 +20,7 @@ import m16 from "./scripts/1.0.0";
|
||||||
import m17 from "./scripts/1.1.0";
|
import m17 from "./scripts/1.1.0";
|
||||||
import m18 from "./scripts/1.2.0";
|
import m18 from "./scripts/1.2.0";
|
||||||
import m19 from "./scripts/1.3.0";
|
import m19 from "./scripts/1.3.0";
|
||||||
|
import { setHostMeta } from "./setHostMeta";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
|
33
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal file
33
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
addApiKey?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrgApiKeysDataTable<TData, TValue>({
|
||||||
|
addApiKey,
|
||||||
|
columns,
|
||||||
|
data
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
title="API Keys"
|
||||||
|
searchPlaceholder="Search API keys..."
|
||||||
|
searchColumn="name"
|
||||||
|
onAdd={addApiKey}
|
||||||
|
addButtonText="Generate API Key"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
204
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal file
204
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { OrgApiKeysDataTable } from "./OrgApiKeysDataTable";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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 moment from "moment";
|
||||||
|
|
||||||
|
export type OrgApiKeyRow = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrgApiKeyTableProps = {
|
||||||
|
apiKeys: OrgApiKeyRow[];
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrgApiKeysTable({
|
||||||
|
apiKeys,
|
||||||
|
orgId
|
||||||
|
}: OrgApiKeyTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<OrgApiKeyRow | null>(null);
|
||||||
|
const [rows, setRows] = useState<OrgApiKeyRow[]>(apiKeys);
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const deleteSite = (apiKeyId: string) => {
|
||||||
|
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error deleting API key", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error deleting API key",
|
||||||
|
description: formatAxiosError(e, "Error deleting API key")
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
|
||||||
|
const newRows = rows.filter((row) => row.id !== apiKeyId);
|
||||||
|
|
||||||
|
setRows(newRows);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<OrgApiKeyRow>[] = [
|
||||||
|
{
|
||||||
|
id: "dots",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const apiKeyROw = row.original;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(apiKeyROw);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>View settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(apiKeyROw);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "key",
|
||||||
|
header: "Key",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span className="font-mono">{r.key}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "Created At",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||||
|
<Button variant={"outlinePrimary"} className="ml-2">
|
||||||
|
Edit
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selected && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelected(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to remove the API key{" "}
|
||||||
|
<b>{selected?.name || selected?.id}</b> from the
|
||||||
|
organization?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
Once removed, the API key will no longer be
|
||||||
|
able to be used.
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To confirm, please type the name of the API key
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText="Confirm Delete API Key"
|
||||||
|
onConfirm={async () => deleteSite(selected!.id)}
|
||||||
|
string={selected.name}
|
||||||
|
title="Delete API Key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OrgApiKeysDataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
addApiKey={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys/create`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
62
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal file
62
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator
|
||||||
|
} from "@app/components/ui/breadcrumb";
|
||||||
|
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||||
|
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
|
||||||
|
interface SettingsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ apiKeyId: string; orgId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
let apiKey = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetApiKeyResponse>>(
|
||||||
|
`/org/${params.orgId}/api-key/${params.apiKeyId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
apiKey = res.data.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
redirect(`/${params.orgId}/settings/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: "Permissions",
|
||||||
|
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||||
|
|
||||||
|
<ApiKeyProvider apiKey={apiKey}>
|
||||||
|
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||||
|
</ApiKeyProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
13
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal file
13
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage(props: {
|
||||||
|
params: Promise<{ orgId: string; apiKeyId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`);
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { orgId, apiKeyId } = useParams();
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||||
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [loadingSavePermissions, setLoadingSavePermissions] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoadingPage(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListApiKeyActionsResponse>
|
||||||
|
>(`/org/${orgId}/api-key/${apiKeyId}/actions`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error loading API key actions",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"Error loading API key actions"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const data = res.data.data;
|
||||||
|
for (const action of data.actions) {
|
||||||
|
setSelectedPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[action.actionId]: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
setLoadingSavePermissions(true);
|
||||||
|
|
||||||
|
const actionsRes = await api
|
||||||
|
.post(`/org/${orgId}/api-key/${apiKeyId}/actions`, {
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error setting permissions", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error setting permissions",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (actionsRes && actionsRes.status === 200) {
|
||||||
|
toast({
|
||||||
|
title: "Permissions updated",
|
||||||
|
description: "The permissions have been updated."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingSavePermissions(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loadingPage && (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Permissions
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Determine what this API key can do
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<PermissionsSelectBox
|
||||||
|
selectedPermissions={selectedPermissions}
|
||||||
|
onChange={setSelectedPermissions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await savePermissions();
|
||||||
|
}}
|
||||||
|
loading={loadingSavePermissions}
|
||||||
|
disabled={loadingSavePermissions}
|
||||||
|
>
|
||||||
|
Save Permissions
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
412
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal file
412
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator
|
||||||
|
} from "@app/components/ui/breadcrumb";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CreateOrgApiKeyBody,
|
||||||
|
CreateOrgApiKeyResponse
|
||||||
|
} from "@server/routers/apiKeys";
|
||||||
|
import { ApiKey } from "@server/db/schemas";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import moment from "moment";
|
||||||
|
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
|
|
||||||
|
const createFormSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, {
|
||||||
|
message: "Name must be at least 2 characters."
|
||||||
|
})
|
||||||
|
.max(255, {
|
||||||
|
message: "Name must not be longer than 255 characters."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||||
|
|
||||||
|
const copiedFormSchema = z
|
||||||
|
.object({
|
||||||
|
copied: z.boolean()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return data.copied;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "You must confirm that you have copied the API key.",
|
||||||
|
path: ["copied"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { orgId } = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const form = useForm<CreateFormValues>({
|
||||||
|
resolver: zodResolver(createFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const copiedForm = useForm<CopiedFormValues>({
|
||||||
|
resolver: zodResolver(copiedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
copied: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: CreateFormValues) {
|
||||||
|
setCreateLoading(true);
|
||||||
|
|
||||||
|
let payload: CreateOrgApiKeyBody = {
|
||||||
|
name: data.name
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.put<
|
||||||
|
AxiosResponse<CreateOrgApiKeyResponse>
|
||||||
|
>(`/org/${orgId}/api-key/`, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating API key",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const data = res.data.data;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsRes = await api
|
||||||
|
.post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error setting permissions", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error setting permissions",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (actionsRes) {
|
||||||
|
setApiKey(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopiedSubmit(data: CopiedFormValues) {
|
||||||
|
if (!data.copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLabel = (str: string) => {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/^./, (char) => char.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoadingPage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<HeaderTitle
|
||||||
|
title="Generate API Key"
|
||||||
|
description="Generate a new API key for your organization"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See All API Keys
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loadingPage && (
|
||||||
|
<div>
|
||||||
|
<SettingsContainer>
|
||||||
|
{!apiKey && (
|
||||||
|
<>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
API Key Information
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="create-site-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Permissions
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Determine what this API key can do
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<PermissionsSelectBox
|
||||||
|
selectedPermissions={
|
||||||
|
selectedPermissions
|
||||||
|
}
|
||||||
|
onChange={setSelectedPermissions}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Your API Key
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={2}>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Name
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={apiKey.name}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Created
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{moment(
|
||||||
|
apiKey.createdAt
|
||||||
|
).format("lll")}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
Save Your API Key
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You will only be able to see this
|
||||||
|
once. Make sure to copy it to a
|
||||||
|
secure place.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">
|
||||||
|
Your API key is:
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<CopyTextBox
|
||||||
|
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form {...copiedForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="copied-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={copiedForm.control}
|
||||||
|
name="copied"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
defaultChecked={
|
||||||
|
copiedForm.getValues(
|
||||||
|
"copied"
|
||||||
|
) as boolean
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
e
|
||||||
|
) => {
|
||||||
|
copiedForm.setValue(
|
||||||
|
"copied",
|
||||||
|
e as boolean
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
I have copied
|
||||||
|
the API key
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={createLoading}
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
49
src/app/[orgId]/settings/api-keys/page.tsx
Normal file
49
src/app/[orgId]/settings/api-keys/page.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
||||||
|
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||||
|
|
||||||
|
type ApiKeyPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||||
|
`/org/${params.orgId}/api-keys`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
apiKeys = res.data.data.apiKeys;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const rows: OrgApiKeyRow[] = apiKeys.map((key) => {
|
||||||
|
return {
|
||||||
|
name: key.name,
|
||||||
|
id: key.apiKeyId,
|
||||||
|
key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`,
|
||||||
|
createdAt: key.createdAt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Manage API Keys"
|
||||||
|
description="API keys are used to authenticate with the integration API"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
58
src/app/admin/api-keys/ApiKeysDataTable.tsx
Normal file
58
src/app/admin/api-keys/ApiKeysDataTable.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
getPaginationRowModel,
|
||||||
|
SortingState,
|
||||||
|
getSortedRowModel,
|
||||||
|
ColumnFiltersState,
|
||||||
|
getFilteredRowModel
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||||
|
import { Plus, Search } from "lucide-react";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
addApiKey?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeysDataTable<TData, TValue>({
|
||||||
|
addApiKey,
|
||||||
|
columns,
|
||||||
|
data
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
title="API Keys"
|
||||||
|
searchPlaceholder="Search API keys..."
|
||||||
|
searchColumn="name"
|
||||||
|
onAdd={addApiKey}
|
||||||
|
addButtonText="Generate API Key"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
199
src/app/admin/api-keys/ApiKeysTable.tsx
Normal file
199
src/app/admin/api-keys/ApiKeysTable.tsx
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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 moment from "moment";
|
||||||
|
import { ApiKeysDataTable } from "./ApiKeysDataTable";
|
||||||
|
|
||||||
|
export type ApiKeyRow = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiKeyTableProps = {
|
||||||
|
apiKeys: ApiKeyRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<ApiKeyRow | null>(null);
|
||||||
|
const [rows, setRows] = useState<ApiKeyRow[]>(apiKeys);
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const deleteSite = (apiKeyId: string) => {
|
||||||
|
api.delete(`/api-key/${apiKeyId}`)
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error deleting API key", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error deleting API key",
|
||||||
|
description: formatAxiosError(e, "Error deleting API key")
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
|
||||||
|
const newRows = rows.filter((row) => row.id !== apiKeyId);
|
||||||
|
|
||||||
|
setRows(newRows);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<ApiKeyRow>[] = [
|
||||||
|
{
|
||||||
|
id: "dots",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const apiKeyROw = row.original;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(apiKeyROw);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>View settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(apiKeyROw);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "key",
|
||||||
|
header: "Key",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span className="font-mono">{r.key}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "Created At",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Link href={`/admin/api-keys/${r.id}`}>
|
||||||
|
<Button variant={"outlinePrimary"} className="ml-2">
|
||||||
|
Edit
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selected && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelected(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to remove the API key{" "}
|
||||||
|
<b>{selected?.name || selected?.id}</b>?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
Once removed, the API key will no longer be
|
||||||
|
able to be used.
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To confirm, please type the name of the API key
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText="Confirm Delete API Key"
|
||||||
|
onConfirm={async () => deleteSite(selected!.id)}
|
||||||
|
string={selected.name}
|
||||||
|
title="Delete API Key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ApiKeysDataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
addApiKey={() => {
|
||||||
|
router.push(`/admin/api-keys/create`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
62
src/app/admin/api-keys/[apiKeyId]/layout.tsx
Normal file
62
src/app/admin/api-keys/[apiKeyId]/layout.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator
|
||||||
|
} from "@app/components/ui/breadcrumb";
|
||||||
|
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||||
|
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
|
||||||
|
interface SettingsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ apiKeyId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
let apiKey = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<GetApiKeyResponse>>(
|
||||||
|
`/api-key/${params.apiKeyId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
apiKey = res.data.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
redirect(`/admin/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: "Permissions",
|
||||||
|
href: "/admin/api-keys/{apiKeyId}/permissions"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||||
|
|
||||||
|
<ApiKeyProvider apiKey={apiKey}>
|
||||||
|
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||||
|
</ApiKeyProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
13
src/app/admin/api-keys/[apiKeyId]/page.tsx
Normal file
13
src/app/admin/api-keys/[apiKeyId]/page.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage(props: {
|
||||||
|
params: Promise<{ apiKeyId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(`/admin/api-keys/${params.apiKeyId}/permissions`);
|
||||||
|
}
|
139
src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx
Normal file
139
src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const { apiKeyId } = useParams();
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||||
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [loadingSavePermissions, setLoadingSavePermissions] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoadingPage(true);
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.get<
|
||||||
|
AxiosResponse<ListApiKeyActionsResponse>
|
||||||
|
>(`/api-key/${apiKeyId}/actions`)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error loading API key actions",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"Error loading API key actions"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const data = res.data.data;
|
||||||
|
for (const action of data.actions) {
|
||||||
|
setSelectedPermissions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[action.actionId]: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPage(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
setLoadingSavePermissions(true);
|
||||||
|
|
||||||
|
const actionsRes = await api
|
||||||
|
.post(`/api-key/${apiKeyId}/actions`, {
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error setting permissions", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error setting permissions",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (actionsRes && actionsRes.status === 200) {
|
||||||
|
toast({
|
||||||
|
title: "Permissions updated",
|
||||||
|
description: "The permissions have been updated."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingSavePermissions(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loadingPage && (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Permissions
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Determine what this API key can do
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<PermissionsSelectBox
|
||||||
|
selectedPermissions={selectedPermissions}
|
||||||
|
onChange={setSelectedPermissions}
|
||||||
|
root={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await savePermissions();
|
||||||
|
}}
|
||||||
|
loading={loadingSavePermissions}
|
||||||
|
disabled={loadingSavePermissions}
|
||||||
|
>
|
||||||
|
Save Permissions
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
402
src/app/admin/api-keys/create/page.tsx
Normal file
402
src/app/admin/api-keys/create/page.tsx
Normal file
|
@ -0,0 +1,402 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator
|
||||||
|
} from "@app/components/ui/breadcrumb";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
CreateOrgApiKeyBody,
|
||||||
|
CreateOrgApiKeyResponse
|
||||||
|
} from "@server/routers/apiKeys";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import moment from "moment";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
|
|
||||||
|
const createFormSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, {
|
||||||
|
message: "Name must be at least 2 characters."
|
||||||
|
})
|
||||||
|
.max(255, {
|
||||||
|
message: "Name must not be longer than 255 characters."
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||||
|
|
||||||
|
const copiedFormSchema = z
|
||||||
|
.object({
|
||||||
|
copied: z.boolean()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return data.copied;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "You must confirm that you have copied the API key.",
|
||||||
|
path: ["copied"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const form = useForm<CreateFormValues>({
|
||||||
|
resolver: zodResolver(createFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const copiedForm = useForm<CopiedFormValues>({
|
||||||
|
resolver: zodResolver(copiedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
copied: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: CreateFormValues) {
|
||||||
|
setCreateLoading(true);
|
||||||
|
|
||||||
|
let payload: CreateOrgApiKeyBody = {
|
||||||
|
name: data.name
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api
|
||||||
|
.put<AxiosResponse<CreateOrgApiKeyResponse>>(`/api-key`, payload)
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error creating API key",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
const data = res.data.data;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsRes = await api
|
||||||
|
.post(`/api-key/${data.apiKeyId}/actions`, {
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error setting permissions", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Error setting permissions",
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (actionsRes) {
|
||||||
|
setApiKey(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopiedSubmit(data: CopiedFormValues) {
|
||||||
|
if (!data.copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoadingPage(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<HeaderTitle
|
||||||
|
title="Generate API Key"
|
||||||
|
description="Generate a new root access API key"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See All API Keys
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loadingPage && (
|
||||||
|
<div>
|
||||||
|
<SettingsContainer>
|
||||||
|
{!apiKey && (
|
||||||
|
<>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
API Key Information
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="create-site-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Permissions
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Determine what this API key can do
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<PermissionsSelectBox
|
||||||
|
root={true}
|
||||||
|
selectedPermissions={
|
||||||
|
selectedPermissions
|
||||||
|
}
|
||||||
|
onChange={setSelectedPermissions}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Your API Key
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={2}>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Name
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={apiKey.name}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Created
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{moment(
|
||||||
|
apiKey.createdAt
|
||||||
|
).format("lll")}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
Save Your API Key
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You will only be able to see this
|
||||||
|
once. Make sure to copy it to a
|
||||||
|
secure place.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<h4 className="font-semibold">
|
||||||
|
Your API key is:
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<CopyTextBox
|
||||||
|
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form {...copiedForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
id="copied-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={copiedForm.control}
|
||||||
|
name="copied"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
defaultChecked={
|
||||||
|
copiedForm.getValues(
|
||||||
|
"copied"
|
||||||
|
) as boolean
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
e
|
||||||
|
) => {
|
||||||
|
copiedForm.setValue(
|
||||||
|
"copied",
|
||||||
|
e as boolean
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
I have copied
|
||||||
|
the API key
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={createLoading}
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
46
src/app/admin/api-keys/page.tsx
Normal file
46
src/app/admin/api-keys/page.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
|
||||||
|
import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable";
|
||||||
|
|
||||||
|
type ApiKeyPageProps = {};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||||
|
let apiKeys: ListRootApiKeysResponse["apiKeys"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListRootApiKeysResponse>>(
|
||||||
|
`/api-keys`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
apiKeys = res.data.data.apiKeys;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const rows: ApiKeyRow[] = apiKeys.map((key) => {
|
||||||
|
return {
|
||||||
|
name: key.name,
|
||||||
|
id: key.apiKeyId,
|
||||||
|
key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`,
|
||||||
|
createdAt: key.createdAt
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Manage API Keys"
|
||||||
|
description="API keys are used to authenticate with the integration API"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiKeysTable apiKeys={rows} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -40,6 +40,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Organization Policies",
|
||||||
|
href: `/admin/idp/${params.idpId}/policies`,
|
||||||
|
showProfessional: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
33
src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx
Normal file
33
src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
onAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PolicyDataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onAdd
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
title="Organization Policies"
|
||||||
|
searchPlaceholder="Search organization policies..."
|
||||||
|
searchColumn="orgId"
|
||||||
|
addButtonText="Add Organization Policy"
|
||||||
|
onAdd={onAdd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
159
src/app/admin/idp/[idpId]/policies/PolicyTable.tsx
Normal file
159
src/app/admin/idp/[idpId]/policies/PolicyTable.tsx
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PolicyDataTable } from "./PolicyDataTable";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
|
export interface PolicyRow {
|
||||||
|
orgId: string;
|
||||||
|
roleMapping?: string;
|
||||||
|
orgMapping?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
policies: PolicyRow[];
|
||||||
|
onDelete: (orgId: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (policy: PolicyRow) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||||
|
const columns: ColumnDef<PolicyRow>[] = [
|
||||||
|
{
|
||||||
|
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">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onDelete(r.orgId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "orgId",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Organization ID
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "roleMapping",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Role Mapping
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mapping = row.original.roleMapping;
|
||||||
|
return mapping ? (
|
||||||
|
<InfoPopup
|
||||||
|
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||||
|
info={mapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"--"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "orgMapping",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Organization Mapping
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mapping = row.original.orgMapping;
|
||||||
|
return mapping ? (
|
||||||
|
<InfoPopup
|
||||||
|
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||||
|
info={mapping}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"--"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const policy = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button
|
||||||
|
variant={"outlinePrimary"}
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() => onEdit(policy)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
|
||||||
|
}
|
645
src/app/admin/idp/[idpId]/policies/page.tsx
Normal file
645
src/app/admin/idp/[idpId]/policies/page.tsx
Normal file
|
@ -0,0 +1,645 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
|
||||||
|
import PolicyTable, { PolicyRow } from "./PolicyTable";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { GetIdpResponse } from "@server/routers/idp";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
|
||||||
|
type Organization = {
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyFormSchema = z.object({
|
||||||
|
orgId: z.string().min(1, { message: "Organization is required" }),
|
||||||
|
roleMapping: z.string().optional(),
|
||||||
|
orgMapping: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultMappingsSchema = z.object({
|
||||||
|
defaultRoleMapping: z.string().optional(),
|
||||||
|
defaultOrgMapping: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||||
|
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||||
|
|
||||||
|
export default function PoliciesPage() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
const { idpId } = useParams();
|
||||||
|
|
||||||
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const [addPolicyLoading, setAddPolicyLoading] = useState(false);
|
||||||
|
const [editPolicyLoading, setEditPolicyLoading] = useState(false);
|
||||||
|
const [deletePolicyLoading, setDeletePolicyLoading] = useState(false);
|
||||||
|
const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] =
|
||||||
|
useState(false);
|
||||||
|
const [policies, setPolicies] = useState<PolicyRow[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<PolicyFormValues>({
|
||||||
|
resolver: zodResolver(policyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
orgId: "",
|
||||||
|
roleMapping: "",
|
||||||
|
orgMapping: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultMappingsForm = useForm<DefaultMappingsValues>({
|
||||||
|
resolver: zodResolver(defaultMappingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
defaultRoleMapping: "",
|
||||||
|
defaultOrgMapping: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadIdp = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<GetIdpResponse>>(
|
||||||
|
`/idp/${idpId}`
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
const data = res.data.data;
|
||||||
|
defaultMappingsForm.reset({
|
||||||
|
defaultRoleMapping: data.idp.defaultRoleMapping || "",
|
||||||
|
defaultOrgMapping: data.idp.defaultOrgMapping || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPolicies = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/idp/${idpId}/org`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(res.data.data.policies);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOrganizations = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<AxiosResponse<ListOrgsResponse>>("/orgs");
|
||||||
|
if (res.status === 200) {
|
||||||
|
const existingOrgIds = policies.map((p) => p.orgId);
|
||||||
|
const availableOrgs = res.data.data.orgs.filter(
|
||||||
|
(org) => !existingOrgIds.includes(org.orgId)
|
||||||
|
);
|
||||||
|
setOrganizations(availableOrgs);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setPageLoading(true);
|
||||||
|
await loadPolicies();
|
||||||
|
await loadIdp();
|
||||||
|
setPageLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [idpId]);
|
||||||
|
|
||||||
|
const onAddPolicy = async (data: PolicyFormValues) => {
|
||||||
|
setAddPolicyLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
});
|
||||||
|
if (res.status === 201) {
|
||||||
|
const newPolicy = {
|
||||||
|
orgId: data.orgId,
|
||||||
|
name:
|
||||||
|
organizations.find((org) => org.orgId === data.orgId)
|
||||||
|
?.name || "",
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
};
|
||||||
|
setPolicies([...policies, newPolicy]);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy added successfully"
|
||||||
|
});
|
||||||
|
setShowAddDialog(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAddPolicyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditPolicy = async (data: PolicyFormValues) => {
|
||||||
|
if (!editingPolicy) return;
|
||||||
|
|
||||||
|
setEditPolicyLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post(
|
||||||
|
`/idp/${idpId}/org/${editingPolicy.orgId}`,
|
||||||
|
{
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(
|
||||||
|
policies.map((policy) =>
|
||||||
|
policy.orgId === editingPolicy.orgId
|
||||||
|
? {
|
||||||
|
...policy,
|
||||||
|
roleMapping: data.roleMapping,
|
||||||
|
orgMapping: data.orgMapping
|
||||||
|
}
|
||||||
|
: policy
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy updated successfully"
|
||||||
|
});
|
||||||
|
setShowAddDialog(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setEditPolicyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeletePolicy = async (orgId: string) => {
|
||||||
|
setDeletePolicyLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.delete(`/idp/${idpId}/org/${orgId}`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setPolicies(
|
||||||
|
policies.filter((policy) => policy.orgId !== orgId)
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Policy deleted successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletePolicyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
|
||||||
|
setUpdateDefaultMappingsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/idp/${idpId}/oidc`, {
|
||||||
|
defaultRoleMapping: data.defaultRoleMapping,
|
||||||
|
defaultOrgMapping: data.defaultOrgMapping
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Default mappings updated successfully"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: formatAxiosError(e),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdateDefaultMappingsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pageLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsContainer>
|
||||||
|
<Alert variant="neutral" className="mb-6">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
About Organization Policies
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Organization policies are used to control access to
|
||||||
|
organizations based on the user's ID token. You can
|
||||||
|
specify JMESPath expressions to extract role and
|
||||||
|
organization information from the ID token. For more
|
||||||
|
information, see{" "}
|
||||||
|
<Link
|
||||||
|
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
the documentation
|
||||||
|
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Default Mappings (Optional)
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
The default mappings are used when when there is not
|
||||||
|
an organization policy defined for an organization.
|
||||||
|
You can specify the default role and organization
|
||||||
|
mappings to fall back to here.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<Form {...defaultMappingsForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={defaultMappingsForm.handleSubmit(
|
||||||
|
onUpdateDefaultMappings
|
||||||
|
)}
|
||||||
|
id="policy-default-mappings-form"
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={defaultMappingsForm.control}
|
||||||
|
name="defaultRoleMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Default Role Mapping
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract role
|
||||||
|
information from the ID
|
||||||
|
token. The result of this
|
||||||
|
expression must return the
|
||||||
|
role name as defined in the
|
||||||
|
organization as a string.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={defaultMappingsForm.control}
|
||||||
|
name="defaultOrgMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Default Organization Mapping
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract
|
||||||
|
organization information
|
||||||
|
from the ID token. This
|
||||||
|
expression must return thr
|
||||||
|
org ID or true for the user
|
||||||
|
to be allowed to access the
|
||||||
|
organization.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="policy-default-mappings-form"
|
||||||
|
loading={updateDefaultMappingsLoading}
|
||||||
|
>
|
||||||
|
Save Default Mappings
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<PolicyTable
|
||||||
|
policies={policies}
|
||||||
|
onDelete={onDeletePolicy}
|
||||||
|
onAdd={() => {
|
||||||
|
loadOrganizations();
|
||||||
|
form.reset({
|
||||||
|
orgId: "",
|
||||||
|
roleMapping: "",
|
||||||
|
orgMapping: ""
|
||||||
|
});
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
onEdit={(policy) => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
form.reset({
|
||||||
|
orgId: policy.orgId,
|
||||||
|
roleMapping: policy.roleMapping || "",
|
||||||
|
orgMapping: policy.orgMapping || ""
|
||||||
|
});
|
||||||
|
setShowAddDialog(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<Credenza
|
||||||
|
open={showAddDialog}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setShowAddDialog(val);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{editingPolicy
|
||||||
|
? "Edit Organization Policy"
|
||||||
|
: "Add Organization Policy"}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Configure access for an organization
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(
|
||||||
|
editingPolicy ? onEditPolicy : onAddPolicy
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="policy-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>Organization</FormLabel>
|
||||||
|
{editingPolicy ? (
|
||||||
|
<Input {...field} disabled />
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between",
|
||||||
|
!field.value &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? organizations.find(
|
||||||
|
(
|
||||||
|
org
|
||||||
|
) =>
|
||||||
|
org.orgId ===
|
||||||
|
field.value
|
||||||
|
)?.name
|
||||||
|
: "Select organization"}
|
||||||
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search org" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No org
|
||||||
|
found.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{organizations.map(
|
||||||
|
(
|
||||||
|
org
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
value={`${org.orgId}`}
|
||||||
|
key={
|
||||||
|
org.orgId
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"orgId",
|
||||||
|
org.orgId
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
org.orgId ===
|
||||||
|
field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
org.name
|
||||||
|
}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="roleMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Role Mapping Path (Optional)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract role
|
||||||
|
information from the ID token.
|
||||||
|
The result of this expression
|
||||||
|
must return the role name as
|
||||||
|
defined in the organization as a
|
||||||
|
string.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgMapping"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Organization Mapping Path
|
||||||
|
(Optional)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
JMESPath to extract organization
|
||||||
|
information from the ID token.
|
||||||
|
This expression must return the
|
||||||
|
org ID or true for the user to
|
||||||
|
be allowed to access the
|
||||||
|
organization.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="policy-form"
|
||||||
|
loading={
|
||||||
|
editingPolicy
|
||||||
|
? editPolicyLoading
|
||||||
|
: addPolicyLoading
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
editingPolicy
|
||||||
|
? editPolicyLoading
|
||||||
|
: addPolicyLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editingPolicy ? "Update Policy" : "Add Policy"}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
|
||||||
type LicenseKeysDataTableProps = {
|
type LicenseKeysDataTableProps = {
|
||||||
licenseKeys: LicenseKeyCache[];
|
licenseKeys: LicenseKeyCache[];
|
||||||
onDelete: (key: string) => void;
|
onDelete: (key: LicenseKeyCache) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -124,7 +124,7 @@ export function LicenseKeysDataTable({
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outlinePrimary"
|
variant="outlinePrimary"
|
||||||
onClick={() => onDelete(row.original.licenseKey)}
|
onClick={() => onDelete(row.original)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -40,8 +40,9 @@ export function SitePriceCalculator({
|
||||||
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalCost = mode === "license"
|
const totalCost =
|
||||||
? licenseFlatRate + (siteCount * pricePerSite)
|
mode === "license"
|
||||||
|
? licenseFlatRate + siteCount * pricePerSite
|
||||||
: siteCount * pricePerSite;
|
: siteCount * pricePerSite;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -49,10 +50,15 @@ export function SitePriceCalculator({
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>
|
<CredenzaTitle>
|
||||||
{mode === "license" ? "Purchase License" : "Purchase Additional Sites"}
|
{mode === "license"
|
||||||
|
? "Purchase License"
|
||||||
|
: "Purchase Additional Sites"}
|
||||||
</CredenzaTitle>
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}.
|
Choose how many sites you want to{" "}
|
||||||
|
{mode === "license"
|
||||||
|
? "purchase a license for. You can always add more sites later."
|
||||||
|
: "add to your existing license."}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
|
@ -108,14 +114,26 @@ export function SitePriceCalculator({
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Number of sites:
|
Number of sites:
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">{siteCount}</span>
|
||||||
{siteCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-4 text-lg font-bold">
|
<div className="flex justify-between items-center mt-4 text-lg font-bold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span>${totalCost.toFixed(2)} / mo</span>
|
<span>${totalCost.toFixed(2)} / mo</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||||
|
For the most up-to-date pricing, please visit
|
||||||
|
our{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.fossorial.io/pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
pricing page
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|
|
@ -55,6 +55,7 @@ import { Progress } from "@app/components/ui/progress";
|
||||||
import { MinusCircle, PlusCircle } from "lucide-react";
|
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
licenseKey: z
|
licenseKey: z
|
||||||
|
@ -75,9 +76,8 @@ export default function LicensePage() {
|
||||||
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
|
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedLicenseKey, setSelectedLicenseKey] = useState<string | null>(
|
const [selectedLicenseKey, setSelectedLicenseKey] =
|
||||||
null
|
useState<LicenseKeyCache | null>(null);
|
||||||
);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
||||||
const [hostLicense, setHostLicense] = useState<string | null>(null);
|
const [hostLicense, setHostLicense] = useState<string | null>(null);
|
||||||
|
@ -136,7 +136,8 @@ export default function LicensePage() {
|
||||||
async function deleteLicenseKey(key: string) {
|
async function deleteLicenseKey(key: string) {
|
||||||
try {
|
try {
|
||||||
setIsDeletingLicense(true);
|
setIsDeletingLicense(true);
|
||||||
const res = await api.delete(`/license/${key}`);
|
const encodedKey = encodeURIComponent(key);
|
||||||
|
const res = await api.delete(`/license/${encodedKey}`);
|
||||||
if (res.data.data) {
|
if (res.data.data) {
|
||||||
updateLicenseStatus(res.data.data);
|
updateLicenseStatus(res.data.data);
|
||||||
}
|
}
|
||||||
|
@ -294,7 +295,11 @@ export default function LicensePage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to delete the license key{" "}
|
Are you sure you want to delete the license key{" "}
|
||||||
<b>{obfuscateLicenseKey(selectedLicenseKey)}</b>
|
<b>
|
||||||
|
{obfuscateLicenseKey(
|
||||||
|
selectedLicenseKey.licenseKey
|
||||||
|
)}
|
||||||
|
</b>
|
||||||
?
|
?
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -310,8 +315,10 @@ export default function LicensePage() {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText="Confirm Delete License Key"
|
buttonText="Confirm Delete License Key"
|
||||||
onConfirm={async () => deleteLicenseKey(selectedLicenseKey)}
|
onConfirm={async () =>
|
||||||
string={selectedLicenseKey}
|
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
|
||||||
|
}
|
||||||
|
string={selectedLicenseKey.licenseKey}
|
||||||
title="Delete License Key"
|
title="Delete License Key"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -428,12 +435,6 @@ export default function LicensePage() {
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
{!licenseStatus?.isHostLicensed ? (
|
{!licenseStatus?.isHostLicensed ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {}}
|
|
||||||
>
|
|
||||||
View License Portal
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPurchaseMode("license");
|
setPurchaseMode("license");
|
||||||
|
@ -444,6 +445,7 @@ export default function LicensePage() {
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -453,6 +455,7 @@ export default function LicensePage() {
|
||||||
>
|
>
|
||||||
Purchase Additional Sites
|
Purchase Additional Sites
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
|
@ -86,7 +86,7 @@ export const adminNavItems: SidebarNavItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "API Keys",
|
title: "API Keys",
|
||||||
href: "/{orgId}/settings/api-keys",
|
href: "/admin/api-keys",
|
||||||
icon: <KeyRound className="h-4 w-4" />,
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
showProfessional: true
|
showProfessional: true
|
||||||
},
|
},
|
||||||
|
|
|
@ -35,7 +35,8 @@ export function HorizontalTabs({
|
||||||
.replace("{orgId}", params.orgId as string)
|
.replace("{orgId}", params.orgId as string)
|
||||||
.replace("{resourceId}", params.resourceId as string)
|
.replace("{resourceId}", params.resourceId as string)
|
||||||
.replace("{niceId}", params.niceId as string)
|
.replace("{niceId}", params.niceId as string)
|
||||||
.replace("{userId}", params.userId as string);
|
.replace("{userId}", params.userId as string)
|
||||||
|
.replace("{apiKeyId}", params.apiKeyId as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
238
src/components/PermissionsSelectBox.tsx
Normal file
238
src/components/PermissionsSelectBox.tsx
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
|
||||||
|
type PermissionsSelectBoxProps = {
|
||||||
|
root?: boolean;
|
||||||
|
selectedPermissions: Record<string, boolean>;
|
||||||
|
onChange: (updated: Record<string, boolean>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getActionsCategories(root: boolean) {
|
||||||
|
const actionsByCategory: Record<string, Record<string, string>> = {
|
||||||
|
Organization: {
|
||||||
|
"Get Organization": "getOrg",
|
||||||
|
"Update Organization": "updateOrg",
|
||||||
|
"Get Organization User": "getOrgUser",
|
||||||
|
"List Organization Domains": "listOrgDomains",
|
||||||
|
"Check Org ID": "checkOrgId",
|
||||||
|
"List Orgs": "listOrgs"
|
||||||
|
},
|
||||||
|
|
||||||
|
Site: {
|
||||||
|
"Create Site": "createSite",
|
||||||
|
"Delete Site": "deleteSite",
|
||||||
|
"Get Site": "getSite",
|
||||||
|
"List Sites": "listSites",
|
||||||
|
"Update Site": "updateSite",
|
||||||
|
"List Allowed Site Roles": "listSiteRoles"
|
||||||
|
},
|
||||||
|
|
||||||
|
Resource: {
|
||||||
|
"Create Resource": "createResource",
|
||||||
|
"Delete Resource": "deleteResource",
|
||||||
|
"Get Resource": "getResource",
|
||||||
|
"List Resources": "listResources",
|
||||||
|
"Update Resource": "updateResource",
|
||||||
|
"List Resource Users": "listResourceUsers",
|
||||||
|
"Set Resource Users": "setResourceUsers",
|
||||||
|
"Set Allowed Resource Roles": "setResourceRoles",
|
||||||
|
"List Allowed Resource Roles": "listResourceRoles",
|
||||||
|
"Set Resource Password": "setResourcePassword",
|
||||||
|
"Set Resource Pincode": "setResourcePincode",
|
||||||
|
"Set Resource Email Whitelist": "setResourceWhitelist",
|
||||||
|
"Get Resource Email Whitelist": "getResourceWhitelist"
|
||||||
|
},
|
||||||
|
|
||||||
|
Target: {
|
||||||
|
"Create Target": "createTarget",
|
||||||
|
"Delete Target": "deleteTarget",
|
||||||
|
"Get Target": "getTarget",
|
||||||
|
"List Targets": "listTargets",
|
||||||
|
"Update Target": "updateTarget"
|
||||||
|
},
|
||||||
|
|
||||||
|
Role: {
|
||||||
|
"Create Role": "createRole",
|
||||||
|
"Delete Role": "deleteRole",
|
||||||
|
"Get Role": "getRole",
|
||||||
|
"List Roles": "listRoles",
|
||||||
|
"Update Role": "updateRole",
|
||||||
|
"List Allowed Role Resources": "listRoleResources"
|
||||||
|
},
|
||||||
|
|
||||||
|
User: {
|
||||||
|
"Invite User": "inviteUser",
|
||||||
|
"Remove User": "removeUser",
|
||||||
|
"List Users": "listUsers",
|
||||||
|
"Add User Role": "addUserRole"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Access Token": {
|
||||||
|
"Generate Access Token": "generateAccessToken",
|
||||||
|
"Delete Access Token": "deleteAcessToken",
|
||||||
|
"List Access Tokens": "listAccessTokens"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Resource Rule": {
|
||||||
|
"Create Resource Rule": "createResourceRule",
|
||||||
|
"Delete Resource Rule": "deleteResourceRule",
|
||||||
|
"List Resource Rules": "listResourceRules",
|
||||||
|
"Update Resource Rule": "updateResourceRule"
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Newt": {
|
||||||
|
// "Create Newt": "createNewt"
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
actionsByCategory["Organization"] = {
|
||||||
|
"Create Organization": "createOrg",
|
||||||
|
"Delete Organization": "deleteOrg",
|
||||||
|
"List API Keys": "listApiKeys",
|
||||||
|
"List API Key Actions": "listApiKeyActions",
|
||||||
|
"Set API Key Allowed Actions": "setApiKeyActions",
|
||||||
|
"Create API Key": "createApiKey",
|
||||||
|
"Delete API Key": "deleteApiKey",
|
||||||
|
...actionsByCategory["Organization"]
|
||||||
|
};
|
||||||
|
|
||||||
|
actionsByCategory["Identity Provider (IDP)"] = {
|
||||||
|
"Create IDP": "createIdp",
|
||||||
|
"Update IDP": "updateIdp",
|
||||||
|
"Delete IDP": "deleteIdp",
|
||||||
|
"List IDP": "listIdps",
|
||||||
|
"Get IDP": "getIdp",
|
||||||
|
"Create IDP Org Policy": "createIdpOrg",
|
||||||
|
"Delete IDP Org Policy": "deleteIdpOrg",
|
||||||
|
"List IDP Orgs": "listIdpOrgs",
|
||||||
|
"Update IDP Org": "updateIdpOrg"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return actionsByCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionsSelectBox({
|
||||||
|
root,
|
||||||
|
selectedPermissions,
|
||||||
|
onChange
|
||||||
|
}: PermissionsSelectBoxProps) {
|
||||||
|
const actionsByCategory = getActionsCategories(root ?? false);
|
||||||
|
|
||||||
|
const togglePermission = (key: string, checked: boolean) => {
|
||||||
|
onChange({
|
||||||
|
...selectedPermissions,
|
||||||
|
[key]: checked
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const areAllCheckedInCategory = (actions: Record<string, string>) => {
|
||||||
|
return Object.values(actions).every(
|
||||||
|
(action) => selectedPermissions[action]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllInCategory = (
|
||||||
|
actions: Record<string, string>,
|
||||||
|
value: boolean
|
||||||
|
) => {
|
||||||
|
const updated = { ...selectedPermissions };
|
||||||
|
Object.values(actions).forEach((action) => {
|
||||||
|
updated[action] = value;
|
||||||
|
});
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allActions = Object.values(actionsByCategory).flatMap(Object.values);
|
||||||
|
const allPermissionsChecked = allActions.every(
|
||||||
|
(action) => selectedPermissions[action]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAllPermissions = (checked: boolean) => {
|
||||||
|
const updated: Record<string, boolean> = {};
|
||||||
|
allActions.forEach((action) => {
|
||||||
|
updated[action] = checked;
|
||||||
|
});
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<CheckboxWithLabel
|
||||||
|
variant="outlinePrimarySquare"
|
||||||
|
id="toggle-all-permissions"
|
||||||
|
label="Allow All Permissions"
|
||||||
|
checked={allPermissionsChecked}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAllPermissions(checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InfoSections cols={5}>
|
||||||
|
{Object.entries(actionsByCategory).map(
|
||||||
|
([category, actions]) => {
|
||||||
|
const allChecked = areAllCheckedInCategory(actions);
|
||||||
|
return (
|
||||||
|
<InfoSection key={category}>
|
||||||
|
<InfoSectionTitle>{category}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CheckboxWithLabel
|
||||||
|
variant="outlinePrimarySquare"
|
||||||
|
id={`toggle-all-${category}`}
|
||||||
|
label="Allow All"
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAllInCategory(
|
||||||
|
actions,
|
||||||
|
checked as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{Object.entries(actions).map(
|
||||||
|
([label, value]) => (
|
||||||
|
<CheckboxWithLabel
|
||||||
|
variant="outlineSquare"
|
||||||
|
key={value}
|
||||||
|
id={value}
|
||||||
|
label={label}
|
||||||
|
checked={
|
||||||
|
!!selectedPermissions[
|
||||||
|
value
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) =>
|
||||||
|
togglePermission(
|
||||||
|
value,
|
||||||
|
checked as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</InfoSections>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
16
src/contexts/apiKeyContext.ts
Normal file
16
src/contexts/apiKeyContext.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
interface ApiKeyContextType {
|
||||||
|
apiKey: GetApiKeyResponse;
|
||||||
|
updateApiKey: (updatedApiKey: Partial<GetApiKeyResponse>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyContext = createContext<ApiKeyContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export default ApiKeyContext;
|
17
src/hooks/useApikeyContext.ts
Normal file
17
src/hooks/useApikeyContext.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import ApiKeyContext from "@app/contexts/apiKeyContext";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
export function useApiKeyContext() {
|
||||||
|
const context = useContext(ApiKeyContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useApiKeyContext must be used within a ApiKeyProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
42
src/providers/ApiKeyProvider.tsx
Normal file
42
src/providers/ApiKeyProvider.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import ApiKeyContext from "@app/contexts/apiKeyContext";
|
||||||
|
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ApiKeyProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
apiKey: GetApiKeyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyProvider({ children, apiKey: ak }: ApiKeyProviderProps) {
|
||||||
|
const [apiKey, setApiKey] = useState<GetApiKeyResponse>(ak);
|
||||||
|
|
||||||
|
const updateApiKey = (updatedApiKey: Partial<GetApiKeyResponse>) => {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("No API key to update");
|
||||||
|
}
|
||||||
|
setApiKey((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
...updatedApiKey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiKeyContext.Provider value={{ apiKey, updateApiKey }}>
|
||||||
|
{children}
|
||||||
|
</ApiKeyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyProvider;
|
Loading…
Add table
Add a link
Reference in a new issue