Compare commits

...

15 commits
v1.3.1 ... oss

Author SHA1 Message Date
a727626807
Merge branch 'main' into oss 2025-05-14 12:09:28 +02:00
Milo Schwartz
1f584bf3e8
Merge pull request #717 from fosrl/dev
1.4.0
2025-05-13 11:12:03 -04:00
miloschwartz
5b0200154a
add feature parity 2025-05-13 11:09:38 -04:00
Milo Schwartz
a512148348
Merge pull request #701 from fosrl/dev
1.3.2
2025-05-10 11:30:07 -04:00
miloschwartz
d9eccd6c13
bump version 2025-05-10 11:28:22 -04:00
miloschwartz
492669f68a
set default congig values 2025-05-09 18:32:14 -04:00
miloschwartz
caded23b51
allow root path 2025-05-09 17:37:55 -04:00
miloschwartz
e9cc48a3ae
fix bug causing duplicate targets 2025-05-09 17:18:42 -04:00
miloschwartz
4ed98c227b
fix setting tlsServerName and hostHeader conflict 2025-05-09 17:12:01 -04:00
miloschwartz
f66fb7d4a3
fix justification for profile icon 2025-05-09 17:09:22 -04:00
miloschwartz
f25990a9a7
add id token and claims to debug logs 2025-05-09 16:46:51 -04:00
miloschwartz
21d5b67ef1
Merge branch 'main' into dev 2025-05-09 16:44:09 -04:00
Milo Schwartz
198810121c
Update README.md 2025-05-09 16:44:01 -04:00
Owen
83c0379c6b
Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-05-03 22:04:29 -04:00
Owen
9a167b5acb
Dont overwrite the secret and crowdsec vars 2025-05-02 14:16:10 -04:00
73 changed files with 5593 additions and 668 deletions

View file

@ -1,5 +1,3 @@
Copyright (c) 2025 Fossorial, LLC.
GNU AFFERO GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 19 November 2007

View file

@ -64,15 +64,15 @@ func main() {
} }
var config Config var config Config
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput(reader) config = collectUserInput(reader)
loadVersions(&config) loadVersions(&config)
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
if err := createConfigFiles(config); err != nil { if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err) fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1) os.Exit(1)

View file

@ -109,7 +109,7 @@ export async function checkUserActionPermission(
try { try {
let userRoleIds = req.userRoleIds; let userRoleIds = req.userRoleIds;
// If userOrgRoleId is not available on the request, fetch it // If userRoleIds is not available on the request, fetch it
if (userRoleIds === undefined) { if (userRoleIds === undefined) {
const userOrgRoles = await db const userOrgRoles = await db
.select({ roleId: userOrgs.roleId }) .select({ roleId: userOrgs.roleId })

View file

@ -5,7 +5,8 @@ import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer"; import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer"; import { createInternalServer } from "./internalServer";
import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas";
// import { createIntegrationApiServer } from "./integrationApiServer"; import { createIntegrationApiServer } from "./integrationApiServer";
import config from "@server/lib/config";
async function startServers() { async function startServers() {
await runSetupFunctions(); await runSetupFunctions();
@ -16,7 +17,9 @@ async function startServers() {
const nextServer = await createNextServer(); const nextServer = await createNextServer();
let integrationServer; let integrationServer;
// integrationServer = createIntegrationApiServer(); if (config.getRawConfig().flags?.enable_integration_api) {
integrationServer = createIntegrationApiServer();
}
return { return {
apiServer, apiServer,

View file

@ -0,0 +1,102 @@
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,
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/integration";
import { logIncomingMiddleware } from "./middlewares/logIncoming";
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();
if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1);
}
apiServer.use(cors());
if (!dev) {
apiServer.use(helmet());
}
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

@ -25,9 +25,12 @@ const configSchema = z.object({
.optional() .optional()
.pipe(z.string().url()) .pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]), log_level: z
save_logs: z.boolean(), .enum(["debug", "info", "warn", "error"])
log_failed_attempts: z.boolean().optional() .optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}), }),
domains: z domains: z
.record( .record(
@ -37,8 +40,8 @@ const configSchema = z.object({
.string() .string()
.nonempty("base_domain must not be empty") .nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(), cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional() prefer_wildcard_cert: z.boolean().optional().default(false)
}) })
) )
.refine( .refine(
@ -58,19 +61,42 @@ const configSchema = z.object({
server: z.object({ server: z.object({
integration_port: portSchema integration_port: portSchema
.optional() .optional()
.default(3003)
.transform(stoi) .transform(stoi)
.pipe(portSchema.optional()), .pipe(portSchema.optional()),
external_port: portSchema.optional().transform(stoi).pipe(portSchema), external_port: portSchema
internal_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
next_port: portSchema.optional().transform(stoi).pipe(portSchema), .default(3000)
internal_hostname: z.string().transform((url) => url.toLowerCase()), .transform(stoi)
session_cookie_name: z.string(), .pipe(portSchema),
resource_access_token_param: z.string(), internal_port: portSchema
resource_access_token_headers: z.object({ .optional()
id: z.string(), .default(3001)
token: z.string() .transform(stoi)
}), .pipe(portSchema),
resource_session_request_param: z.string(), next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z.string().optional().default("p_session_token"),
resource_access_token_param: z.string().optional().default("p_token"),
resource_access_token_headers: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z dashboard_session_length_hours: z
.number() .number()
.positive() .positive()
@ -98,35 +124,61 @@ const configSchema = z.object({
.transform(getEnvOrYaml("SERVER_SECRET")) .transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8)) .pipe(z.string().min(8))
}), }),
traefik: z.object({ traefik: z
http_entrypoint: z.string(), .object({
https_entrypoint: z.string().optional(), http_entrypoint: z.string().optional().default("web"),
additional_middlewares: z.array(z.string()).optional() https_entrypoint: z.string().optional().default("websecure"),
}), additional_middlewares: z.array(z.string()).optional()
gerbil: z.object({ })
start_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
base_endpoint: z .default({}),
.string() gerbil: z
.optional() .object({
.pipe(z.string()) start_port: portSchema
.transform((url) => url.toLowerCase()), .optional()
use_subdomain: z.boolean(), .default(51820)
subnet_group: z.string(), .transform(stoi)
block_size: z.number().positive().gt(0), .pipe(portSchema),
site_block_size: z.number().positive().gt(0) base_endpoint: z
}), .string()
rate_limits: z.object({ .optional()
global: z.object({ .pipe(z.string())
window_minutes: z.number().positive().gt(0), .transform((url) => url.toLowerCase()),
max_requests: z.number().positive().gt(0) use_subdomain: z.boolean().optional().default(false),
}), subnet_group: z.string().optional().default("100.89.137.0/20"),
auth: z block_size: z.number().positive().gt(0).optional().default(24),
.object({ site_block_size: z.number().positive().gt(0).optional().default(30)
window_minutes: z.number().positive().gt(0), })
max_requests: z.number().positive().gt(0) .optional()
}) .default({}),
.optional() rate_limits: z
}), .object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
})
.optional()
})
.optional()
.default({}),
email: z email: z
.object({ .object({
smtp_host: z.string().optional(), smtp_host: z.string().optional(),
@ -160,7 +212,8 @@ const configSchema = z.object({
disable_user_create_org: z.boolean().optional(), disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(), allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional(), allow_base_domain_resources: z.boolean().optional(),
allow_local_sites: z.boolean().optional() allow_local_sites: z.boolean().optional(),
enable_integration_api: z.boolean().optional()
}) })
.optional() .optional()
}); });

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.3.0"; export const APP_VERSION = "1.4.0";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
} }
export function isValidUrlGlobPattern(pattern: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean {
if (pattern === "/") {
return true;
}
// Remove leading slash if present // Remove leading slash if present
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;

View file

@ -16,6 +16,6 @@ export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess"; export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin"; export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser";
// export * from "./integration"; export * from "./integration";
export * from "./verifyUserHasAction"; export * from "./verifyUserHasAction";
// export * from "./verifyApiKeyAccess"; export * from "./verifyApiKeyAccess";

View file

@ -0,0 +1,12 @@
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,110 @@
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,60 @@
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,81 @@
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,56 @@
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,39 @@
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,61 @@
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,85 @@
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,127 @@
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,69 @@
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,89 @@
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,112 @@
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,67 @@
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,96 @@
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)
)
);
req.userOrg = userOrgRole[0];
req.userRoleIds = userOrgRole.map((r) => r.roleId);
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying key access"
)
);
}
}

View file

@ -0,0 +1,128 @@
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,100 @@
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,76 @@
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,99 @@
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,76 @@
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,11 @@
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,113 @@
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,116 @@
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,85 @@
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,136 @@
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,117 @@
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

@ -1,61 +1,136 @@
import { isPathAllowed } from './verifySession';
import { assertEquals } from '@test/assert'; import { assertEquals } from '@test/assert';
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() { function runTests() {
console.log('Running path matching tests...'); console.log('Running path matching tests...');
// Test exact matching // Test exact matching
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed'); assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match'); assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed'); assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed'); assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
// Test with leading and trailing slashes // Test with leading and trailing slashes
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match'); assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match'); assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match'); assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match'); assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
// Test simple wildcard matching // Test simple wildcard matching
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment'); assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments'); assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match'); assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match'); assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match'); assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
// Test multiple wildcards // Test multiple wildcards
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments'); assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments'); assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match'); assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match'); assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
// Test wildcard consumption behavior // Test wildcard consumption behavior
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments'); assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional'); assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments'); assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped'); assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
// Test complex nested paths // Test complex nested paths
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match'); assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match'); assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match'); assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
// Test for the requested padbootstrap* pattern // Test for the requested padbootstrap* pattern
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files'); assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)'); assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
// Test wildcard edge cases // Test wildcard edge cases
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments'); assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments'); assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
// Test patterns with partial segment matches // Test patterns with partial segment matches
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard'); assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard'); assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('/', '/'), true, 'Root path should match root path');
assertEquals(isPathAllowed('/', '/test'), false, 'Root path should not match non-root path');
console.log('All tests passed!'); console.log('All tests passed!');
} }
@ -64,4 +139,4 @@ try {
runTests(); runTests();
} catch (error) { } catch (error) {
console.error('Test failed:', error); console.error('Test failed:', error);
} }

View file

@ -10,7 +10,7 @@ import * as auth from "./auth";
import * as role from "./role"; import * as role from "./role";
import * as accessToken from "./accessToken"; import * as accessToken from "./accessToken";
import * as idp from "./idp"; import * as idp from "./idp";
// import * as apiKeys from "./apiKeys"; import * as apiKeys from "./apiKeys";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
verifyAccessTokenAccess, verifyAccessTokenAccess,
@ -26,8 +26,8 @@ import {
verifyUserAccess, verifyUserAccess,
getUserOrgs, getUserOrgs,
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
verifyIsLoggedInUser verifyIsLoggedInUser,
// verifyApiKeyAccess verifyApiKeyAccess,
} from "@server/middlewares"; } from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
@ -555,7 +555,6 @@ authenticated.get(
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
/*
authenticated.get( authenticated.get(
`/api-key/:apiKeyId`, `/api-key/:apiKeyId`,
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
@ -637,7 +636,6 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getApiKey), verifyUserHasAction(ActionsEnum.getApiKey),
apiKeys.getApiKey apiKeys.getApiKey
); );
*/
// Auth routes // Auth routes
export const authRouter = Router(); export const authRouter = Router();

View file

@ -6,8 +6,12 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, users } from "@server/db/schemas"; import {
import { and, eq, inArray } from "drizzle-orm"; idp,
idpOidcConfig,
users
} from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import * as arctic from "arctic"; import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath"; import jmespath from "jmespath";
@ -159,7 +163,9 @@ export async function validateOidcCallback(
); );
const idToken = tokens.idToken(); const idToken = tokens.idToken();
logger.debug("ID token", { idToken });
const claims = arctic.decodeIdToken(idToken); const claims = arctic.decodeIdToken(idToken);
logger.debug("ID token claims", { claims });
const userIdentifier = jmespath.search( const userIdentifier = jmespath.search(
claims, claims,

View file

@ -0,0 +1,494 @@
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

@ -318,8 +318,8 @@ async function updateHttpResource(
domainId: updatePayload.domainId, domainId: updatePayload.domainId,
enabled: updatePayload.enabled, enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession, stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null, tlsServerName: updatePayload.tlsServerName,
setHostHeader: updatePayload.setHostHeader || null, setHostHeader: updatePayload.setHostHeader,
fullDomain: updatePayload.fullDomain fullDomain: updatePayload.fullDomain
}) })
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))

View file

@ -35,7 +35,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }), username: z.string(),
roles: z roles: z
.array( .array(
z.object({ z.object({
@ -64,7 +64,7 @@ export default function AccessControlsPage() {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: user.email!, username: user.username!,
roles: [] roles: []
} }
}); });

View file

@ -0,0 +1,28 @@
"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,199 @@
"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,57 @@
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,8 @@
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,133 @@
"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,407 @@
"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,44 @@
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

@ -1,8 +1,6 @@
"use client"; "use client";
import { import { ColumnDef } from "@tanstack/react-table";
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table"; import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@ -25,6 +23,10 @@ export function ResourcesDataTable<TData, TValue>({
searchColumn="name" searchColumn="name"
onAdd={createResource} onAdd={createResource}
addButtonText="Add Resource" addButtonText="Add Resource"
defaultSort={{
id: "name",
desc: false
}}
/> />
); );
} }

View file

@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
AxiosResponse<CreateTargetResponse> AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data); >(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId; target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) { } else if (target.updated) {
await api.post(`/target/${target.targetId}`, data); await api.post(`/target/${target.targetId}`, data);
target.updated = false;
} }
} }
@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
type="submit" type="submit"
variant="outlinePrimary" variant="outlinePrimary"
className="mt-6" className="mt-6"
disabled={
!(
addTargetForm.getValues("ip") &&
addTargetForm.getValues("port")
)
}
> >
Add Target Add Target
</Button> </Button>

View file

@ -64,7 +64,6 @@ import {
InfoSections, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { Separator } from "@app/components/ui/separator";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { import {
isValidCIDR, isValidCIDR,

View file

@ -1,8 +1,6 @@
"use client"; "use client";
import { import { ColumnDef } from "@tanstack/react-table";
ColumnDef,
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table"; import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@ -25,6 +23,10 @@ export function SitesDataTable<TData, TValue>({
searchColumn="name" searchColumn="name"
onAdd={createSite} onAdd={createSite}
addButtonText="Add Site" addButtonText="Add Site"
defaultSort={{
id: "name",
desc: false
}}
/> />
); );
} }

View file

@ -0,0 +1,53 @@
"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,194 @@
"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,57 @@
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,8 @@
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,134 @@
"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,397 @@
"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,41 @@
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

@ -33,7 +33,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
}, },
{ {
title: "Organization Policies", title: "Organization Policies",
href: `/admin/idp/${params.idpId}/policies`, href: `/admin/idp/${params.idpId}/policies`
} }
]; ];

View file

@ -1,368 +0,0 @@
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PolicyRow } from "./PolicyTable";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import type { Org } from "@server/db/schemas";
import { AxiosResponse } from "axios";
import { CreateIdpOrgPolicyResponse } from "@server/routers/idp";
import { toast } from "@app/hooks/useToast";
type EditPolicyFormProps = {
idpId: string;
orgs: Org[];
policies: PolicyRow[];
policyToEdit: PolicyRow | null;
open: boolean;
setOpen: (open: boolean) => void;
afterCreate?: (policy: PolicyRow) => void;
afterEdit?: (policy: PolicyRow) => void;
};
const formSchema = z.object({
orgId: z.string(),
roleMapping: z.string().optional(),
orgMapping: z.string().optional()
});
export default function EditPolicyForm({
idpId,
orgs,
policies,
policyToEdit,
open,
setOpen,
afterCreate,
afterEdit
}: EditPolicyFormProps) {
const [loading, setLoading] = useState(false);
const [orgsPopoverOpen, setOrgsPopoverOpen] = useState(false);
const api = createApiClient(useEnvContext());
const defaultValues = {
roleMapping: "",
orgMapping: ""
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues,
// @ts-ignore
values: policyToEdit
? {
orgId: policyToEdit.orgId,
roleMapping: policyToEdit.roleMapping || "",
orgMapping: policyToEdit.orgMapping || ""
}
: defaultValues
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
if (policyToEdit) {
const res = await api
.post<AxiosResponse<CreateIdpOrgPolicyResponse>>(
`/idp/${idpId}/org/${values.orgId}`,
{
roleMapping: values.roleMapping,
orgMapping: values.orgMapping
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create org policy",
description: formatAxiosError(
e,
"An error occurred while updating the org policy."
)
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "Org policy created",
description: "The org policy has been successfully updated."
});
setOpen(false);
if (afterEdit) {
afterEdit({
orgId: values.orgId,
roleMapping: values.roleMapping ?? null,
orgMapping: values.orgMapping ?? null
});
}
}
} else {
const res = await api
.put<AxiosResponse<CreateIdpOrgPolicyResponse>>(
`/idp/${idpId}/org/${values.orgId}`,
{
roleMapping: values.roleMapping,
orgMapping: values.orgMapping
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create role",
description: formatAxiosError(
e,
"An error occurred while creating the role."
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: "Org policy created",
description: "The org policy has been successfully created."
});
setOpen(false);
if (afterCreate) {
afterCreate({
orgId: values.orgId,
roleMapping: values.roleMapping ?? null,
orgMapping: values.orgMapping ?? null
});
}
}
}
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
setOrgsPopoverOpen(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{policyToEdit ? "Edit" : "Create"} Organization Policy
</CredenzaTitle>
<CredenzaDescription>
Configure access for an organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="edit-policy-form"
>
<FormField
control={form.control}
name="orgId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Organization</FormLabel>
{policyToEdit ? (
<Input {...field} disabled />
) : (
<Popover
open={orgsPopoverOpen}
onOpenChange={
setOrgsPopoverOpen
}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value ??
"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 site" />
<CommandList>
<CommandEmpty>
No site found.
</CommandEmpty>
<CommandGroup>
{orgs.map(
(org) => {
if (
policies.find(
(
p
) =>
p.orgId ===
org.orgId
)
) {
return undefined;
}
return (
<CommandItem
value={
org.orgId
}
key={
org.orgId
}
onSelect={() => {
form.setValue(
"orgId",
org.orgId
);
setOrgsPopoverOpen(
false
);
}}
>
<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(s) as defined in the
organization as a string/list of
strings.
</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 thr 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">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-policy-form"
loading={loading}
disabled={loading}
>
{policyToEdit ? "Edit" : "Create"} Policy
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -6,7 +6,7 @@ import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
onAdd?: () => void; onAdd: () => void;
} }
export function PolicyDataTable<TData, TValue>({ export function PolicyDataTable<TData, TValue>({
@ -18,11 +18,11 @@ export function PolicyDataTable<TData, TValue>({
<DataTable <DataTable
columns={columns} columns={columns}
data={data} data={data}
title="Idp organization policies" title="Organization Policies"
searchPlaceholder="Search organization policies..." searchPlaceholder="Search organization policies..."
searchColumn="orgId" searchColumn="orgId"
onAdd={onAdd}
addButtonText="Add Organization Policy" addButtonText="Add Organization Policy"
onAdd={onAdd}
/> />
); );
} }

View file

@ -1,78 +1,62 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
MoreHorizontal,
} from "lucide-react";
import { PolicyDataTable } from "./PolicyDataTable";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { InfoPopup } from "@app/components/ui/info-popup";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { PolicyDataTable } from "./PolicyDataTable";
export interface PolicyRow { export interface PolicyRow {
orgId: string; orgId: string;
roleMapping: string | null; roleMapping?: string;
orgMapping: string | null; orgMapping?: string;
} }
type PolicyTableProps = { interface Props {
policies: PolicyRow[]; policies: PolicyRow[];
onDelete: (orgId: string) => void;
onAdd: () => void; onAdd: () => void;
onEdit: (row: PolicyRow) => void; onEdit: (policy: PolicyRow) => void;
onDelete: (row: PolicyRow) => void; }
};
export default function PolicyTable({ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
policies,
onAdd,
onEdit,
onDelete
}: PolicyTableProps) {
const columns: ColumnDef<PolicyRow>[] = [ const columns: ColumnDef<PolicyRow>[] = [
{ {
id: "actions", id: "dots",
cell: ({ row }) => { cell: ({ row }) => {
const policyRow = row.original; const r = row.original;
return ( return (
<> <DropdownMenu>
<div> <DropdownMenuTrigger asChild>
<DropdownMenu> <Button variant="ghost" className="h-8 w-8 p-0">
<DropdownMenuTrigger asChild> <span className="sr-only">Open menu</span>
<Button <MoreHorizontal className="h-4 w-4" />
variant="ghost" </Button>
className="h-8 w-8 p-0" </DropdownMenuTrigger>
> <DropdownMenuContent align="end">
<span className="sr-only"> <DropdownMenuItem
Open menu onClick={() => {
</span> onDelete(r.orgId);
<MoreHorizontal className="h-4 w-4" /> }}
</Button> >
</DropdownMenuTrigger> <span className="text-red-500">Delete</span>
<DropdownMenuContent align="end"> </DropdownMenuItem>
<DropdownMenuItem </DropdownMenuContent>
onClick={() => onEdit(policyRow)} </DropdownMenu>
>
Edit Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(policyRow)}
>
<span className="text-red-500">
Delete Policy
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
); );
} }
}, },
{ {
id: "orgId",
accessorKey: "orgId", accessorKey: "orgId",
header: ({ column }) => { header: ({ column }) => {
return ( return (
@ -90,24 +74,74 @@ export default function PolicyTable({
}, },
{ {
accessorKey: "roleMapping", accessorKey: "roleMapping",
header: "Role Mapping" 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", accessorKey: "orgMapping",
header: "Organization Mapping" 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: "edit", id: "actions",
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex items-center justify-end space-x-2"> const policy = row.original;
<Button return (
variant="outlinePrimary" <div className="flex items-center justify-end">
onClick={() => onEdit(row.original)} <Button
> variant={"outlinePrimary"}
Edit className="ml-2"
</Button> onClick={() => onEdit(policy)}
</div> >
) Edit
</Button>
</div>
);
}
} }
]; ];

View file

@ -1,8 +1,22 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react";
import { z } from "zod"; 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 { 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 { import {
Form, Form,
FormControl, FormControl,
@ -12,10 +26,33 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useParams } from "next/navigation"; 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 { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@ -23,51 +60,64 @@ import {
SettingsSectionTitle, SettingsSectionTitle,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionFooter SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import Link from "next/link";
import { AxiosResponse } from "axios";
import {
GetIdpResponse,
ListIdpOrgPoliciesResponse
} from "@server/routers/idp";
import PolicyTable, { PolicyRow } from "./PolicyTable";
import EditPolicyForm from "./EditPolicyForm";
import { ListOrgsResponse } from "@server/routers/org";
import type { Org } from "@server/db/schemas";
const DefaultMappingsFormSchema = z.object({ 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(), defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional() defaultOrgMapping: z.string().optional()
}); });
type DefaultMappingsFormValues = z.infer<typeof DefaultMappingsFormSchema>; type PolicyFormValues = z.infer<typeof policyFormSchema>;
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
export default function PoliciesPage() { export default function PoliciesPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const router = useRouter(); const router = useRouter();
const { idpId } = useParams(); const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [addPolicyLoading, setAddPolicyLoading] = useState(false);
const [editPolicyLoading, setEditPolicyLoading] = useState(false);
const [deletePolicyLoading, setDeletePolicyLoading] = useState(false);
const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] = const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] =
useState(false); useState(false);
const [policies, setPolicies] = useState<PolicyRow[]>([]); const [policies, setPolicies] = useState<PolicyRow[]>([]);
const [editPolicyFormOpen, setEditPolicyFormOpen] = useState(false); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [policyToEdit, setPolicyToEdit] = useState<PolicyRow | null>(null); const [showAddDialog, setShowAddDialog] = useState(false);
const [orgs, setOrgs] = useState<Org[]>([]); const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
const defaultMappingsForm = useForm<DefaultMappingsFormValues>({ const form = useForm<PolicyFormValues>({
resolver: zodResolver(DefaultMappingsFormSchema), resolver: zodResolver(policyFormSchema),
defaultValues: { defaultRoleMapping: "", defaultOrgMapping: "" } defaultValues: {
orgId: "",
roleMapping: "",
orgMapping: ""
}
}); });
async function loadIdp() { const defaultMappingsForm = useForm<DefaultMappingsValues>({
resolver: zodResolver(defaultMappingsSchema),
defaultValues: {
defaultRoleMapping: "",
defaultOrgMapping: ""
}
});
const loadIdp = async () => {
try { try {
const res = await api.get<AxiosResponse<GetIdpResponse>>( const res = await api.get<AxiosResponse<GetIdpResponse>>(
`/idp/${idpId}` `/idp/${idpId}`
@ -86,13 +136,11 @@ export default function PoliciesPage() {
variant: "destructive" variant: "destructive"
}); });
} }
} };
async function loadIdpOrgPolicies() { const loadPolicies = async () => {
try { try {
const res = await api.get< const res = await api.get(`/idp/${idpId}/org`);
AxiosResponse<ListIdpOrgPoliciesResponse>
>(`/idp/${idpId}/org`);
if (res.status === 200) { if (res.status === 200) {
setPolicies(res.data.data.policies); setPolicies(res.data.data.policies);
} }
@ -103,13 +151,17 @@ export default function PoliciesPage() {
variant: "destructive" variant: "destructive"
}); });
} }
} };
async function loadOrgs() { const loadOrganizations = async () => {
try { try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>(`/orgs`); const res = await api.get<AxiosResponse<ListOrgsResponse>>("/orgs");
if (res.status === 200) { if (res.status === 200) {
setOrgs(res.data.data.orgs); const existingOrgIds = policies.map((p) => p.orgId);
const availableOrgs = res.data.data.orgs.filter(
(org) => !existingOrgIds.includes(org.orgId)
);
setOrganizations(availableOrgs);
} }
} catch (e) { } catch (e) {
toast({ toast({
@ -118,19 +170,121 @@ export default function PoliciesPage() {
variant: "destructive" variant: "destructive"
}); });
} }
} };
useEffect(() => { useEffect(() => {
const load = async () => { async function load() {
setLoading(true); setPageLoading(true);
await Promise.all([loadIdp(), loadIdpOrgPolicies(), loadOrgs()]); await loadPolicies();
setLoading(false); await loadIdp();
}; setPageLoading(false);
}
load(); load();
}, [idpId, api, router]); }, [idpId]);
async function onDefaultMappingsSubmit(data: DefaultMappingsFormValues) { 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); setUpdateDefaultMappingsLoading(true);
try { try {
const res = await api.post(`/idp/${idpId}/oidc`, { const res = await api.post(`/idp/${idpId}/oidc`, {
@ -152,60 +306,26 @@ export default function PoliciesPage() {
} finally { } finally {
setUpdateDefaultMappingsLoading(false); setUpdateDefaultMappingsLoading(false);
} }
} };
// Button clicks if (pageLoading) {
return null;
function onAdd() {
setPolicyToEdit(null);
setEditPolicyFormOpen(true);
}
function onEdit(row: PolicyRow) {
setPolicyToEdit(row);
setEditPolicyFormOpen(true);
}
function onDelete(row: PolicyRow) {
api.delete(`/idp/${idpId}/org/${row.orgId}`)
.then((res) => {
if (res.status === 200) {
toast({
title: "Success",
description: "Org policy deleted successfully"
});
const p2 = policies.filter((p) => p.orgId !== row.orgId);
setPolicies(p2);
}
})
.catch((e) => {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
});
}
function afterCreate(row: PolicyRow) {
setPolicies([...policies, row]);
}
function afterEdit(row: PolicyRow) {
const p2 = policies.map((p) => (p.orgId === row.orgId ? row : p));
setPolicies(p2);
} }
return ( return (
<> <>
<SettingsContainer> <SettingsContainer>
<Alert variant="neutral" className=""> <Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
About Organization Policies About Organization Policies
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Organization policies are used to configure access Organization policies are used to control access to
control for a specific organization based on the user's organizations based on the user's ID token. You can
ID token. For more information, see{" "} specify JMESPath expressions to extract role and
organization information from the ID token. For more
information, see{" "}
<Link <Link
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision" href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
target="_blank" target="_blank"
@ -224,9 +344,9 @@ export default function PoliciesPage() {
Default Mappings (Optional) Default Mappings (Optional)
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
The default mappings are used when there is no The default mappings are used when when there is not
organization policy defined for an organization. You an organization policy defined for an organization.
can specify the default role and organization You can specify the default role and organization
mappings to fall back to here. mappings to fall back to here.
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@ -234,10 +354,10 @@ export default function PoliciesPage() {
<Form {...defaultMappingsForm}> <Form {...defaultMappingsForm}>
<form <form
onSubmit={defaultMappingsForm.handleSubmit( onSubmit={defaultMappingsForm.handleSubmit(
onDefaultMappingsSubmit onUpdateDefaultMappings
)} )}
className="space-y-4"
id="policy-default-mappings-form" id="policy-default-mappings-form"
className="space-y-4"
> >
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<FormField <FormField
@ -252,18 +372,16 @@ export default function PoliciesPage() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
JMESPath to extract role The result of this
information from the ID
token. The result of this
expression must return the expression must return the
role name(s) as defined in role name as defined in the
the organization as a organization as a string.
string/list of strings.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={defaultMappingsForm.control} control={defaultMappingsForm.control}
name="defaultOrgMapping" name="defaultOrgMapping"
@ -276,13 +394,10 @@ export default function PoliciesPage() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
JMESPath to extract This expression must return
organization information thr org ID or true for the
from the ID token. This user to be allowed to access
expression must return thr the organization.
org ID or true for the user
to be allowed to access the
organization.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -305,22 +420,212 @@ export default function PoliciesPage() {
<PolicyTable <PolicyTable
policies={policies} policies={policies}
onAdd={onAdd} onDelete={onDeletePolicy}
onEdit={onEdit} onAdd={() => {
onDelete={onDelete} loadOrganizations();
/> form.reset({
orgId: "",
<EditPolicyForm roleMapping: "",
open={editPolicyFormOpen} orgMapping: ""
setOpen={setEditPolicyFormOpen} });
policyToEdit={policyToEdit} setEditingPolicy(null);
idpId={idpId!.toString()} setShowAddDialog(true);
orgs={orgs} }}
policies={policies} onEdit={(policy) => {
afterCreate={afterCreate} setEditingPolicy(policy);
afterEdit={afterEdit} form.reset({
orgId: policy.orgId,
roleMapping: policy.roleMapping || "",
orgMapping: policy.orgMapping || ""
});
setShowAddDialog(true);
}}
/> />
</SettingsContainer> </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>
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>
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

@ -410,7 +410,7 @@ export default function Page() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The JMESPath to the user The path to the user
identifier in the ID identifier in the ID
token token
</FormDescription> </FormDescription>
@ -431,7 +431,7 @@ export default function Page() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The JMESPath to the The path to the
user's email in the ID user's email in the ID
token token
</FormDescription> </FormDescription>
@ -452,7 +452,7 @@ export default function Page() {
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The JMESPath to the The path to the
user's name in the ID user's name in the ID
token token
</FormDescription> </FormDescription>

View file

@ -1,6 +1,8 @@
import ProfileIcon from "@app/components/ProfileIcon"; import ProfileIcon from "@app/components/ProfileIcon";
import { Separator } from "@app/components/ui/separator";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { ExternalLink } from "lucide-react";
import { Metadata } from "next"; import { Metadata } from "next";
import { cache } from "react"; import { cache } from "react";
@ -21,17 +23,52 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{user && ( {user && (
<UserProvider user={user}> <UserProvider user={user}>
<div className="p-3"> <div className="p-3 ml-auto">
<ProfileIcon /> <ProfileIcon />
</div> </div>
</UserProvider> </UserProvider>
)} )}
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3"> <div className="w-full max-w-md p-3">{children}</div>
{children}
</div>
</div> </div>
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://code.thetadev.de/ThetaDev/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="Repository"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Open Source</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
</div> </div>
); );
} }

View file

@ -8,7 +8,6 @@ import {
Combine, Combine,
Fingerprint, Fingerprint,
KeyRound, KeyRound,
TicketCheck
} from "lucide-react"; } from "lucide-react";
export const orgLangingNavItems: SidebarNavItem[] = [ export const orgLangingNavItems: SidebarNavItem[] = [
@ -65,14 +64,11 @@ export const orgNavItems: SidebarNavItem[] = [
href: "/{orgId}/settings/share-links", href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" /> icon: <LinkIcon className="h-4 w-4" />
}, },
/*
TODO:
{ {
title: "API Keys", title: "API Keys",
href: "/{orgId}/settings/api-keys", href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />, icon: <KeyRound className="h-4 w-4" />
}, },
*/
{ {
title: "Settings", title: "Settings",
href: "/{orgId}/settings/general", href: "/{orgId}/settings/general",
@ -86,14 +82,11 @@ export const adminNavItems: SidebarNavItem[] = [
href: "/admin/users", href: "/admin/users",
icon: <Users className="h-4 w-4" /> icon: <Users className="h-4 w-4" />
}, },
/*
TODO:
{ {
title: "API Keys", title: "API Keys",
href: "/admin/api-keys", href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />, icon: <KeyRound className="h-4 w-4" />
}, },
*/
{ {
title: "Identity Providers", title: "Identity Providers",
href: "/admin/idp", href: "/admin/idp",

View file

@ -0,0 +1,3 @@
"use client";
export function AuthFooter() {}

View file

@ -0,0 +1,229 @@
"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",
},
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"
}
};
if (root) {
actionsByCategory["Organization"] = {
"List Organizations": "listOrgs",
"Check ID": "checkOrgId",
"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

@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>; return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
} }
export function SettingsSectionGrid({ export function SettingsSectionGrid({

View file

@ -31,7 +31,7 @@ import {
CardTitle CardTitle
} from "@app/components/ui/card"; } from "@app/components/ui/card";
interface DataTableProps<TData, TValue> { type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
title?: string; title?: string;
@ -39,7 +39,11 @@ interface DataTableProps<TData, TValue> {
onAdd?: () => void; onAdd?: () => void;
searchPlaceholder?: string; searchPlaceholder?: string;
searchColumn?: string; searchColumn?: string;
} defaultSort?: {
id: string;
desc: boolean;
};
};
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
columns, columns,
@ -48,9 +52,12 @@ export function DataTable<TData, TValue>({
addButtonText, addButtonText,
onAdd, onAdd,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
searchColumn = "name" searchColumn = "name",
defaultSort
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]); const [globalFilter, setGlobalFilter] = useState<any>([]);

View file

@ -0,0 +1,11 @@
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,12 @@
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,37 @@
"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;