mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-22 13:23:51 +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",
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
|
@ -94,6 +95,7 @@
|
|||
"@types/better-sqlite3": "7.6.12",
|
||||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
|
@ -4478,7 +4480,7 @@
|
|||
"version": "7.6.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
||||
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
|
@ -4525,6 +4527,13 @@
|
|||
"@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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
|
@ -4619,7 +4628,7 @@
|
|||
"version": "22.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
@ -4653,7 +4662,7 @@
|
|||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
|
@ -4663,7 +4672,7 @@
|
|||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
|
@ -6271,11 +6280,17 @@
|
|||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
|
@ -9584,7 +9599,7 @@
|
|||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
|
@ -15962,7 +15977,6 @@
|
|||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
|
@ -16330,7 +16344,6 @@
|
|||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
@ -16362,7 +16375,7 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
|
|
@ -57,6 +57,7 @@
|
|||
"cookie-parser": "1.4.7",
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"drizzle-orm": "0.38.3",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
|
@ -105,6 +106,7 @@
|
|||
"@types/better-sqlite3": "7.6.12",
|
||||
"@types/cookie-parser": "1.4.8",
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
|
|
|
@ -77,7 +77,15 @@ export enum ActionsEnum {
|
|||
createIdpOrg = "createIdpOrg",
|
||||
deleteIdpOrg = "deleteIdpOrg",
|
||||
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(
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
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()
|
||||
});
|
||||
|
||||
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 User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
@ -494,3 +545,6 @@ export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
|||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
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 { createNextServer } from "./nextServer";
|
||||
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() {
|
||||
await runSetupFunctions();
|
||||
|
@ -14,10 +16,16 @@ async function startServers() {
|
|||
const internalServer = createInternalServer();
|
||||
const nextServer = await createNextServer();
|
||||
|
||||
let integrationServer;
|
||||
if (await license.isUnlocked()) {
|
||||
integrationServer = createIntegrationApiServer();
|
||||
}
|
||||
|
||||
return {
|
||||
apiServer,
|
||||
nextServer,
|
||||
internalServer,
|
||||
integrationServer
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -25,9 +33,11 @@ async function startServers() {
|
|||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
user?: User;
|
||||
session?: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
userOrgId?: 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({
|
||||
integration_port: portSchema
|
||||
.optional()
|
||||
.transform(stoi)
|
||||
.pipe(portSchema.optional()),
|
||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
|
@ -96,14 +100,7 @@ const configSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_SECRET"))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(
|
||||
32,
|
||||
"SERVER_SECRET must be at least 32 characters long"
|
||||
)
|
||||
)
|
||||
.pipe(z.string().min(8))
|
||||
}),
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
|
@ -267,6 +264,8 @@ export class Config {
|
|||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
|
||||
license.setServerSecret(parsedConfig.data.server.secret);
|
||||
|
||||
this.checkKeyStatus();
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
|
@ -274,7 +273,6 @@ export class Config {
|
|||
|
||||
private async checkKeyStatus() {
|
||||
const licenseStatus = await license.check();
|
||||
console.log("License status", licenseStatus);
|
||||
if (!licenseStatus.isHostLicensed) {
|
||||
this.checkSupporterKey();
|
||||
}
|
||||
|
|
|
@ -1,40 +1,12 @@
|
|||
import * as crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export function encrypt(value: string, key: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final()
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString("base64"),
|
||||
encrypted.toString("base64"),
|
||||
authTag.toString("base64")
|
||||
].join(":");
|
||||
const ciphertext = CryptoJS.AES.encrypt(value, key).toString();
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedValue: string, key: string): string {
|
||||
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
|
||||
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const encrypted = Buffer.from(encryptedB64, "base64");
|
||||
const authTag = Buffer.from(authTagB64, "base64");
|
||||
const keyBuffer = Buffer.from(key, "base64");
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
const bytes = CryptoJS.AES.decrypt(encryptedValue, key);
|
||||
const originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||
return originalText;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ import NodeCache from "node-cache";
|
|||
import { validateJWT } from "./licenseJwt";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import { setHostMeta } from "@server/setup/setHostMeta";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
|
||||
export type LicenseStatus = {
|
||||
isHostLicensed: boolean; // Are there any license keys?
|
||||
|
@ -21,6 +23,7 @@ export type LicenseStatus = {
|
|||
|
||||
export type LicenseKeyCache = {
|
||||
licenseKey: string;
|
||||
licenseKeyEncrypted: string;
|
||||
valid: boolean;
|
||||
iat?: Date;
|
||||
type?: "LICENSE" | "SITES";
|
||||
|
@ -69,6 +72,7 @@ export class License {
|
|||
|
||||
private ephemeralKey!: string;
|
||||
private statusKey = "status";
|
||||
private serverSecret!: string;
|
||||
|
||||
private publicKey = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
||||
|
@ -100,6 +104,10 @@ LQIDAQAB
|
|||
});
|
||||
}
|
||||
|
||||
public setServerSecret(secret: string) {
|
||||
this.serverSecret = secret;
|
||||
}
|
||||
|
||||
public async forceRecheck() {
|
||||
this.statusCache.flushAll();
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
@ -129,8 +137,7 @@ LQIDAQAB
|
|||
hostId: this.hostId,
|
||||
isHostLicensed: true,
|
||||
isLicenseValid: false,
|
||||
maxSites: undefined,
|
||||
usedSites: 150
|
||||
maxSites: undefined
|
||||
};
|
||||
|
||||
try {
|
||||
|
@ -143,8 +150,6 @@ LQIDAQAB
|
|||
// Invalidate all
|
||||
this.licenseKeyCache.flushAll();
|
||||
|
||||
logger.debug("Checking license status...");
|
||||
|
||||
const allKeysRes = await db.select().from(licenseKey);
|
||||
|
||||
if (allKeysRes.length === 0) {
|
||||
|
@ -152,24 +157,37 @@ LQIDAQAB
|
|||
return status;
|
||||
}
|
||||
|
||||
let foundHostKey = false;
|
||||
// Validate stored license keys
|
||||
for (const key of allKeysRes) {
|
||||
try {
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
// Decrypt the license key and token
|
||||
const decryptedKey = decrypt(
|
||||
key.licenseKeyId,
|
||||
this.serverSecret
|
||||
);
|
||||
const decryptedToken = decrypt(
|
||||
key.token,
|
||||
this.serverSecret
|
||||
);
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
decryptedToken,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKeyId,
|
||||
{
|
||||
licenseKey: key.licenseKeyId,
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
|
||||
licenseKey: decryptedKey,
|
||||
licenseKeyEncrypted: key.licenseKeyId,
|
||||
valid: payload.valid,
|
||||
type: payload.type,
|
||||
numSites: payload.quantity,
|
||||
iat: new Date(payload.iat * 1000)
|
||||
});
|
||||
|
||||
if (payload.type === "LICENSE") {
|
||||
foundHostKey = true;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error validating license key: ${key.licenseKeyId}`
|
||||
|
@ -180,15 +198,21 @@ LQIDAQAB
|
|||
key.licenseKeyId,
|
||||
{
|
||||
licenseKey: key.licenseKeyId,
|
||||
licenseKeyEncrypted: key.licenseKeyId,
|
||||
valid: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundHostKey && allKeysRes.length) {
|
||||
logger.debug("No host license key found");
|
||||
status.isHostLicensed = false;
|
||||
}
|
||||
|
||||
const keys = allKeysRes.map((key) => ({
|
||||
licenseKey: key.licenseKeyId,
|
||||
instanceId: key.instanceId
|
||||
licenseKey: decrypt(key.licenseKeyId, this.serverSecret),
|
||||
instanceId: decrypt(key.instanceId, this.serverSecret)
|
||||
}));
|
||||
|
||||
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||
|
@ -251,12 +275,22 @@ LQIDAQAB
|
|||
cached.numSites = payload.quantity;
|
||||
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
|
||||
.update(licenseKey)
|
||||
.set({
|
||||
token: licenseKeyRes
|
||||
token: encryptedToken
|
||||
})
|
||||
.where(eq(licenseKey.licenseKeyId, key.licenseKey));
|
||||
.where(eq(licenseKey.licenseKeyId, encryptedKey));
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
|
@ -300,10 +334,13 @@ LQIDAQAB
|
|||
}
|
||||
|
||||
public async activateLicenseKey(key: string) {
|
||||
// Encrypt the license key before storing
|
||||
const encryptedKey = encrypt(key, this.serverSecret);
|
||||
|
||||
const [existingKey] = await db
|
||||
.select()
|
||||
.from(licenseKey)
|
||||
.where(eq(licenseKey.licenseKeyId, key))
|
||||
.where(eq(licenseKey.licenseKeyId, encryptedKey))
|
||||
.limit(1);
|
||||
|
||||
if (existingKey) {
|
||||
|
@ -380,11 +417,15 @@ LQIDAQAB
|
|||
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
|
||||
await db.insert(licenseKey).values({
|
||||
licenseKeyId: key,
|
||||
token: licenseKeyRes,
|
||||
instanceId: instanceId!
|
||||
licenseKeyId: encryptedKey,
|
||||
token: encryptedToken,
|
||||
instanceId: encryptedInstanceId
|
||||
});
|
||||
} catch (error) {
|
||||
throw Error(`Error validating license key: ${error}`);
|
||||
|
@ -400,13 +441,21 @@ LQIDAQAB
|
|||
instanceId: string;
|
||||
}[]
|
||||
): 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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKeys: keys,
|
||||
licenseKeys: decryptedKeys,
|
||||
ephemeralKey: this.ephemeralKey,
|
||||
instanceName: this.hostId
|
||||
})
|
||||
|
@ -418,6 +467,8 @@ LQIDAQAB
|
|||
}
|
||||
}
|
||||
|
||||
await setHostMeta();
|
||||
|
||||
const [info] = await db.select().from(hostMeta).limit(1);
|
||||
|
||||
if (!info) {
|
||||
|
|
|
@ -16,3 +16,7 @@ export * from "./verifyUserInRole";
|
|||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
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",
|
||||
Rule = "Rule",
|
||||
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 idp from "./idp";
|
||||
import * as license from "./license";
|
||||
import * as apiKeys from "./apiKeys";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyAccessTokenAccess,
|
||||
|
@ -27,7 +28,9 @@ import {
|
|||
verifyUserAccess,
|
||||
getUserOrgs,
|
||||
verifyUserIsServerAdmin,
|
||||
verifyIsLoggedInUser
|
||||
verifyIsLoggedInUser,
|
||||
verifyApiKeyAccess,
|
||||
verifyValidLicense
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
@ -522,6 +525,38 @@ authenticated.post(
|
|||
|
||||
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/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||
|
||||
|
@ -549,6 +584,100 @@ authenticated.post(
|
|||
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
|
||||
export const authRouter = Router();
|
||||
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 { fromError } from "zod-validation-error";
|
||||
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 { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
|
@ -67,6 +67,11 @@ export async function deleteIdp(
|
|||
.delete(idpOidcConfig)
|
||||
.where(eq(idpOidcConfig.idpId, idpId));
|
||||
|
||||
// Delete IDP-org mappings
|
||||
await trx
|
||||
.delete(idpOrg)
|
||||
.where(eq(idpOrg.idpId, idpId));
|
||||
|
||||
// Delete the IDP itself
|
||||
await trx
|
||||
.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 logger from "@server/logger";
|
||||
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 * as arctic from "arctic";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
|
@ -27,6 +27,10 @@ const bodySchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
return url.endsWith('/') ? url : `${url}/`;
|
||||
};
|
||||
|
||||
export type GenerateOidcUrlResponse = {
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
@ -106,12 +110,13 @@ export async function generateOidcUrl(
|
|||
const codeVerifier = arctic.generateCodeVerifier();
|
||||
const state = arctic.generateState();
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
existingIdp.idpOidcConfig.authUrl,
|
||||
ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl),
|
||||
state,
|
||||
arctic.CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
parsedScopes
|
||||
);
|
||||
logger.debug("Generated OIDC URL", { url });
|
||||
|
||||
const stateJwt = jsonwebtoken.sign(
|
||||
{
|
||||
|
|
|
@ -5,3 +5,7 @@ export * from "./listIdps";
|
|||
export * from "./generateOidcUrl";
|
||||
export * from "./validateOidcCallback";
|
||||
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 { z } from "zod";
|
||||
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 HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
|
@ -33,8 +33,10 @@ async function query(limit: number, offset: number) {
|
|||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
||||
})
|
||||
.from(idp)
|
||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||
.groupBy(idp.idpId)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
@ -46,6 +48,7 @@ export type ListIdpsResponse = {
|
|||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
}>;
|
||||
pagination: {
|
||||
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({
|
||||
method: "post",
|
||||
path: "/idp/:idpId/oidc",
|
||||
path: "/idp/{idpId}/oidc",
|
||||
description: "Update an OIDC IdP.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig, users } from "@server/db/schemas";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import * as arctic from "arctic";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import jmespath from "jmespath";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
createSession,
|
||||
generateSessionToken,
|
||||
serializeSessionCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { response } from "@server/lib";
|
||||
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
|
||||
.object({
|
||||
|
@ -148,7 +154,7 @@ export async function validateOidcCallback(
|
|||
}
|
||||
|
||||
const tokens = await client.validateAuthorizationCode(
|
||||
existingIdp.idpOidcConfig.tokenUrl,
|
||||
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
||||
code,
|
||||
codeVerifier
|
||||
);
|
||||
|
@ -204,12 +210,24 @@ export async function validateOidcCallback(
|
|||
);
|
||||
|
||||
if (existingIdp.idp.autoProvision) {
|
||||
if (!(await license.isUnlocked())) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Auto provisioning is not supported"
|
||||
HttpCode.FORBIDDEN,
|
||||
"Auto-provisioning is not available"
|
||||
)
|
||||
);
|
||||
}
|
||||
await oidcAutoProvision({
|
||||
idp: existingIdp.idp,
|
||||
userIdentifier,
|
||||
email,
|
||||
name,
|
||||
claims,
|
||||
existingUser,
|
||||
req,
|
||||
res
|
||||
});
|
||||
} else {
|
||||
if (!existingUser) {
|
||||
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 { licenseKey } from "@server/db/schemas";
|
||||
import license, { LicenseStatus } from "@server/license/license";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
|
|
|
@ -31,7 +31,7 @@ const listOrgsSchema = z.object({
|
|||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/user/:userId/orgs",
|
||||
path: "/user/{userId}/orgs",
|
||||
description: "List all organizations for a user.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||
request: {
|
||||
|
|
|
@ -3,11 +3,9 @@ import { copyInConfig } from "./copyInConfig";
|
|||
import { setupServerAdmin } from "./setupServerAdmin";
|
||||
import logger from "@server/logger";
|
||||
import { clearStaleData } from "./clearStaleData";
|
||||
import { setHostMeta } from "./setHostMeta";
|
||||
|
||||
export async function runSetupFunctions() {
|
||||
try {
|
||||
await setHostMeta();
|
||||
await copyInConfig(); // copy in the config to the db as needed
|
||||
await setupServerAdmin();
|
||||
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 m18 from "./scripts/1.2.0";
|
||||
import m19 from "./scripts/1.3.0";
|
||||
import { setHostMeta } from "./setHostMeta";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// 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",
|
||||
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 = {
|
||||
licenseKeys: LicenseKeyCache[];
|
||||
onDelete: (key: string) => void;
|
||||
onDelete: (key: LicenseKeyCache) => void;
|
||||
onCreate: () => void;
|
||||
};
|
||||
|
||||
|
@ -124,7 +124,7 @@ export function LicenseKeysDataTable({
|
|||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outlinePrimary"
|
||||
onClick={() => onDelete(row.original.licenseKey)}
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
|
|
@ -40,8 +40,9 @@ export function SitePriceCalculator({
|
|||
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||
};
|
||||
|
||||
const totalCost = mode === "license"
|
||||
? licenseFlatRate + (siteCount * pricePerSite)
|
||||
const totalCost =
|
||||
mode === "license"
|
||||
? licenseFlatRate + siteCount * pricePerSite
|
||||
: siteCount * pricePerSite;
|
||||
|
||||
return (
|
||||
|
@ -49,10 +50,15 @@ export function SitePriceCalculator({
|
|||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license" ? "Purchase License" : "Purchase Additional Sites"}
|
||||
{mode === "license"
|
||||
? "Purchase License"
|
||||
: "Purchase Additional Sites"}
|
||||
</CredenzaTitle>
|
||||
<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>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -108,14 +114,26 @@ export function SitePriceCalculator({
|
|||
<span className="text-sm font-medium">
|
||||
Number of sites:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{siteCount}
|
||||
</span>
|
||||
<span className="font-medium">{siteCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-4 text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span>${totalCost.toFixed(2)} / mo</span>
|
||||
</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>
|
||||
</CredenzaBody>
|
||||
|
|
|
@ -55,6 +55,7 @@ import { Progress } from "@app/components/ui/progress";
|
|||
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
||||
import Link from "next/link";
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
|
@ -75,9 +76,8 @@ export default function LicensePage() {
|
|||
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedLicenseKey, setSelectedLicenseKey] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [selectedLicenseKey, setSelectedLicenseKey] =
|
||||
useState<LicenseKeyCache | null>(null);
|
||||
const router = useRouter();
|
||||
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
||||
const [hostLicense, setHostLicense] = useState<string | null>(null);
|
||||
|
@ -136,7 +136,8 @@ export default function LicensePage() {
|
|||
async function deleteLicenseKey(key: string) {
|
||||
try {
|
||||
setIsDeletingLicense(true);
|
||||
const res = await api.delete(`/license/${key}`);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const res = await api.delete(`/license/${encodedKey}`);
|
||||
if (res.data.data) {
|
||||
updateLicenseStatus(res.data.data);
|
||||
}
|
||||
|
@ -294,7 +295,11 @@ export default function LicensePage() {
|
|||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to delete the license key{" "}
|
||||
<b>{obfuscateLicenseKey(selectedLicenseKey)}</b>
|
||||
<b>
|
||||
{obfuscateLicenseKey(
|
||||
selectedLicenseKey.licenseKey
|
||||
)}
|
||||
</b>
|
||||
?
|
||||
</p>
|
||||
<p>
|
||||
|
@ -310,8 +315,10 @@ export default function LicensePage() {
|
|||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete License Key"
|
||||
onConfirm={async () => deleteLicenseKey(selectedLicenseKey)}
|
||||
string={selectedLicenseKey}
|
||||
onConfirm={async () =>
|
||||
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
|
||||
}
|
||||
string={selectedLicenseKey.licenseKey}
|
||||
title="Delete License Key"
|
||||
/>
|
||||
)}
|
||||
|
@ -428,12 +435,6 @@ export default function LicensePage() {
|
|||
<SettingsSectionFooter>
|
||||
{!licenseStatus?.isHostLicensed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {}}
|
||||
>
|
||||
View License Portal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPurchaseMode("license");
|
||||
|
@ -444,6 +445,7 @@ export default function LicensePage() {
|
|||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
@ -453,6 +455,7 @@ export default function LicensePage() {
|
|||
>
|
||||
Purchase Additional Sites
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -86,7 +86,7 @@ export const adminNavItems: SidebarNavItem[] = [
|
|||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
href: "/admin/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
showProfessional: true
|
||||
},
|
||||
|
|
|
@ -35,7 +35,8 @@ export function HorizontalTabs({
|
|||
.replace("{orgId}", params.orgId as string)
|
||||
.replace("{resourceId}", params.resourceId 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 (
|
||||
|
|
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