diff --git a/docker-compose.drizzle.yml b/docker-compose.drizzle.yml new file mode 100644 index 00000000..ab7a4048 --- /dev/null +++ b/docker-compose.drizzle.yml @@ -0,0 +1,15 @@ +services: + drizzle-gateway: + image: ghcr.io/drizzle-team/gateway:latest + ports: + - "4984:4983" + depends_on: + - db + environment: + - STORE_PATH=/app + - DATABASE_URL=postgresql://postgres:password@db:5432/postgres + volumes: + - drizzle-gateway-data:/app + +volumes: + drizzle-gateway-data: diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 2a45f129..764c0915 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -11,7 +11,7 @@ services: - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 - restart: no + restart: no redis: image: redis:latest # Use the latest Redis image diff --git a/messages/en-US.json b/messages/en-US.json index b34e3887..1e891693 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1163,6 +1163,25 @@ "sidebarLicense": "License", "sidebarClients": "Clients", "sidebarDomains": "Domains", + "sidebarBluePrints": "Blueprints", + "blueprints": "Blueprints", + "blueprintsDescription": "Blueprints are declarative YAML configurations that define your resources and their settings", + "blueprintAdd": "Add Blueprint", + "blueprintGoBack": "See all Blueprints", + "blueprintCreate": "Create Blueprint", + "blueprintCreateDescription2": "Follow the steps below to create and apply a new blueprint", + "blueprintDetails": "Blueprint details", + "blueprintDetailsDescription": "See the blueprint run details", + "blueprintInfo": "Blueprint Information", + "message": "Message", + "blueprintContentsDescription": "Define the YAML content describing your infrastructure", + "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", + "blueprintErrorCreate": "Error creating blueprint", + "searchBlueprintProgress": "Search blueprints...", + "appliedAt": "Applied At", + "source": "Source", + "contents": "Contents", + "parsedContents": "Parsed Contents", "enableDockerSocket": "Enable Docker Blueprint", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.", "enableDockerSocketLink": "Learn More", diff --git a/next.config.mjs b/next.config.mjs index c870f1c1..d771dbca 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -7,7 +7,8 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true }, - output: "standalone" + output: "standalone", + }; export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index d710b33a..7b3f6c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.908.0", "@hookform/resolvers": "5.2.2", + "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", @@ -73,10 +74,12 @@ "next": "15.5.6", "next-intl": "^4.3.12", "next-themes": "0.4.6", + "nextjs-toploader": "^3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.10", "npm": "^11.6.2", + "nprogress": "^0.2.0", "oslo": "1.2.1", "pg": "^8.16.2", "posthog-node": "^5.10.4", @@ -100,6 +103,7 @@ "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", + "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", "zod-validation-error": "3.5.2" @@ -107,6 +111,7 @@ "devDependencies": { "@dotenvx/dotenvx": "1.51.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@faker-js/faker": "^10.1.0", "@react-email/preview-server": "4.3.2", "@tailwindcss/postcss": "^4.1.16", "@types/better-sqlite3": "7.6.12", @@ -120,6 +125,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "24.9.2", "@types/nodemailer": "7.0.3", + "@types/nprogress": "^0.2.3", "@types/pg": "8.15.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", @@ -3104,6 +3110,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", + "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -3849,6 +3872,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.6.1.tgz", + "integrity": "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -8681,6 +8727,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/nprogress": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz", + "integrity": "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/pg": { "version": "8.15.6", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", @@ -11186,6 +11239,13 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -14975,6 +15035,30 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", @@ -15202,6 +15286,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nextjs-toploader": { + "version": "3.9.17", + "resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz", + "integrity": "sha512-9OF0KSSLtoSAuNg2LZ3aTl4hR9mBDj5L9s9DZiFCbMlXehyICGjkIz5dVGzuATU2bheJZoBdFgq9w07AKSuQQw==", + "license": "MIT", + "dependencies": { + "nprogress": "^0.2.0", + "prop-types": "^15.8.1" + }, + "funding": { + "url": "https://buymeacoffee.com/thesgj" + }, + "peerDependencies": { + "next": ">= 6.0.0", + "react": ">= 16.0.0", + "react-dom": ">= 16.0.0" + } + }, "node_modules/node-abi": { "version": "3.78.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", @@ -17732,6 +17834,12 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", + "license": "MIT" + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -20579,6 +20687,12 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index ab6e4d59..496d4abb 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@asteasolutions/zod-to-openapi": "^7.3.4", "@aws-sdk/client-s3": "3.908.0", "@hookform/resolvers": "5.2.2", + "@monaco-editor/react": "^4.7.0", "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", @@ -96,10 +97,12 @@ "next": "15.5.6", "next-intl": "^4.3.12", "next-themes": "0.4.6", + "nextjs-toploader": "^3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.10", "npm": "^11.6.2", + "nprogress": "^0.2.0", "oslo": "1.2.1", "pg": "^8.16.2", "posthog-node": "^5.10.4", @@ -123,6 +126,7 @@ "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", + "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", "zod-validation-error": "3.5.2" @@ -130,6 +134,7 @@ "devDependencies": { "@dotenvx/dotenvx": "1.51.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@faker-js/faker": "^10.1.0", "@react-email/preview-server": "4.3.2", "@tailwindcss/postcss": "^4.1.16", "@types/better-sqlite3": "7.6.12", @@ -141,6 +146,7 @@ "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "^9.0.10", + "@types/nprogress": "^0.2.3", "@types/node": "24.9.2", "@types/nodemailer": "7.0.3", "@types/pg": "8.15.6", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 34278dfb..d08457e5 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -119,6 +119,8 @@ export enum ActionsEnum { updateLoginPage = "updateLoginPage", getLoginPage = "getLoginPage", deleteLoginPage = "deleteLoginPage", + listBlueprints = "listBlueprints", + getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint", viewLogs = "viewLogs", exportLogs = "exportLogs" @@ -198,7 +200,6 @@ export async function checkUserActionPermission( .limit(1); return roleActionPermission.length > 0; - } catch (error) { console.error("Error checking user action permission:", error); throw createHttpError( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 8d1987f1..ffbe820c 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -25,7 +25,6 @@ export const domains = pgTable("domains", { preferWildcardCert: boolean("preferWildcardCert") }); - export const dnsRecords = pgTable("dnsRecords", { id: serial("id").primaryKey(), domainId: varchar("domainId") @@ -34,7 +33,7 @@ export const dnsRecords = pgTable("dnsRecords", { recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: varchar("baseDomain"), value: varchar("value").notNull(), - verified: boolean("verified").notNull().default(false), + verified: boolean("verified").notNull().default(false) }); export const orgs = pgTable("orgs", { @@ -703,6 +702,21 @@ export const setupTokens = pgTable("setupTokens", { dateUsed: varchar("dateUsed") }); +// Blueprint runs +export const blueprints = pgTable("blueprints", { + blueprintId: serial("blueprintId").primaryKey(), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + name: varchar("name").notNull(), + source: varchar("source").notNull(), + createdAt: integer("createdAt").notNull(), + succeeded: boolean("succeeded").notNull(), + contents: text("contents").notNull(), + message: text("message") +}); export const requestAuditLog = pgTable( "requestAuditLog", { @@ -790,6 +804,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type Blueprint = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 211ba8ea..05870cb9 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -13,12 +13,17 @@ bootstrapVolume(); function createDb() { const sqlite = new Database(location); - return DrizzleSqlite(sqlite, { schema }); + return DrizzleSqlite(sqlite, { + schema, + logger: process.env.NODE_ENV === "development" + }); } export const db = createDb(); export default db; -export type Transaction = Parameters[0]>[0]; +export type Transaction = Parameters< + Parameters<(typeof db)["transaction"]>[0] +>[0]; function checkFileExists(filePath: string): boolean { try { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index aed12477..13453d2e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -749,6 +749,23 @@ export const idpOrg = sqliteTable("idpOrg", { orgMapping: text("orgMapping") }); +// Blueprint runs +export const blueprints = sqliteTable("blueprints", { + blueprintId: integer("blueprintId").primaryKey({ + autoIncrement: true + }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + name: text("name").notNull(), + source: text("source").notNull(), + createdAt: integer("createdAt").notNull(), + succeeded: integer("succeeded", { mode: "boolean" }).notNull(), + contents: text("contents").notNull(), + message: text("message") +}); export const requestAuditLog = sqliteTable( "requestAuditLog", { @@ -837,6 +854,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type Blueprint = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 633c12f1..697e8093 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -1,22 +1,35 @@ -import { db, newts, Target } from "@server/db"; +import { db, newts, blueprints, Blueprint } from "@server/db"; import { Config, ConfigSchema } from "./types"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { resources, targets, sites } from "@server/db"; -import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; +import { sites } from "@server/db"; +import { eq, and, isNotNull } from "drizzle-orm"; import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; import { addTargets as addClientTargets } from "@server/routers/client/targets"; import { ClientResourcesResults, updateClientResources } from "./clientResources"; +import { BlueprintSource } from "@server/routers/blueprints/types"; +import { stringify as stringifyYaml } from "yaml"; +import { faker } from "@faker-js/faker"; -export async function applyBlueprint( - orgId: string, - configData: unknown, - siteId?: number -): Promise { +type ApplyBlueprintArgs = { + orgId: string; + configData: unknown; + name?: string; + siteId?: number; + source?: BlueprintSource; +}; + +export async function applyBlueprint({ + orgId, + configData, + siteId, + name, + source = "API" +}: ApplyBlueprintArgs): Promise { // Validate the input data const validationResult = ConfigSchema.safeParse(configData); if (!validationResult.success) { @@ -24,6 +37,9 @@ export async function applyBlueprint( } const config: Config = validationResult.data; + let blueprintSucceeded: boolean = false; + let blueprintMessage: string; + let error: any | null = null; try { let proxyResourcesResults: ProxyResourcesResults = []; @@ -120,10 +136,42 @@ export async function applyBlueprint( ); } } - } catch (error) { - logger.error(`Failed to update database from config: ${error}`); - throw error; + + blueprintSucceeded = true; + blueprintMessage = "Blueprint applied successfully"; + } catch (err) { + blueprintSucceeded = false; + blueprintMessage = `Blueprint applied with errors: ${err}`; + logger.error(blueprintMessage); + error = err; } + + let blueprint: Blueprint | null = null; + await db.transaction(async (trx) => { + const newBlueprint = await trx + .insert(blueprints) + .values({ + orgId, + name: + name ?? + `${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`, + contents: stringifyYaml(configData), + createdAt: Math.floor(Date.now() / 1000), + succeeded: blueprintSucceeded, + message: blueprintMessage, + source + }) + .returning(); + + blueprint = newBlueprint[0]; + }); + + if (!blueprint || (source !== "UI" && !blueprintSucceeded)) { + // ^^^^^^^^^^^^^^^ The UI considers a failed blueprint as a valid response + throw error ?? "Unknown Server Error"; + } + + return blueprint; } // await updateDatabaseFromConfig("org_i21aifypnlyxur2", { diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts index 2afba84c..ab185caa 100644 --- a/server/lib/blueprints/applyNewtDockerBlueprint.ts +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -30,7 +30,12 @@ export async function applyNewtDockerBlueprint( logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`); // Update the blueprint in the database - await applyBlueprint(site.orgId, blueprint, site.siteId); + await applyBlueprint({ + orgId: site.orgId, + configData: blueprint, + siteId: site.siteId, + source: "NEWT" + }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); await sendToClient(newtId, { diff --git a/server/nextServer.ts b/server/nextServer.ts index 78169f03..5302b9c8 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -9,7 +9,7 @@ const nextPort = config.getRawConfig().server.next_port; export async function createNextServer() { // const app = next({ dev }); - const app = next({ dev: process.env.ENVIRONMENT !== "prod" }); + const app = next({ dev: process.env.ENVIRONMENT !== "prod", turbopack: true }); const handle = app.getRequestHandler(); await app.prepare(); diff --git a/server/openApi.ts b/server/openApi.ts index 32cdb67b..68b05a30 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -15,5 +15,6 @@ export enum OpenAPITags { Idp = "Identity Provider", Client = "Client", ApiKey = "API Key", - Domain = "Domain" + Domain = "Domain", + Blueprint = "Blueprint" } diff --git a/server/routers/org/applyBlueprint.ts b/server/routers/blueprints/applyJSONBlueprint.ts similarity index 80% rename from server/routers/org/applyBlueprint.ts rename to server/routers/blueprints/applyJSONBlueprint.ts index 982258ee..6860307b 100644 --- a/server/routers/org/applyBlueprint.ts +++ b/server/routers/blueprints/applyJSONBlueprint.ts @@ -1,30 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; -import { eq } from "drizzle-orm"; -import { - apiKeyOrg, - apiKeys, - domains, - Org, - orgDomains, - orgs, - roleActions, - roles, - userOrgs, - users, - actions -} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; -import { defaultRoleAllowedActions } from "../role"; import { OpenAPITags, registry } from "@server/openApi"; -import { isValidCIDR } from "@server/lib/validators"; -import { applyBlueprint as applyBlueprintFunc } from "@server/lib/blueprints/applyBlueprint"; +import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; const applyBlueprintSchema = z .object({ @@ -41,8 +23,8 @@ const applyBlueprintParamsSchema = z registry.registerPath({ method: "put", path: "/org/{orgId}/blueprint", - description: "Apply a base64 encoded blueprint to an organization", - tags: [OpenAPITags.Org], + description: "Apply a base64 encoded JSON blueprint to an organization", + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { @@ -56,7 +38,7 @@ registry.registerPath({ responses: {} }); -export async function applyBlueprint( +export async function applyJSONBlueprint( req: Request, res: Response, next: NextFunction @@ -100,7 +82,11 @@ export async function applyBlueprint( const blueprintParsed = JSON.parse(decoded); // Update the blueprint in the database - await applyBlueprintFunc(orgId, blueprintParsed); + await applyBlueprint({ + orgId, + configData: blueprintParsed, + source: "API" + }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); return next( diff --git a/server/routers/blueprints/applyYAMLBlueprint.ts b/server/routers/blueprints/applyYAMLBlueprint.ts new file mode 100644 index 00000000..21402cd0 --- /dev/null +++ b/server/routers/blueprints/applyYAMLBlueprint.ts @@ -0,0 +1,146 @@ +import { OpenAPITags, registry } from "@server/openApi"; +import z from "zod"; +import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; +import { NextFunction, Request, Response } from "express"; +import logger from "@server/logger"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromZodError } from "zod-validation-error"; +import response from "@server/lib/response"; +import { type Blueprint } from "@server/db"; +import { parse as parseYaml } from "yaml"; +import { ConfigSchema } from "@server/lib/blueprints/types"; + +const applyBlueprintSchema = z + .object({ + name: z.string().min(1).max(255), + blueprint: z + .string() + .min(1) + .superRefine((val, ctx) => { + try { + parseYaml(val); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }) + }) + .strict(); + +const applyBlueprintParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export type CreateBlueprintResponse = Blueprint; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/blueprint", + description: "Create and apply a YAML blueprint to an organization", + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + request: { + params: applyBlueprintParamsSchema, + body: { + content: { + "application/json": { + schema: applyBlueprintSchema + } + } + } + }, + responses: {} +}); + +export async function applyYAMLBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = applyBlueprintParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = applyBlueprintSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedBody.error) + ) + ); + } + + const { blueprint: contents, name } = parsedBody.data; + + logger.debug(`Received blueprint:`, contents); + + const parsedConfig = parseYaml(contents); + // apply the validation in advance so that error concerning the format are ruled out first + const validationResult = ConfigSchema.safeParse(parsedConfig); + if (!validationResult.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(validationResult.error) + ) + ); + } + + let blueprint: Blueprint | null = null; + + let error: string | null = null; + try { + blueprint = await applyBlueprint({ + orgId, + name, + source: "UI", + configData: parsedConfig + }); + } catch (err) { + // We do nothing, the error is thrown for the other APIs & websockets for backwards compatibility + // for this API, the error is already saved in the blueprint and we don't need to handle it + logger.error(err); + if (err instanceof Error) { + error = err.message; + } + } + + if (!blueprint) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error + ? error + : "An unknown error occurred while applying the blueprint" + ) + ); + } + + return response(res, { + data: blueprint, + success: true, + error: false, + message: "Done", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/blueprints/getBlueprint.ts b/server/routers/blueprints/getBlueprint.ts new file mode 100644 index 00000000..3d3f7366 --- /dev/null +++ b/server/routers/blueprints/getBlueprint.ts @@ -0,0 +1,110 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { blueprints, orgs } from "@server/db"; +import { eq, and } 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 stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { BlueprintData } from "./types"; + +const getBlueprintSchema = z + .object({ + blueprintId: z + .string() + .transform(stoi) + .pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +async function query(blueprintId: number, orgId: string) { + // Get the client + const [blueprint] = await db + .select({ + blueprintId: blueprints.blueprintId, + name: blueprints.name, + source: blueprints.source, + succeeded: blueprints.succeeded, + orgId: blueprints.orgId, + createdAt: blueprints.createdAt, + message: blueprints.message, + contents: blueprints.contents + }) + .from(blueprints) + .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) + .where( + and( + eq(blueprints.blueprintId, blueprintId), + eq(blueprints.orgId, orgId) + ) + ) + .limit(1); + + if (!blueprint) { + return null; + } + + return blueprint; +} + +export type GetBlueprintResponse = BlueprintData; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/blueprint/{blueprintId}", + description: "Get a blueprint by its blueprint ID.", + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + request: { + params: getBlueprintSchema + }, + responses: {} +}); + +export async function getBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getBlueprintSchema.safeParse(req.params); + if (!parsedParams.success) { + logger.error( + `Error parsing params: ${fromError(parsedParams.error).toString()}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, blueprintId } = parsedParams.data; + + const blueprint = await query(blueprintId, orgId); + + if (!blueprint) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + return response(res, { + data: blueprint as BlueprintData, + success: true, + error: false, + message: "Client retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/blueprints/index.ts b/server/routers/blueprints/index.ts new file mode 100644 index 00000000..dddc6063 --- /dev/null +++ b/server/routers/blueprints/index.ts @@ -0,0 +1,4 @@ +export * from "./listBlueprints"; +export * from "./applyYAMLBlueprint"; +export * from "./applyJSONBlueprint"; +export * from "./getBlueprint"; diff --git a/server/routers/blueprints/listBlueprints.ts b/server/routers/blueprints/listBlueprints.ts new file mode 100644 index 00000000..5ae8b211 --- /dev/null +++ b/server/routers/blueprints/listBlueprints.ts @@ -0,0 +1,144 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, blueprints, orgs } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql, eq, desc } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { BlueprintData } from "./types"; + +const listBluePrintsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listBluePrintsSchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function queryBlueprints(orgId: string, limit: number, offset: number) { + const res = await db + .select({ + blueprintId: blueprints.blueprintId, + name: blueprints.name, + source: blueprints.source, + succeeded: blueprints.succeeded, + orgId: blueprints.orgId, + createdAt: blueprints.createdAt + }) + .from(blueprints) + .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) + .where(eq(blueprints.orgId, orgId)) + .orderBy(desc(blueprints.createdAt)) + .limit(limit) + .offset(offset); + return res; +} + +export type ListBlueprintsResponse = { + blueprints: NonNullable< + Pick< + BlueprintData, + | "blueprintId" + | "name" + | "source" + | "succeeded" + | "orgId" + | "createdAt" + >[] + >; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/blueprints", + description: "List all blueprints for a organization.", + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + request: { + params: z.object({ + orgId: z.string() + }), + query: listBluePrintsSchema + }, + responses: {} +}); + +export async function listBlueprints( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listBluePrintsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listBluePrintsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + const blueprintsList = await queryBlueprints( + orgId.toString(), + limit, + offset + ); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(blueprints); + + return response(res, { + data: { + blueprints: + blueprintsList as ListBlueprintsResponse["blueprints"], + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Blueprints retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/blueprints/types.ts b/server/routers/blueprints/types.ts new file mode 100644 index 00000000..52d61300 --- /dev/null +++ b/server/routers/blueprints/types.ts @@ -0,0 +1,7 @@ +import type { Blueprint } from "@server/db"; + +export type BlueprintSource = "API" | "UI" | "NEWT"; + +export type BlueprintData = Omit & { + source: BlueprintSource; +}; diff --git a/server/routers/external.ts b/server/routers/external.ts index d2258652..5c235902 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -13,6 +13,7 @@ import * as siteResource from "./siteResource"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; +import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; import HttpCode from "@server/types/HttpCode"; @@ -895,6 +896,27 @@ authenticated.get( logs.exportRequestAuditLogs ); +authenticated.get( + "/org/:orgId/blueprints", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listBlueprints), + blueprints.listBlueprints +); + +authenticated.put( + "/org/:orgId/blueprint", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.applyBlueprint), + blueprints.applyYAMLBlueprint +); + +authenticated.get( + "/org/:orgId/blueprint/:blueprintId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getBlueprint), + blueprints.getBlueprint +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9cd2d3ca..7164c1de 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1,5 +1,6 @@ import * as site from "./site"; import * as org from "./org"; +import * as blueprints from "./blueprints"; import * as resource from "./resource"; import * as domain from "./domain"; import * as target from "./target"; @@ -52,7 +53,7 @@ authenticated.put( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), logActionAudit(ActionsEnum.createOrg), - org.createOrg, + org.createOrg ); authenticated.get( @@ -74,7 +75,7 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), - org.updateOrg, + org.updateOrg ); authenticated.delete( @@ -82,7 +83,7 @@ authenticated.delete( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), - org.deleteOrg, + org.deleteOrg ); authenticated.put( @@ -90,7 +91,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), logActionAudit(ActionsEnum.createSite), - site.createSite, + site.createSite ); authenticated.get( @@ -126,7 +127,7 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), - site.updateSite, + site.updateSite ); authenticated.delete( @@ -134,7 +135,7 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), logActionAudit(ActionsEnum.deleteSite), - site.deleteSite, + site.deleteSite ); authenticated.get( @@ -149,7 +150,7 @@ authenticated.put( verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), - siteResource.createSiteResource, + siteResource.createSiteResource ); authenticated.get( @@ -183,7 +184,7 @@ authenticated.post( verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource, + siteResource.updateSiteResource ); authenticated.delete( @@ -193,7 +194,7 @@ authenticated.delete( verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource, + siteResource.deleteSiteResource ); authenticated.put( @@ -201,7 +202,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.put( @@ -209,7 +210,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.get( @@ -245,7 +246,7 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), - user.inviteUser, + user.inviteUser ); authenticated.get( @@ -274,7 +275,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), - resource.updateResource, + resource.updateResource ); authenticated.delete( @@ -282,7 +283,7 @@ authenticated.delete( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), - resource.deleteResource, + resource.deleteResource ); authenticated.put( @@ -290,7 +291,7 @@ authenticated.put( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( @@ -305,7 +306,7 @@ authenticated.put( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), - resource.createResourceRule, + resource.createResourceRule ); authenticated.get( @@ -320,7 +321,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), - resource.updateResourceRule, + resource.updateResourceRule ); authenticated.delete( @@ -328,7 +329,7 @@ authenticated.delete( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule, + resource.deleteResourceRule ); authenticated.get( @@ -343,7 +344,7 @@ authenticated.post( verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), - target.updateTarget, + target.updateTarget ); authenticated.delete( @@ -351,7 +352,7 @@ authenticated.delete( verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), logActionAudit(ActionsEnum.deleteTarget), - target.deleteTarget, + target.deleteTarget ); authenticated.put( @@ -359,7 +360,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( @@ -374,7 +375,7 @@ authenticated.delete( verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.get( @@ -390,7 +391,7 @@ authenticated.post( verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); authenticated.post( @@ -399,7 +400,7 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -408,7 +409,7 @@ authenticated.post( verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( @@ -416,7 +417,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), - resource.setResourcePassword, + resource.setResourcePassword ); authenticated.post( @@ -424,7 +425,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), - resource.setResourcePincode, + resource.setResourcePincode ); authenticated.post( @@ -432,7 +433,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth, + resource.setResourceHeaderAuth ); authenticated.post( @@ -440,7 +441,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist, + resource.setResourceWhitelist ); authenticated.post( @@ -469,7 +470,7 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken, + accessToken.generateAccessToken ); authenticated.delete( @@ -477,7 +478,7 @@ authenticated.delete( verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken, + accessToken.deleteAccessToken ); authenticated.get( @@ -506,7 +507,7 @@ authenticated.post( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), logActionAudit(ActionsEnum.updateUser), - user.updateUser2FA, + user.updateUser2FA ); authenticated.get( @@ -528,7 +529,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), - user.createOrgUser, + user.createOrgUser ); authenticated.post( @@ -537,7 +538,7 @@ authenticated.post( verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), - user.updateOrgUser, + user.updateOrgUser ); authenticated.delete( @@ -546,7 +547,7 @@ authenticated.delete( verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -567,7 +568,7 @@ authenticated.post( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions, + apiKeys.setApiKeyActions ); authenticated.get( @@ -582,7 +583,7 @@ authenticated.put( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey, + apiKeys.createOrgApiKey ); authenticated.delete( @@ -590,7 +591,7 @@ authenticated.delete( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey, + apiKeys.deleteApiKey ); authenticated.put( @@ -598,7 +599,7 @@ authenticated.put( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), - idp.createOidcIdp, + idp.createOidcIdp ); authenticated.post( @@ -606,7 +607,7 @@ authenticated.post( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), - idp.updateOidcIdp, + idp.updateOidcIdp ); authenticated.get( @@ -628,7 +629,7 @@ authenticated.put( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), logActionAudit(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy, + idp.createIdpOrgPolicy ); authenticated.post( @@ -636,7 +637,7 @@ authenticated.post( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), logActionAudit(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy, + idp.updateIdpOrgPolicy ); authenticated.delete( @@ -644,7 +645,7 @@ authenticated.delete( verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), logActionAudit(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy, + idp.deleteIdpOrgPolicy ); authenticated.get( @@ -684,7 +685,7 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), - client.createClient, + client.createClient ); authenticated.delete( @@ -693,7 +694,7 @@ authenticated.delete( verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), - client.deleteClient, + client.deleteClient ); authenticated.post( @@ -702,7 +703,7 @@ authenticated.post( verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), logActionAudit(ActionsEnum.updateClient), - client.updateClient, + client.updateClient ); authenticated.put( @@ -710,5 +711,5 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), logActionAudit(ActionsEnum.applyBlueprint), - org.applyBlueprint, + blueprints.applyJSONBlueprint ); diff --git a/server/routers/newt/handleApplyBlueprintMessage.ts b/server/routers/newt/handleApplyBlueprintMessage.ts index 62802fff..908ff048 100644 --- a/server/routers/newt/handleApplyBlueprintMessage.ts +++ b/server/routers/newt/handleApplyBlueprintMessage.ts @@ -43,7 +43,12 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => { try { const blueprintParsed = JSON.parse(blueprint); // Update the blueprint in the database - await applyBlueprint(site.orgId, blueprintParsed, site.siteId); + await applyBlueprint({ + orgId: site.orgId, + configData: blueprintParsed, + siteId: site.siteId, + source: "NEWT" + }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); return { diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 8ce01e92..b0db28d1 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -7,5 +7,4 @@ export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; -export * from "./applyBlueprint"; export * from "./checkOrgUserAccess"; diff --git a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx new file mode 100644 index 00000000..fe3ae4b9 --- /dev/null +++ b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx @@ -0,0 +1,58 @@ +import BlueprintDetailsForm from "@app/components/BlueprintDetailsForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import { GetBlueprintResponse } from "@server/routers/blueprints"; +import { AxiosResponse } from "axios"; +import { ArrowLeft } from "lucide-react"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +type BluePrintsPageProps = { + params: Promise<{ orgId: string; blueprintId: string }>; +}; + +export const metadata: Metadata = { + title: "Blueprint Detail" +}; + +export default async function BluePrintDetailPage(props: BluePrintsPageProps) { + const params = await props.params; + let org = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}`); + } + + let blueprint = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/blueprint/${params.blueprintId}`, + await authCookieHeader() + ); + + blueprint = res.data.data; + } catch (e) { + console.error(e); + notFound(); + } + + const t = await getTranslations(); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/blueprints/create/page.tsx b/src/app/[orgId]/settings/blueprints/create/page.tsx new file mode 100644 index 00000000..e7a0490e --- /dev/null +++ b/src/app/[orgId]/settings/blueprints/create/page.tsx @@ -0,0 +1,42 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; + +import type { Metadata } from "next"; +import { ArrowLeft } from "lucide-react"; +import CreateBlueprintForm from "@app/components/CreateBlueprintForm"; + +export interface CreateBlueprintPageProps { + params: Promise<{ orgId: string }>; +} + +export const metadata: Metadata = { + title: "Create blueprint" +}; + +export default async function CreateBlueprintPage( + props: CreateBlueprintPageProps +) { + const t = await getTranslations(); + + const orgId = (await props.params).orgId; + + return ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/blueprints/page.tsx b/src/app/[orgId]/settings/blueprints/page.tsx new file mode 100644 index 00000000..f71e8375 --- /dev/null +++ b/src/app/[orgId]/settings/blueprints/page.tsx @@ -0,0 +1,57 @@ +import BlueprintsTable, { + type BlueprintRow +} from "@app/components/BlueprintsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import { ListBlueprintsResponse } from "@server/routers/blueprints"; +import { AxiosResponse } from "axios"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +type BluePrintsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const metadata: Metadata = { + title: "Blueprints" +}; + +export default async function BluePrintsPage(props: BluePrintsPageProps) { + const params = await props.params; + + let blueprints: BlueprintRow[] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/blueprints`, + await authCookieHeader() + ); + + blueprints = res.data.data.blueprints; + } catch (e) { + console.error(e); + } + + let org = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}`); + } + + const t = await getTranslations(); + + return ( + + + + + ); +} diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index 2c667b3a..04db84b3 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -9,7 +9,8 @@ import { GetOrgResponse } from "@server/routers/org"; import { redirect } from "next/navigation"; import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; -import { toUnicode } from 'punycode'; +import { toUnicode } from "punycode"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type Props = { params: Promise<{ orgId: string }>; @@ -20,15 +21,16 @@ export default async function DomainsPage(props: Props) { let domains: DomainRow[] = []; try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/domains`, await authCookieHeader()); + const res = await internal.get>( + `/org/${params.orgId}/domains`, + await authCookieHeader() + ); const rawDomains = res.data.data.domains as DomainRow[]; domains = rawDomains.map((domain) => ({ ...domain, - baseDomain: toUnicode(domain.baseDomain), + baseDomain: toUnicode(domain.baseDomain) })); } catch (e) { console.error(e); @@ -36,21 +38,12 @@ export default async function DomainsPage(props: Props) { let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); } - if (!org) { - } - const t = await getTranslations(); return ( diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index d35af6e6..5b12ea3a 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,25 +1,15 @@ import { Metadata } from "next"; -import { - Combine, - KeyRound, - LinkIcon, - Settings, - Users, - Waypoints, - Workflow -} from "lucide-react"; + import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; -import { ListOrgsResponse } from "@server/routers/org"; -import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { ListUserOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; -import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { orgNavSections } from "@app/app/navigation"; @@ -27,7 +17,10 @@ import { orgNavSections } from "@app/app/navigation"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + title: { + template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + default: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}` + }, description: "" }; @@ -86,7 +79,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( - + {children} diff --git a/src/app/[orgId]/settings/not-found.tsx b/src/app/[orgId]/settings/not-found.tsx new file mode 100644 index 00000000..d3ca37cc --- /dev/null +++ b/src/app/[orgId]/settings/not-found.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from "next-intl/server"; + +export default async function NotFound() { + const t = await getTranslations(); + + return ( +
+

404

+

+ {t("pageNotFound")} +

+

+ {t("pageNotFoundDescription")} +

+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index e643cfb6..1147e37e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -40,7 +40,7 @@ } .dark { - --background: oklch(0.20 0.006 285.885); + --background: oklch(0.2 0.006 285.885); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); @@ -140,3 +140,7 @@ p { word-break: keep-all; white-space: normal; } + +#nprogress .bar { + background: var(--color-primary) !important; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 144e7850..cc586dfb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,6 +18,7 @@ import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; import { Toaster } from "@app/components/ui/toaster"; import { build } from "@server/build"; +import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; export const metadata: Metadata = { @@ -85,6 +86,7 @@ export default async function RootLayout({ return ( + {build === "saas" && (