Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2025-10-29 20:46:13 -07:00
45 changed files with 1695 additions and 142 deletions

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true
},
output: "standalone"
output: "standalone",
};
export default withNextIntl(nextConfig);

114
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
export type Blueprint = InferSelectModel<typeof blueprints>;
export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;

View File

@@ -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<Parameters<typeof db["transaction"]>[0]>[0];
export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0]
>[0];
function checkFileExists(filePath: string): boolean {
try {

View File

@@ -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<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>;
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
export type Blueprint = InferSelectModel<typeof blueprints>;
export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;

View File

@@ -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<void> {
type ApplyBlueprintArgs = {
orgId: string;
configData: unknown;
name?: string;
siteId?: number;
source?: BlueprintSource;
};
export async function applyBlueprint({
orgId,
configData,
siteId,
name,
source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> {
// 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", {

View File

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

View File

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

View File

@@ -15,5 +15,6 @@ export enum OpenAPITags {
Idp = "Identity Provider",
Client = "Client",
ApiKey = "API Key",
Domain = "Domain"
Domain = "Domain",
Blueprint = "Blueprint"
}

View File

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

View File

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

View File

@@ -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<any> {
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<GetBlueprintResponse>(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")
);
}
}

View File

@@ -0,0 +1,4 @@
export * from "./listBlueprints";
export * from "./applyYAMLBlueprint";
export * from "./applyJSONBlueprint";
export * from "./getBlueprint";

View File

@@ -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<any> {
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<number>`count(*)` })
.from(blueprints);
return response<ListBlueprintsResponse>(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")
);
}
}

View File

@@ -0,0 +1,7 @@
import type { Blueprint } from "@server/db";
export type BlueprintSource = "API" | "UI" | "NEWT";
export type BlueprintData = Omit<Blueprint, "source"> & {
source: BlueprintSource;
};

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,4 @@ export * from "./checkId";
export * from "./getOrgOverview";
export * from "./listOrgs";
export * from "./pickOrgDefaults";
export * from "./applyBlueprint";
export * from "./checkOrgUserAccess";

View File

@@ -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<AxiosResponse<GetBlueprintResponse>>(
`/org/${params.orgId}/blueprint/${params.blueprintId}`,
await authCookieHeader()
);
blueprint = res.data.data;
} catch (e) {
console.error(e);
notFound();
}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("blueprintDetails")}
description={t("blueprintDetailsDescription")}
/>
<BlueprintDetailsForm blueprint={blueprint} />
</>
);
}

View File

@@ -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 (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={t("blueprintCreate")}
description={t("blueprintCreateDescription2")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/blueprints`}>
{t("blueprintGoBack")}
</Link>
</Button>
</div>
<CreateBlueprintForm orgId={orgId} />
</>
);
}

View File

@@ -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<AxiosResponse<ListBlueprintsResponse>>(
`/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 (
<OrgProvider org={org}>
<SettingsSectionTitle
title={t("blueprints")}
description={t("blueprintsDescription")}
/>
<BlueprintsTable blueprints={blueprints} orgId={params.orgId} />
</OrgProvider>
);
}

View File

@@ -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<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
const res = await internal.get<AxiosResponse<ListDomainsResponse>>(
`/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<AxiosResponse<GetOrgResponse>>(
`/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 (

View File

@@ -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 (
<UserProvider user={user}>
<Layout orgId={params.orgId} orgs={orgs} navItems={orgNavSections(env.flags.enableClients)}>
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env.flags.enableClients)}
>
{children}
</Layout>
</UserProvider>

View File

@@ -0,0 +1,17 @@
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations();
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>
<p className="text-neutral-500 dark:text-neutral-700 mb-8">
{t("pageNotFoundDescription")}
</p>
</div>
);
}

View File

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

View File

@@ -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 (
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen overflow-hidden`}>
<TopLoader />
{build === "saas" && (
<Script
src="https://rybbit.fossorial.io/api/script.js"

View File

@@ -1,21 +1,19 @@
import { SidebarNavItem } from "@app/components/SidebarNav";
import { build } from "@server/build";
import {
Home,
Settings,
Users,
Link as LinkIcon,
Waypoints,
Combine,
Fingerprint,
Workflow,
KeyRound,
TicketCheck,
User,
Globe, // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Server,
Zap,
ReceiptText,
CreditCard,
Logs,
SquareMousePointer,
@@ -77,6 +75,11 @@ export const orgNavSections = (
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="h-4 w-4" />
}
]
},

View File

@@ -0,0 +1,208 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import Editor from "@monaco-editor/react";
import { cn } from "@app/lib/cn";
import type { GetBlueprintResponse } from "@server/routers/blueprints";
import { Alert, AlertDescription } from "./ui/alert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { Badge } from "./ui/badge";
import { Globe, Terminal, Webhook } from "lucide-react";
export type CreateBlueprintFormProps = {
blueprint: GetBlueprintResponse;
};
export default function BlueprintDetailsForm({
blueprint
}: CreateBlueprintFormProps) {
const t = useTranslations();
const form = useForm({
disabled: true,
defaultValues: {
name: blueprint.name,
contents: blueprint.contents
}
});
return (
<Form {...form}>
<div className="flex flex-col gap-6">
<Alert>
<AlertDescription>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("appliedAt")}
</InfoSectionTitle>
<InfoSectionContent>
<time
className="text-muted-foreground"
dateTime={blueprint.createdAt.toString()}
>
{new Date(
blueprint.createdAt * 1000
).toLocaleString()}
</time>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.succeeded ? (
<Badge variant="green">
{t("success")}
</Badge>
) : (
<Badge variant="red">
{t("failed", {
fallback: "Failed"
})}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("message")}
</InfoSectionTitle>
<InfoSectionContent>
<p className="text-muted-foreground">
{blueprint.message}
</p>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("source")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.source === "API" && (
<Badge
variant="secondary"
className="-mx-2"
>
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "NEWT" && (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "UI" && (
<Badge
variant="secondary"
className="-mx-1 py-1"
>
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("parsedContents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
},
readOnly: true
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
</div>
</Form>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
Globe,
Terminal,
Webhook
} from "lucide-react";
import { useTransition } from "react";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { DataTable } from "./ui/data-table";
import Link from "next/link";
import { ListBlueprintsResponse } from "@server/routers/blueprints";
export type BlueprintRow = ListBlueprintsResponse["blueprints"][number];
type Props = {
blueprints: BlueprintRow[];
orgId: string;
};
export default function BlueprintsTable({ blueprints, orgId }: Props) {
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();
const router = useRouter();
const columns: ColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("appliedAt")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<time
className="text-muted-foreground"
dateTime={row.original.createdAt.toString()}
>
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</time>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
}
},
{
accessorKey: "source",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("source")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
switch (originalRow.source) {
case "API": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
);
}
case "NEWT": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
);
}
case "UI": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
);
}
}
}
},
{
accessorKey: "succeeded",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { succeeded } = row.original;
if (succeeded) {
return <Badge variant="green">{t("success")}</Badge>;
} else {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
}
}
},
{
id: "actions",
header: () => {
return null;
},
cell: ({ row }) => {
return (
<div className="flex justify-end">
<Button
variant="outline"
className="items-center"
asChild
>
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
View details{" "}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
</div>
);
}
}
];
return (
<DataTable
columns={columns}
data={blueprints}
persistPageSize="blueprint-table"
title={t("blueprints")}
searchPlaceholder={t("searchBlueprintProgress")}
searchColumn="name"
onAdd={() => {
router.push(`/${orgId}/settings/blueprints/create`);
}}
addButtonText={t("blueprintAdd")}
onRefresh={() => {
startTransition(() => router.refresh());
}}
isRefreshing={isRefreshing}
defaultSort={{
id: "createdAt",
desc: true
}}
/>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import { useActionState } from "react";
import Editor from "@monaco-editor/react";
import { cn } from "@app/lib/cn";
import { Button } from "./ui/button";
import { parse as parseYaml } from "yaml";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import type { CreateBlueprintResponse } from "@server/routers/blueprints";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
export type CreateBlueprintFormProps = {
orgId: string;
};
export default function CreateBlueprintForm({
orgId
}: CreateBlueprintFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const router = useRouter();
const form = useForm({
resolver: zodResolver(
z.object({
name: z.string().min(1).max(255),
contents: z
.string()
.min(1)
.superRefine((contents, ctx) => {
try {
parseYaml(contents);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
})
})
),
defaultValues: {
name: "",
contents: `# Example blueprint
# proxy-resources:
# resource-nice-id-uno:
# name: this is my resource
# protocol: http
# full-domain: never-gonna-give-you-up.example.com
# targets:
# - site: lively-yosemite-toad
# hostname: localhost
# method: http
# port: 8000
`
}
});
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const res = await api
.put<AxiosResponse<CreateBlueprintResponse>>(
`/org/${orgId}/blueprint/`,
{
name: form.getValues("name"),
blueprint: form.getValues("contents")
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorCreate"),
description: formatAxiosError(
e,
t("blueprintErrorCreateDescription")
)
});
});
if (res && res.status === 201) {
const createdBlueprint = res.data.data;
toast({
variant: "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message
});
router.push(`/${orgId}/settings/blueprints`);
}
}
return (
<Form {...form}>
<form action={formAction} id="base-resource-form">
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
}
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<div className="flex justify-end space-x-2 mt-8">
<Button type="submit" loading={isSubmitting}>
{t("actionApplyBlueprint")}
</Button>
</div>
</SettingsContainer>
</form>
</Form>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols
@@ -9,25 +11,44 @@ export function InfoSections({
}) {
return (
<div
className={`grid md:grid-cols-${cols || 1} md:gap-4 gap-2 md:items-start grid-cols-1`}
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))`
}}
>
{children}
</div>
);
}
export function InfoSection({ children }: { children: React.ReactNode }) {
return <div className="space-y-1">{children}</div>;
export function InfoSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-1", className)}>{children}</div>;
}
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>;
export function InfoSectionTitle({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("font-semibold", className)}>{children}</div>;
}
export function InfoSectionContent({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="break-words">{children}</div>;
return <div className={cn("break-words", className)}>{children}</div>;
}

View File

@@ -11,7 +11,6 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import CertificateStatus from "@app/components/private/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";

View File

@@ -1,9 +1,15 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
return (
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
{children}
</div>
);
}
export function SettingsSectionHeader({
@@ -15,11 +21,15 @@ export function SettingsSectionHeader({
}
export function SettingsSectionForm({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="max-w-xl space-y-4">{children}</div>;
return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
);
}
export function SettingsSectionTitle({
@@ -55,7 +65,11 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
return (
<div className="flex justify-end space-x-2 mt-auto pt-6">
{children}
</div>
);
}
export function SettingsSectionGrid({

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as NProgress from "nprogress";
import NextTopLoader from "nextjs-toploader";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() {
return (
<>
<NextTopLoader showSpinner={false} />
<FinishingLoader />
</>
);
}
function FinishingLoader() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
React.useEffect(() => {
NProgress.done();
}, [pathname, router, searchParams]);
React.useEffect(() => {
const linkClickListener = (ev: MouseEvent) => {
const element = ev.target as HTMLElement;
const closestlink = element.closest("a");
const isOpenToNewTabClick =
ev.ctrlKey ||
ev.shiftKey ||
ev.metaKey || // apple
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
if (closestlink && isOpenToNewTabClick) {
NProgress.done();
}
};
window.addEventListener("click", linkClickListener);
return () => window.removeEventListener("click", linkClickListener);
}, []);
return null;
}

View File

@@ -71,8 +71,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading || props.disabled} // Disable button when loading
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{props.children}
{asChild ? (
props.children
) : (
<>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{props.children}
</>
)}
</Comp>
);
}

View File

@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@@ -31,7 +31,8 @@ const toastVariants = cva(
variant: {
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground"
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground",
warning: "group border-amber-600 bg-amber-600 text-white"
}
},
defaultVariants: {

View File

@@ -0,0 +1,12 @@
import type { GetOrgResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import { cache } from "react";
import { authCookieHeader } from "./cookies";
import { internal } from ".";
export const getCachedOrg = cache(async (orgId: string) =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);

4
src/lib/wait.ts Normal file
View File

@@ -0,0 +1,4 @@
export function wait(ms: number): Promise<void> {
// Wait for the specified amount of time
return new Promise((resolve) => setTimeout(resolve, ms));
}