Compare commits
15 commits
Author | SHA1 | Date | |
---|---|---|---|
a727626807 | |||
|
1f584bf3e8 | ||
|
5b0200154a | ||
|
a512148348 | ||
|
d9eccd6c13 | ||
|
492669f68a | ||
|
caded23b51 | ||
|
e9cc48a3ae | ||
|
4ed98c227b | ||
|
f66fb7d4a3 | ||
|
f25990a9a7 | ||
|
21d5b67ef1 | ||
|
198810121c | ||
|
83c0379c6b | ||
|
9a167b5acb |
73 changed files with 5593 additions and 668 deletions
2
LICENSE
2
LICENSE
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -64,14 +64,14 @@ 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)
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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,
|
||||||
|
|
102
server/integrationApiServer.ts
Normal file
102
server/integrationApiServer.ts
Normal 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" }]
|
||||||
|
});
|
||||||
|
}
|
|
@ -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"),
|
||||||
|
https_entrypoint: z.string().optional().default("websecure"),
|
||||||
additional_middlewares: z.array(z.string()).optional()
|
additional_middlewares: z.array(z.string()).optional()
|
||||||
}),
|
})
|
||||||
gerbil: z.object({
|
.optional()
|
||||||
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
.default({}),
|
||||||
|
gerbil: z
|
||||||
|
.object({
|
||||||
|
start_port: portSchema
|
||||||
|
.optional()
|
||||||
|
.default(51820)
|
||||||
|
.transform(stoi)
|
||||||
|
.pipe(portSchema),
|
||||||
base_endpoint: z
|
base_endpoint: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.pipe(z.string())
|
.pipe(z.string())
|
||||||
.transform((url) => url.toLowerCase()),
|
.transform((url) => url.toLowerCase()),
|
||||||
use_subdomain: z.boolean(),
|
use_subdomain: z.boolean().optional().default(false),
|
||||||
subnet_group: z.string(),
|
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||||
block_size: z.number().positive().gt(0),
|
block_size: z.number().positive().gt(0).optional().default(24),
|
||||||
site_block_size: z.number().positive().gt(0)
|
site_block_size: z.number().positive().gt(0).optional().default(30)
|
||||||
}),
|
})
|
||||||
rate_limits: z.object({
|
.optional()
|
||||||
global: z.object({
|
.default({}),
|
||||||
window_minutes: z.number().positive().gt(0),
|
rate_limits: z
|
||||||
max_requests: z.number().positive().gt(0)
|
.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
|
auth: z
|
||||||
.object({
|
.object({
|
||||||
window_minutes: z.number().positive().gt(0),
|
window_minutes: z.number().positive().gt(0),
|
||||||
max_requests: z.number().positive().gt(0)
|
max_requests: z.number().positive().gt(0)
|
||||||
})
|
})
|
||||||
.optional()
|
.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()
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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";
|
||||||
|
|
12
server/middlewares/integration/index.ts
Normal file
12
server/middlewares/integration/index.ts
Normal 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";
|
110
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
110
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
60
server/middlewares/integration/verifyApiKey.ts
Normal file
60
server/middlewares/integration/verifyApiKey.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
81
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
81
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
56
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
56
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
39
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
39
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
61
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
61
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
85
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
85
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
127
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
127
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
89
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
89
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
112
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
112
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
67
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
67
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
96
server/middlewares/verifyApiKeyAccess.ts
Normal file
96
server/middlewares/verifyApiKeyAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
128
server/routers/apiKeys/createOrgApiKey.ts
Normal file
128
server/routers/apiKeys/createOrgApiKey.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
100
server/routers/apiKeys/createRootApiKey.ts
Normal file
100
server/routers/apiKeys/createRootApiKey.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
76
server/routers/apiKeys/deleteApiKey.ts
Normal file
76
server/routers/apiKeys/deleteApiKey.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
99
server/routers/apiKeys/deleteOrgApiKey.ts
Normal file
99
server/routers/apiKeys/deleteOrgApiKey.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
76
server/routers/apiKeys/getApiKey.ts
Normal file
76
server/routers/apiKeys/getApiKey.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
11
server/routers/apiKeys/index.ts
Normal file
11
server/routers/apiKeys/index.ts
Normal 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";
|
113
server/routers/apiKeys/listApiKeyActions.ts
Normal file
113
server/routers/apiKeys/listApiKeyActions.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
116
server/routers/apiKeys/listOrgApiKeys.ts
Normal file
116
server/routers/apiKeys/listOrgApiKeys.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
85
server/routers/apiKeys/listRootApiKeys.ts
Normal file
85
server/routers/apiKeys/listRootApiKeys.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
136
server/routers/apiKeys/setApiKeyActions.ts
Normal file
136
server/routers/apiKeys/setApiKeyActions.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
117
server/routers/apiKeys/setApiKeyOrgs.ts
Normal file
117
server/routers/apiKeys/setApiKeyOrgs.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,78 @@
|
||||||
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...');
|
||||||
|
|
||||||
|
@ -56,6 +128,9 @@ function runTests() {
|
||||||
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!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
|
|
494
server/routers/integration.ts
Normal file
494
server/routers/integration.ts
Normal 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
|
||||||
|
);
|
|
@ -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))
|
||||||
|
|
|
@ -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: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
28
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal file
28
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
199
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal file
199
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal 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`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
57
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal file
57
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
8
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal file
8
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal 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`);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
407
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal file
407
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
44
src/app/[orgId]/settings/api-keys/page.tsx
Normal file
44
src/app/[orgId]/settings/api-keys/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
53
src/app/admin/api-keys/ApiKeysDataTable.tsx
Normal file
53
src/app/admin/api-keys/ApiKeysDataTable.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
194
src/app/admin/api-keys/ApiKeysTable.tsx
Normal file
194
src/app/admin/api-keys/ApiKeysTable.tsx
Normal 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`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
57
src/app/admin/api-keys/[apiKeyId]/layout.tsx
Normal file
57
src/app/admin/api-keys/[apiKeyId]/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
8
src/app/admin/api-keys/[apiKeyId]/page.tsx
Normal file
8
src/app/admin/api-keys/[apiKeyId]/page.tsx
Normal 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`);
|
||||||
|
}
|
134
src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx
Normal file
134
src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
397
src/app/admin/api-keys/create/page.tsx
Normal file
397
src/app/admin/api-keys/create/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
41
src/app/admin/api-keys/page.tsx
Normal file
41
src/app/admin/api-keys/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
<span className="sr-only">Open menu</span>
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
Open menu
|
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onEdit(policyRow)}
|
onClick={() => {
|
||||||
|
onDelete(r.orgId);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit Policy
|
<span className="text-red-500">Delete</span>
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDelete(policyRow)}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">
|
|
||||||
Delete Policy
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outlinePrimary"
|
variant={"outlinePrimary"}
|
||||||
onClick={() => onEdit(row.original)}
|
className="ml-2"
|
||||||
|
onClick={() => onEdit(policy)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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(() => {
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await Promise.all([loadIdp(), loadIdpOrgPolicies(), loadOrgs()]);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setPageLoading(true);
|
||||||
|
await loadPolicies();
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
3
src/components/AuthFooter.tsx
Normal file
3
src/components/AuthFooter.tsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export function AuthFooter() {}
|
229
src/components/PermissionsSelectBox.tsx
Normal file
229
src/components/PermissionsSelectBox.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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({
|
||||||
|
|
|
@ -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>([]);
|
||||||
|
|
||||||
|
|
11
src/contexts/apiKeyContext.ts
Normal file
11
src/contexts/apiKeyContext.ts
Normal 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;
|
12
src/hooks/useApikeyContext.ts
Normal file
12
src/hooks/useApikeyContext.ts
Normal 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;
|
||||||
|
}
|
37
src/providers/ApiKeyProvider.tsx
Normal file
37
src/providers/ApiKeyProvider.tsx
Normal 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;
|
Loading…
Add table
Reference in a new issue