add api key code and oidc auto provision code

This commit is contained in:
miloschwartz 2025-04-28 21:14:09 -04:00
parent 4819f410e6
commit 599d0a52bf
No known key found for this signature in database
84 changed files with 7021 additions and 151 deletions

31
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

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

View file

@ -1,2 +1 @@
export * from "./schema";
export * from "./proSchema";

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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) {

View file

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

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

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

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

View file

@ -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"
}

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

View file

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

View file

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

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

View file

@ -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(
{

View file

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

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

View file

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

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

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

View file

@ -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: {

View file

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

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

View file

@ -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({

View file

@ -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: {

View file

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

View file

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

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

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -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
},

View file

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

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

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

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

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