Merge branch 'feat/cert-resolver-through-UI' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-feat/cert-resolver-through-UI

This commit is contained in:
Owen
2025-10-25 16:25:42 -07:00
27 changed files with 1712 additions and 426 deletions

View File

@@ -1891,5 +1891,23 @@
"cannotbeUndone": "This can not be undone.",
"toConfirm": "to confirm",
"deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?",
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site."
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",
"enterCustomResolver": "Enter Custom Resolver",
"preferWildcardCert": "Prefer Wildcard Certificate",
"unverified": "Unverified",
"domainSetting": "Domain Settings",
"domainSettingDescription": "Configure settings for your domain",
"preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).",
"recordName": "Record Name",
"auto": "Auto",
"TTL": "TTL",
"howToAddRecords": "How to Add Records",
"dnsRecord": "DNS Records",
"required": "Required",
"domainSettingsUpdated": "Domain settings updated successfully",
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
"loadingDNSRecords": "Loading DNS records..."
}

View File

@@ -81,6 +81,9 @@ export enum ActionsEnum {
listClients = "listClients",
getClient = "getClient",
listOrgDomains = "listOrgDomains",
getDomain = "getDomain",
updateOrgDomain = "updateOrgDomain",
getDNSRecords = "getDNSRecords",
createNewt = "createNewt",
createIdp = "createIdp",
updateIdp = "updateIdp",

View File

@@ -18,7 +18,22 @@ export const domains = pgTable("domains", {
type: varchar("type"), // "ns", "cname", "wildcard"
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0)
tries: integer("tries").notNull().default(0),
certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert")
});
export const dnsRecords = pgTable("dnsRecords", {
id: varchar("id").primaryKey(),
domainId: varchar("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
baseDomain: varchar("baseDomain"),
value: varchar("value").notNull(),
verified: boolean("verified").notNull().default(false),
});
export const orgs = pgTable("orgs", {

View File

@@ -11,9 +11,24 @@ export const domains = sqliteTable("domains", {
type: text("type"), // "ns", "cname", "wildcard"
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0)
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
});
export const dnsRecords = sqliteTable("dnsRecords", {
id: text("id").primaryKey(),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
baseDomain: text("baseDomain"),
value: text("value").notNull(),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
});
export const orgs = sqliteTable("orgs", {
orgId: text("orgId").primaryKey(),
name: text("name").notNull(),
@@ -745,6 +760,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSites>;
export type RoleClient = InferSelectModel<typeof roleClients>;

View File

@@ -20,7 +20,7 @@ import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
import { fetchServerIp } from "./lib/serverIpService.js";
async function startServers() {
await setHostMeta();
@@ -31,6 +31,8 @@ async function startServers() {
await runSetupFunctions();
await fetchServerIp();
initTelemetryClient();
// Start all servers

View File

@@ -0,0 +1,28 @@
import axios from "axios";
let serverIp: string | null = null;
const services = [
"https://ifconfig.io/ip",
"https://api.ipify.org",
"https://checkip.amazonaws.com"
];
export async function fetchServerIp() {
for (const url of services) {
try {
const response = await axios.get(url, { timeout: 5000 });
serverIp = response.data.trim();
console.log("Detected public IP:", serverIp);
return;
} catch (err: any) {
console.warn(`Failed to fetch server IP from ${url}: ${err.message || err.code}`);
}
}
console.error("All attempts to fetch server IP failed.");
}
export function getServerIp() {
return serverIp;
}

View File

@@ -1,4 +1,4 @@
import { db, targetHealthCheck } from "@server/db";
import { db, targetHealthCheck, domains } from "@server/db";
import {
and,
eq,
@@ -75,11 +75,14 @@ export async function getTraefikConfig(
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -168,7 +171,9 @@ export async function getTraefikConfig(
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType,
priority: priority // may be null, we fallback later
priority: priority,
// Store domain cert resolver fields
domainCertResolver: row.domainCertResolver
});
}
@@ -247,30 +252,45 @@ export async function getTraefikConfig(
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
let certResolver: string, preferWildcardCert: boolean;
if (!configDomain) {
certResolver = config.getRawConfig().traefik.cert_resolver;
preferWildcardCert =
config.getRawConfig().traefik.prefer_wildcard_cert;
} else {
certResolver = configDomain.cert_resolver;
preferWildcardCert = configDomain.prefer_wildcard_cert;
}
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
const tls = {
certResolver: certResolver,
...(preferWildcardCert
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
const tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
@@ -509,14 +529,14 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {})
}
};
@@ -617,13 +637,13 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
}
};

View File

@@ -15,6 +15,7 @@ import {
certificates,
db,
domainNamespaces,
domains,
exitNodes,
loginPage,
targetHealthCheck
@@ -104,11 +105,16 @@ export async function getTraefikConfig(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
// Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Certificate
certificateStatus: certificates.status,
domainCertResolver: domains.certResolver,
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -206,7 +212,8 @@ export async function getTraefikConfig(
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath,
rewritePathType: row.rewritePathType,
priority: priority // may be null, we fallback later
priority: priority, // may be null, we fallback later
domainCertResolver: row.domainCertResolver,
});
}
@@ -294,6 +301,20 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
@@ -324,13 +345,13 @@ export async function getTraefikConfig(
certResolver: certResolver,
...(preferWildcardCert
? {
domains: [
{
main: wildCard
}
]
}
: {})
domains: [
{
main: wildCard,
},
],
}
: {}),
};
} else {
// find a cert that matches the full domain, if not continue
@@ -582,14 +603,14 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {})
}
};
@@ -690,13 +711,13 @@ export async function getTraefikConfig(
})(),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
}
};
@@ -744,10 +765,9 @@ export async function getTraefikConfig(
loadBalancer: {
servers: [
{
url: `http://${
config.getRawConfig().server
url: `http://${config.getRawConfig().server
.internal_hostname
}:${config.getRawConfig().server.next_port}`
}:${config.getRawConfig().server.next_port}`
}
]
}

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db";
import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -24,16 +24,21 @@ const paramsSchema = z
const bodySchema = z
.object({
type: z.enum(["ns", "cname", "wildcard"]),
baseDomain: subdomainSchema
baseDomain: subdomainSchema,
certResolver: z.string().optional().nullable(),
preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard
})
.strict();
export type CreateDomainResponse = {
domainId: string;
nsRecords?: string[];
cnameRecords?: { baseDomain: string; value: string }[];
aRecords?: { baseDomain: string; value: string }[];
txtRecords?: { baseDomain: string; value: string }[];
certResolver?: string | null;
preferWildcardCert?: boolean | null;
};
// Helper to check if a domain is a subdomain or equal to another domain
@@ -71,7 +76,7 @@ export async function createOrgDomain(
}
const { orgId } = parsedParams.data;
const { type, baseDomain } = parsedBody.data;
const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data;
if (build == "oss") {
if (type !== "wildcard") {
@@ -254,7 +259,9 @@ export async function createOrgDomain(
domainId,
baseDomain,
type,
verified: type === "wildcard" ? true : false
verified: type === "wildcard" ? true : false,
certResolver: certResolver || null,
preferWildcardCert: preferWildcardCert || false
})
.returning();
@@ -269,9 +276,24 @@ export async function createOrgDomain(
})
.returning();
// Prepare DNS records to insert
const recordsToInsert = [];
// TODO: This needs to be cross region and not hardcoded
if (type === "ns") {
nsRecords = config.getRawConfig().dns.nameservers as string[];
// Save NS records to database
for (const nsValue of nsRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "NS",
baseDomain: baseDomain,
value: nsValue,
verified: false
});
}
} else if (type === "cname") {
cnameRecords = [
{
@@ -283,6 +305,18 @@ export async function createOrgDomain(
baseDomain: `_acme-challenge.${baseDomain}`
}
];
// Save CNAME records to database
for (const cnameRecord of cnameRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "CNAME",
baseDomain: cnameRecord.baseDomain,
value: cnameRecord.value,
verified: false
});
}
} else if (type === "wildcard") {
aRecords = [
{
@@ -294,6 +328,23 @@ export async function createOrgDomain(
baseDomain: `${baseDomain}`
}
];
// Save A records to database
for (const aRecord of aRecords) {
recordsToInsert.push({
id: generateId(15),
domainId,
recordType: "A",
baseDomain: aRecord.baseDomain,
value: aRecord.value,
verified: true
});
}
}
// Insert all DNS records in batch
if (recordsToInsert.length > 0) {
await trx.insert(dnsRecords).values(recordsToInsert);
}
numOrgDomains = await trx
@@ -325,7 +376,9 @@ export async function createOrgDomain(
cnameRecords,
txtRecords,
nsRecords,
aRecords
aRecords,
certResolver: returned.certResolver,
preferWildcardCert: returned.preferWildcardCert
},
success: true,
error: false,

View File

@@ -0,0 +1,97 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, dnsRecords } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module
const getDNSRecordsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
async function query(domainId: string) {
const records = await db
.select()
.from(dnsRecords)
.where(eq(dnsRecords.domainId, domainId));
return records;
}
export type GetDNSRecordsResponse = Awaited<ReturnType<typeof query>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}/dns-records",
description: "Get all DNS records for a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function getDNSRecords(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getDNSRecordsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { domainId } = parsedParams.data;
const records = await query(domainId);
if (!records || records.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No DNS records found for this domain"
)
);
}
const serverIp = getServerIp();
// Override value for type A or wildcard records
const updatedRecords = records.map(record => {
if ((record.recordType === "A" || record.baseDomain === "*") && serverIp) {
return { ...record, value: serverIp };
}
return record;
});
return response<GetDNSRecordsResponse>(res, {
data: updatedRecords,
success: true,
error: false,
message: "DNS records 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,86 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains } 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 { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { domain } from "zod/v4/core/regexes";
const getDomainSchema = z
.object({
domainId: z
.string()
.optional(),
orgId: z.string().optional()
})
.strict();
async function query(domainId?: string, orgId?: string) {
if (domainId) {
const [res] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.limit(1);
return res;
}
}
export type GetDomainResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}",
description: "Get a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function getDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getDomainSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
const domain = await query(domainId, orgId);
if (!domain) {
return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found"));
}
return response<GetDomainResponse>(res, {
data: domain,
success: true,
error: false,
message: "Domain retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,4 +1,7 @@
export * from "./listDomains";
export * from "./createOrgDomain";
export * from "./deleteOrgDomain";
export * from "./restartOrgDomain";
export * from "./restartOrgDomain";
export * from "./getDomain";
export * from "./getDNSRecords";
export * from "./updateDomain";

View File

@@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
type: domains.type,
failed: domains.failed,
tries: domains.tries,
configManaged: domains.configManaged
configManaged: domains.configManaged,
certResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))

View File

@@ -0,0 +1,161 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
orgId: z.string(),
domainId: z.string()
})
.strict();
const bodySchema = z
.object({
certResolver: z.string().optional().nullable(),
preferWildcardCert: z.boolean().optional().nullable()
})
.strict();
export type UpdateDomainResponse = {
domainId: string;
certResolver: string | null;
preferWildcardCert: boolean | null;
};
registry.registerPath({
method: "patch",
path: "/org/{orgId}/domain/{domainId}",
description: "Update a domain by domainId.",
tags: [OpenAPITags.Domain],
request: {
params: z.object({
domainId: z.string(),
orgId: z.string()
})
},
responses: {}
});
export async function updateOrgDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
const { certResolver, preferWildcardCert } = parsedBody.data;
const [orgDomain] = await db
.select()
.from(orgDomains)
.where(
and(
eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId)
)
);
if (!orgDomain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Domain not found or does not belong to this organization"
)
);
}
const [existingDomain] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId));
if (!existingDomain) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Domain not found")
);
}
if (existingDomain.type !== "wildcard") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain settings can only be updated for wildcard domains"
)
);
}
const updateData: Partial<{
certResolver: string | null;
preferWildcardCert: boolean;
}> = {};
if (certResolver !== undefined) {
updateData.certResolver = certResolver;
}
if (preferWildcardCert !== undefined && preferWildcardCert !== null) {
updateData.preferWildcardCert = preferWildcardCert;
}
const [updatedDomain] = await db
.update(domains)
.set(updateData)
.where(eq(domains.domainId, domainId))
.returning();
if (!updatedDomain) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update domain"
)
);
}
return response<UpdateDomainResponse>(res, {
data: {
domainId: updatedDomain.domainId,
certResolver: updatedDomain.certResolver,
preferWildcardCert: updatedDomain.preferWildcardCert
},
success: true,
error: false,
message: "Domain updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -302,6 +302,27 @@ authenticated.get(
domain.listDomains
);
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.get(
"/org/:orgId/invitations",
verifyOrgAccess,

View File

@@ -37,7 +37,9 @@ async function copyInDomains() {
const configDomains = Object.entries(rawDomains).map(
([key, value]) => ({
domainId: key,
baseDomain: value.base_domain.toLowerCase()
baseDomain: value.base_domain.toLowerCase(),
certResolver: value.cert_resolver || null,
preferWildcardCert: value.prefer_wildcard_cert || null,
})
);
@@ -59,11 +61,11 @@ async function copyInDomains() {
}
}
for (const { domainId, baseDomain } of configDomains) {
for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) {
if (existingDomainKeys.has(domainId)) {
await trx
.update(domains)
.set({ baseDomain, verified: true, type: "wildcard" })
.set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert })
.where(eq(domains.domainId, domainId))
.execute();
} else {
@@ -74,7 +76,9 @@ async function copyInDomains() {
baseDomain,
configManaged: true,
type: "wildcard",
verified: true
verified: true,
certResolver,
preferWildcardCert
})
.execute();
}

View File

@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { internal } from "@app/lib/api";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { AxiosResponse } from "axios";
import DomainProvider from "@app/providers/DomainProvider";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ domainId: string; orgId: string }>;
}
export default async function SettingsLayout({ children, params }: SettingsLayoutProps) {
const { domainId, orgId } = await params;
let domain = null;
try {
const res = await internal.get<AxiosResponse<GetDomainResponse>>(
`/org/${orgId}/domain/${domainId}`,
await authCookieHeader()
);
domain = res.data.data;
} catch {
redirect(`/${orgId}/settings/domains`);
}
return (
<DomainProvider domain={domain} orgId={orgId}>
{children}
</DomainProvider>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainInfoCard from "@app/components/DomainInfoCard";
import { useDomain } from "@app/contexts/domainContext";
import { useTranslations } from "next-intl";
export default function DomainSettingsPage() {
const { domain, orgId } = useDomain();
const router = useRouter();
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(new Set());
const t = useTranslations();
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive",
});
} finally {
setIsRefreshing(false);
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains((prev) => new Set(prev).add(domainId));
try {
await api.post(`/org/${orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", {
fallback: "Domain verification restarted successfully",
}),
});
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive",
});
} finally {
setRestartingDomains((prev) => {
const newSet = new Set(prev);
newSet.delete(domainId);
return newSet;
});
}
};
if (!domain) {
return null;
}
const isRestarting = restartingDomains.has(domain.domainId);
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
<Button
variant="outline"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
{isRestarting ? (
<>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("restarting", { fallback: "Restarting..." })}
</>
) : (
<>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("restart", { fallback: "Restart" })}
</>
)}
</Button>
</div>
<div className="space-y-6">
<DomainInfoCard orgId={orgId} domainId={domain.domainId} />
</div>
</>
);
}

View File

@@ -60,7 +60,7 @@ export default async function DomainsPage(props: Props) {
title={t("domains")}
description={t("domainsDescription")}
/>
<DomainsTable domains={domains} />
<DomainsTable domains={domains} orgId={org.org.orgId} />
</OrgProvider>
</>
);

View File

@@ -11,6 +11,7 @@ import {
FormDescription
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useMemo } from "react";
@@ -45,6 +46,8 @@ import {
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useRouter } from "next/navigation";
// Helper functions for Unicode domain handling
@@ -96,7 +99,9 @@ const formSchema = z.object({
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"])
type: z.enum(["ns", "cname", "wildcard"]),
certResolver: z.string().nullable().optional(),
preferWildcardCert: z.boolean().optional()
});
type FormValues = z.infer<typeof formSchema>;
@@ -107,6 +112,12 @@ type CreateDomainFormProps = {
onCreated?: (domain: CreateDomainResponse) => void;
};
// Example cert resolver options (replace with real API/fetch if needed)
const certResolverOptions = [
{ id: "default", title: "Default" },
{ id: "custom", title: "Custom Resolver" }
];
export default function CreateDomainForm({
open,
setOpen,
@@ -120,20 +131,32 @@ export default function CreateDomainForm({
const { toast } = useToast();
const { org } = useOrgContext();
const { env } = useEnvContext();
const router = useRouter();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns"
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: null,
preferWildcardCert: false
}
});
function reset() {
const baseDomain = form.watch("baseDomain");
const domainType = form.watch("type");
const punycodePreview = useMemo(() => {
if (!baseDomain) return "";
const punycode = toPunycode(baseDomain);
return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]);
const reset = () => {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
};
async function onSubmit(values: FormValues) {
setLoading(true);
@@ -149,6 +172,7 @@ export default function CreateDomainForm({
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`);
} catch (e) {
toast({
title: t("error"),
@@ -158,17 +182,9 @@ export default function CreateDomainForm({
} finally {
setLoading(false);
}
}
const baseDomain = form.watch("baseDomain");
const domainInputValue = form.watch("baseDomain") || "";
const punycodePreview = useMemo(() => {
if (!domainInputValue) return "";
const punycode = toPunycode(domainInputValue);
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
}, [domainInputValue]);
};
// Domain type options
let domainOptions: any = [];
if (build != "oss" && env.flags.usePangolinDns) {
domainOptions = [
@@ -209,7 +225,6 @@ export default function CreateDomainForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -260,331 +275,95 @@ export default function CreateDomainForm({
</FormItem>
)}
/>
{domainType === "wildcard" && (
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("certResolver")}</FormLabel>
<FormControl>
<Select
value={
field.value === null ? "default" :
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
"default"
}
onValueChange={(val) => {
if (val === "default") {
field.onChange(null);
} else if (val === "custom") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} />
</SelectTrigger>
<SelectContent>
{certResolverOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t("enterCustomResolver")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({ field: checkboxField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<CheckboxWithLabel
label={t("preferWildcardCert")}
checked={checkboxField.value}
onCheckedChange={checkboxField.onChange}
/>
</FormControl>
{/* <div className="space-y-1 leading-none">
<FormLabel>
{t("preferWildcardCert")}
</FormLabel>
</div> */}
</FormItem>
)}
/>
)}
</>
)}
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainAddDnsRecords")}
</AlertTitle>
<AlertDescription>
{t("createDomainAddDnsRecordsDescription")}
</AlertDescription>
</Alert>
<div className="space-y-4">
{createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(baseDomain)}
</span>
{fromPunycode(baseDomain) !== baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({baseDomain})
</span>
)}
</div>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainCnameRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(cnameRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(cnameRecord.baseDomain)}
</span>
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({cnameRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.aRecords &&
createdDomain.aRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainARecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.aRecords.map(
(aRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
A
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(aRecord.baseDomain)}
</span>
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({aRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.value
}
</span>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainTxtRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(txtRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(txtRecord.baseDomain)}
</span>
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({txtRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</div>
{build != "oss" && env.flags.usePangolinDns && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainSaveTheseRecords")}
</AlertTitle>
<AlertDescription>
{t(
"createDomainSaveTheseRecordsDescription"
)}
</AlertDescription>
</Alert>
)}
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainDnsPropagation")}
</AlertTitle>
<AlertDescription>
{t("createDomainDnsPropagationDescription")}
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>

View File

@@ -0,0 +1,130 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
export type DNSRecordRow = {
id: string;
domainId: string;
recordType: string; // "NS" | "CNAME" | "A" | "TXT"
baseDomain: string | null;
value: string;
verified?: boolean;
};
type Props = {
records: DNSRecordRow[];
domainId: string;
isRefreshing?: boolean;
};
export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) {
const t = useTranslations();
const columns: ColumnDef<DNSRecordRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<div
>
{t("recordName", { fallback: "Record name" })}
</div>
);
},
cell: ({ row }) => {
const baseDomain = row.original.baseDomain;
return (
<div>
{baseDomain || "-"}
</div>
);
}
},
{
accessorKey: "recordType",
header: ({ column }) => {
return (
<div
>
{t("type")}
</div>
);
},
cell: ({ row }) => {
const type = row.original.recordType;
return (
<div className="">
{type}
</div>
);
}
},
{
accessorKey: "ttl",
header: ({ column }) => {
return (
<div
>
{t("TTL")}
</div>
);
},
cell: ({ row }) => {
return (
<div>
{t("auto")}
</div>
);
}
},
{
accessorKey: "value",
header: () => {
return <div>{t("value")}</div>;
},
cell: ({ row }) => {
const value = row.original.value;
return (
<div>
{value}
</div>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<div
>
{t("status")}
</div>
);
},
cell: ({ row }) => {
const verified = row.original.verified;
return (
verified ? (
<Badge variant="green">{t("verified")}</Badge>
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)
);
}
}
];
return (
<DNSRecordsDataTable
columns={columns}
data={records}
isRefreshing={isRefreshing}
/>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useMemo, useState } from "react";
import { ExternalLink, Plus, RefreshCw } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
type DNSRecordsDataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
id: string;
desc: boolean;
};
tabs?: TabFilter[];
defaultTab?: string;
persistPageSize?: boolean | string;
defaultPageSize?: number;
};
export function DNSRecordsDataTable<TData, TValue>({
columns,
data,
title,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
defaultSort,
tabs,
defaultTab,
}: DNSRecordsDataTableProps<TData, TValue>) {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
// Apply tab filter to data
const filteredData = useMemo(() => {
if (!tabs || activeTab === "") {
return data;
}
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
if (!activeTabFilter) {
return data;
}
return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab]);
const table = useReactTable({
data: filteredData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2 justify-between">
<div className="relative w-full sm:max-w-sm flex flex-row gap-4 items-center">
<h1 className="font-bold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
</div>
<Button
variant="outline"
>
<ExternalLink className="h-4 w-4 mr-1"/>
{t("howToAddRecords")}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-secondary dark:bg-transparent">
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,397 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDomainContext } from "@app/hooks/useDomainContext";
import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings";
import { Button } from "./ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from "@app/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Input } from "./ui/input";
import { useForm } from "react-hook-form";
import z from "zod";
import { toASCII } from "punycode";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { Switch } from "./ui/switch";
import { useEffect, useState } from "react";
import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable";
import { createApiClient } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { Badge } from "./ui/badge";
type DomainInfoCardProps = {
orgId?: string;
domainId?: string;
};
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
const parts = toASCII(domain);
return parts;
} catch (error) {
return domain.toLowerCase();
}
}
function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
if (!unicodeRegex.test(domain)) {
return false;
}
const parts = domain.split('.');
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
return false;
}
if (part.length > 63) {
return false;
}
}
if (domain.length > 253) {
return false;
}
return true;
}
const formSchema = z.object({
baseDomain: z
.string()
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"]),
certResolver: z.string().nullable().optional(),
preferWildcardCert: z.boolean().optional()
});
type FormValues = z.infer<typeof formSchema>;
const certResolverOptions = [
{ id: "default", title: "Default" },
{ id: "custom", title: "Custom Resolver" }
];
export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) {
const { domain, updateDomain } = useDomainContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const { toast } = useToast();
const [dnsRecords, setDnsRecords] = useState<DNSRecordRow[]>([]);
const [loadingRecords, setLoadingRecords] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: domain.certResolver ?? "",
preferWildcardCert: false
}
});
useEffect(() => {
if (domain.domainId) {
const certResolverValue = domain.certResolver && domain.certResolver.trim() !== ""
? domain.certResolver
: null;
form.reset({
baseDomain: domain.baseDomain || "",
type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard",
certResolver: certResolverValue,
preferWildcardCert: domain.preferWildcardCert || false
});
}
}, [domain]);
const fetchDNSRecords = async (showRefreshing = false) => {
if (showRefreshing) {
setIsRefreshing(true);
} else {
setLoadingRecords(true);
}
try {
const response = await api.get<{ data: DNSRecordRow[] }>(
`/org/${orgId}/domain/${domainId}/dns-records`
);
setDnsRecords(response.data.data);
} catch (error) {
// Only show error if records exist (not a 404)
const err = error as any;
if (err?.response?.status !== 404) {
toast({
title: t("error"),
description: formatAxiosError(error),
variant: "destructive"
});
}
} finally {
setLoadingRecords(false);
setIsRefreshing(false);
}
};
useEffect(() => {
if (domain.domainId) {
fetchDNSRecords();
}
}, [domain.domainId]);
const onSubmit = async (values: FormValues) => {
if (!orgId || !domainId) {
toast({
title: t("error"),
description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }),
variant: "destructive"
});
return;
}
setSaveLoading(true);
try {
const response = await api.patch(
`/org/${orgId}/domain/${domainId}`,
{
certResolver: values.certResolver,
preferWildcardCert: values.preferWildcardCert
}
);
updateDomain({
...domain,
certResolver: values.certResolver || null,
preferWildcardCert: values.preferWildcardCert || false
});
toast({
title: t("success"),
description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }),
variant: "default"
});
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error),
variant: "destructive"
});
} finally {
setSaveLoading(false);
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
case "wildcard":
return t("selectDomainTypeWildcardName");
default:
return type;
}
};
return (
<>
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{getTypeDisplay(domain.type ? domain.type : "")}
</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{domain.verified ? (
<Badge variant="green">{t("verified")}</Badge>
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
{loadingRecords ? (
<div className="space-y-4">
{t("loadingDNSRecords", { fallback: "Loading DNS Records..." })}
</div>
) : (
<DNSRecordsTable
domainId={domain.domainId}
records={dnsRecords}
isRefreshing={isRefreshing}
/>
)
}
{/* Domain Settings - Only show for wildcard domains */}
{domain.type === "wildcard" && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("domainSetting")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="domain-settings-form"
>
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("certResolver")}</FormLabel>
<FormControl>
<Select
value={
field.value === null ? "default" :
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
"default"
}
onValueChange={(val) => {
if (val === "default") {
field.onChange(null);
} else if (val === "custom") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} />
</SelectTrigger>
<SelectContent>
{certResolverOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t("enterCustomResolver")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({ field: switchField }) => (
<FormItem className="items-center space-y-2 mt-4">
<FormControl>
<div className="flex items-center space-x-2">
<Switch
checked={switchField.value}
onCheckedChange={switchField.onChange}
/>
<FormLabel>{t("preferWildcardCert")}</FormLabel>
</div>
</FormControl>
<FormDescription>
{t("preferWildcardCertDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="domain-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)}
</>
);
}

View File

@@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
@@ -15,6 +15,8 @@ import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
import Link from "next/link";
export type DomainRow = {
domainId: string;
@@ -24,13 +26,16 @@ export type DomainRow = {
failed: boolean;
tries: number;
configManaged: boolean;
certResolver: string;
preferWildcardCert: boolean;
};
type Props = {
domains: DomainRow[];
orgId: string;
};
export default function DomainsTable({ domains }: Props) {
export default function DomainsTable({ domains, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
@@ -205,12 +210,51 @@ export default function DomainsTable({ domains }: Props) {
>
{isRestarting
? t("restarting", {
fallback: "Restarting..."
})
fallback: "Restarting..."
})
: t("restart", { fallback: "Restart" })}
</Button>
)}
<Button
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
{/* <Button
variant="secondary"
size="sm"
disabled={domain.configManaged}
@@ -220,7 +264,7 @@ export default function DomainsTable({ domains }: Props) {
}}
>
{t("delete")}
</Button>
</Button> */}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { createContext, useContext } from "react";
interface DomainContextType {
domain: GetDomainResponse;
updateDomain: (updatedDomain: Partial<GetDomainResponse>) => void;
orgId: string;
}
const DomainContext = createContext<DomainContextType | undefined>(undefined);
export function useDomain() {
const context = useContext(DomainContext);
if (!context) {
throw new Error("useDomain must be used within DomainProvider");
}
return context;
}
export default DomainContext;

View File

@@ -0,0 +1,10 @@
import DomainContext from "@app/contexts/domainContext";
import { useContext } from "react";
export function useDomainContext() {
const context = useContext(DomainContext);
if (context === undefined) {
throw new Error('useDomainContext must be used within a DomainProvider');
}
return context;
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import DomainContext from "@app/contexts/domainContext";
interface DomainProviderProps {
children: React.ReactNode;
domain: GetDomainResponse;
orgId: string;
}
export function DomainProvider({
children,
domain: serverDomain,
orgId
}: DomainProviderProps) {
const [domain, setDomain] = useState<GetDomainResponse>(serverDomain);
const t = useTranslations();
const updateDomain = (updatedDomain: Partial<GetDomainResponse>) => {
if (!domain) {
throw new Error(t('domainErrorNoUpdate'));
}
setDomain((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedDomain
};
});
};
return (
<DomainContext.Provider value={{ domain, updateDomain, orgId }}>
{children}
</DomainContext.Provider>
);
}
export default DomainProvider;