mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-04 09:46:40 +00:00
Merge branch 'feat/cert-resolver-through-UI' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-feat/cert-resolver-through-UI
This commit is contained in:
@@ -1891,5 +1891,23 @@
|
|||||||
"cannotbeUndone": "This can not be undone.",
|
"cannotbeUndone": "This can not be undone.",
|
||||||
"toConfirm": "to confirm",
|
"toConfirm": "to confirm",
|
||||||
"deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?",
|
"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..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export enum ActionsEnum {
|
|||||||
listClients = "listClients",
|
listClients = "listClients",
|
||||||
getClient = "getClient",
|
getClient = "getClient",
|
||||||
listOrgDomains = "listOrgDomains",
|
listOrgDomains = "listOrgDomains",
|
||||||
|
getDomain = "getDomain",
|
||||||
|
updateOrgDomain = "updateOrgDomain",
|
||||||
|
getDNSRecords = "getDNSRecords",
|
||||||
createNewt = "createNewt",
|
createNewt = "createNewt",
|
||||||
createIdp = "createIdp",
|
createIdp = "createIdp",
|
||||||
updateIdp = "updateIdp",
|
updateIdp = "updateIdp",
|
||||||
|
|||||||
@@ -18,7 +18,22 @@ export const domains = pgTable("domains", {
|
|||||||
type: varchar("type"), // "ns", "cname", "wildcard"
|
type: varchar("type"), // "ns", "cname", "wildcard"
|
||||||
verified: boolean("verified").notNull().default(false),
|
verified: boolean("verified").notNull().default(false),
|
||||||
failed: boolean("failed").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", {
|
export const orgs = pgTable("orgs", {
|
||||||
|
|||||||
@@ -11,9 +11,24 @@ export const domains = sqliteTable("domains", {
|
|||||||
type: text("type"), // "ns", "cname", "wildcard"
|
type: text("type"), // "ns", "cname", "wildcard"
|
||||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||||
failed: integer("failed", { 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", {
|
export const orgs = sqliteTable("orgs", {
|
||||||
orgId: text("orgId").primaryKey(),
|
orgId: text("orgId").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
@@ -745,6 +760,7 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
|||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
|
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
|
||||||
export type Client = InferSelectModel<typeof clients>;
|
export type Client = InferSelectModel<typeof clients>;
|
||||||
export type ClientSite = InferSelectModel<typeof clientSites>;
|
export type ClientSite = InferSelectModel<typeof clientSites>;
|
||||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { initTelemetryClient } from "./lib/telemetry.js";
|
|||||||
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
|
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
|
||||||
import { initCleanup } from "#dynamic/cleanup";
|
import { initCleanup } from "#dynamic/cleanup";
|
||||||
import license from "#dynamic/license/license";
|
import license from "#dynamic/license/license";
|
||||||
|
import { fetchServerIp } from "./lib/serverIpService.js";
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ async function startServers() {
|
|||||||
|
|
||||||
await runSetupFunctions();
|
await runSetupFunctions();
|
||||||
|
|
||||||
|
await fetchServerIp();
|
||||||
|
|
||||||
initTelemetryClient();
|
initTelemetryClient();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
|
|||||||
28
server/lib/serverIpService.ts
Normal file
28
server/lib/serverIpService.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, targetHealthCheck } from "@server/db";
|
import { db, targetHealthCheck, domains } from "@server/db";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -75,11 +75,14 @@ export async function getTraefikConfig(
|
|||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
subnet: sites.subnet,
|
subnet: sites.subnet,
|
||||||
exitNodeId: sites.exitNodeId
|
exitNodeId: sites.exitNodeId,
|
||||||
|
// Domain cert resolver fields
|
||||||
|
domainCertResolver: domains.certResolver
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
@@ -168,7 +171,9 @@ export async function getTraefikConfig(
|
|||||||
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
|
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
|
||||||
rewritePath: row.rewritePath,
|
rewritePath: row.rewritePath,
|
||||||
rewritePathType: row.rewritePathType,
|
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;
|
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;
|
const domainCertResolver = resource.domainCertResolver;
|
||||||
if (!configDomain) {
|
const preferWildcardCert = resource.preferWildcardCert;
|
||||||
certResolver = config.getRawConfig().traefik.cert_resolver;
|
|
||||||
preferWildcardCert =
|
|
||||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
|
||||||
} else {
|
|
||||||
certResolver = configDomain.cert_resolver;
|
|
||||||
preferWildcardCert = configDomain.prefer_wildcard_cert;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tls = {
|
let resolverName: string | undefined;
|
||||||
certResolver: certResolver,
|
let preferWildcard: boolean | undefined;
|
||||||
...(preferWildcardCert
|
// Handle both letsencrypt & custom cases
|
||||||
? {
|
if (domainCertResolver) {
|
||||||
domains: [
|
resolverName = domainCertResolver.trim();
|
||||||
{
|
} else {
|
||||||
main: wildCard
|
resolverName = globalDefaultResolver;
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
if (
|
||||||
: {})
|
preferWildcardCert !== undefined &&
|
||||||
};
|
preferWildcardCert !== null
|
||||||
|
) {
|
||||||
|
preferWildcard = preferWildcardCert;
|
||||||
|
} else {
|
||||||
|
preferWildcard = globalDefaultPreferWildcard;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tls = {
|
||||||
|
certResolver: resolverName,
|
||||||
|
...(preferWildcard
|
||||||
|
? {
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
main: wildCard
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const additionalMiddlewares =
|
const additionalMiddlewares =
|
||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
@@ -509,14 +529,14 @@ export async function getTraefikConfig(
|
|||||||
})(),
|
})(),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? {
|
? {
|
||||||
sticky: {
|
sticky: {
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
||||||
secure: resource.ssl,
|
secure: resource.ssl,
|
||||||
httpOnly: true
|
httpOnly: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -617,13 +637,13 @@ export async function getTraefikConfig(
|
|||||||
})(),
|
})(),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? {
|
? {
|
||||||
sticky: {
|
sticky: {
|
||||||
ipStrategy: {
|
ipStrategy: {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
sourcePort: true
|
sourcePort: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
loginPage,
|
loginPage,
|
||||||
targetHealthCheck
|
targetHealthCheck
|
||||||
@@ -104,11 +105,16 @@ export async function getTraefikConfig(
|
|||||||
subnet: sites.subnet,
|
subnet: sites.subnet,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
// Namespace
|
// Namespace
|
||||||
domainNamespaceId: domainNamespaces.domainNamespaceId
|
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||||
|
// Certificate
|
||||||
|
certificateStatus: certificates.status,
|
||||||
|
domainCertResolver: domains.certResolver,
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||||
|
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
@@ -206,7 +212,8 @@ export async function getTraefikConfig(
|
|||||||
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
|
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
|
||||||
rewritePath: row.rewritePath,
|
rewritePath: row.rewritePath,
|
||||||
rewritePathType: row.rewritePathType,
|
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 = {};
|
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 = {};
|
let tls = {};
|
||||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
const domainParts = fullDomain.split(".");
|
const domainParts = fullDomain.split(".");
|
||||||
@@ -324,13 +345,13 @@ export async function getTraefikConfig(
|
|||||||
certResolver: certResolver,
|
certResolver: certResolver,
|
||||||
...(preferWildcardCert
|
...(preferWildcardCert
|
||||||
? {
|
? {
|
||||||
domains: [
|
domains: [
|
||||||
{
|
{
|
||||||
main: wildCard
|
main: wildCard,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
: {})
|
: {}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// find a cert that matches the full domain, if not continue
|
// find a cert that matches the full domain, if not continue
|
||||||
@@ -582,14 +603,14 @@ export async function getTraefikConfig(
|
|||||||
})(),
|
})(),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? {
|
? {
|
||||||
sticky: {
|
sticky: {
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
||||||
secure: resource.ssl,
|
secure: resource.ssl,
|
||||||
httpOnly: true
|
httpOnly: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -690,13 +711,13 @@ export async function getTraefikConfig(
|
|||||||
})(),
|
})(),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? {
|
? {
|
||||||
sticky: {
|
sticky: {
|
||||||
ipStrategy: {
|
ipStrategy: {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
sourcePort: true
|
sourcePort: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -744,10 +765,9 @@ export async function getTraefikConfig(
|
|||||||
loadBalancer: {
|
loadBalancer: {
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
url: `http://${
|
url: `http://${config.getRawConfig().server
|
||||||
config.getRawConfig().server
|
|
||||||
.internal_hostname
|
.internal_hostname
|
||||||
}:${config.getRawConfig().server.next_port}`
|
}:${config.getRawConfig().server.next_port}`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
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 response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -24,16 +24,21 @@ const paramsSchema = z
|
|||||||
const bodySchema = z
|
const bodySchema = z
|
||||||
.object({
|
.object({
|
||||||
type: z.enum(["ns", "cname", "wildcard"]),
|
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();
|
.strict();
|
||||||
|
|
||||||
|
|
||||||
export type CreateDomainResponse = {
|
export type CreateDomainResponse = {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
nsRecords?: string[];
|
nsRecords?: string[];
|
||||||
cnameRecords?: { baseDomain: string; value: string }[];
|
cnameRecords?: { baseDomain: string; value: string }[];
|
||||||
aRecords?: { baseDomain: string; value: string }[];
|
aRecords?: { baseDomain: string; value: string }[];
|
||||||
txtRecords?: { 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
|
// 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 { orgId } = parsedParams.data;
|
||||||
const { type, baseDomain } = parsedBody.data;
|
const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data;
|
||||||
|
|
||||||
if (build == "oss") {
|
if (build == "oss") {
|
||||||
if (type !== "wildcard") {
|
if (type !== "wildcard") {
|
||||||
@@ -254,7 +259,9 @@ export async function createOrgDomain(
|
|||||||
domainId,
|
domainId,
|
||||||
baseDomain,
|
baseDomain,
|
||||||
type,
|
type,
|
||||||
verified: type === "wildcard" ? true : false
|
verified: type === "wildcard" ? true : false,
|
||||||
|
certResolver: certResolver || null,
|
||||||
|
preferWildcardCert: preferWildcardCert || false
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -269,9 +276,24 @@ export async function createOrgDomain(
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
// Prepare DNS records to insert
|
||||||
|
const recordsToInsert = [];
|
||||||
|
|
||||||
// TODO: This needs to be cross region and not hardcoded
|
// TODO: This needs to be cross region and not hardcoded
|
||||||
if (type === "ns") {
|
if (type === "ns") {
|
||||||
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
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") {
|
} else if (type === "cname") {
|
||||||
cnameRecords = [
|
cnameRecords = [
|
||||||
{
|
{
|
||||||
@@ -283,6 +305,18 @@ export async function createOrgDomain(
|
|||||||
baseDomain: `_acme-challenge.${baseDomain}`
|
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") {
|
} else if (type === "wildcard") {
|
||||||
aRecords = [
|
aRecords = [
|
||||||
{
|
{
|
||||||
@@ -294,6 +328,23 @@ export async function createOrgDomain(
|
|||||||
baseDomain: `${baseDomain}`
|
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
|
numOrgDomains = await trx
|
||||||
@@ -325,7 +376,9 @@ export async function createOrgDomain(
|
|||||||
cnameRecords,
|
cnameRecords,
|
||||||
txtRecords,
|
txtRecords,
|
||||||
nsRecords,
|
nsRecords,
|
||||||
aRecords
|
aRecords,
|
||||||
|
certResolver: returned.certResolver,
|
||||||
|
preferWildcardCert: returned.preferWildcardCert
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
97
server/routers/domain/getDNSRecords.ts
Normal file
97
server/routers/domain/getDNSRecords.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
server/routers/domain/getDomain.ts
Normal file
86
server/routers/domain/getDomain.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
export * from "./listDomains";
|
export * from "./listDomains";
|
||||||
export * from "./createOrgDomain";
|
export * from "./createOrgDomain";
|
||||||
export * from "./deleteOrgDomain";
|
export * from "./deleteOrgDomain";
|
||||||
export * from "./restartOrgDomain";
|
export * from "./restartOrgDomain";
|
||||||
|
export * from "./getDomain";
|
||||||
|
export * from "./getDNSRecords";
|
||||||
|
export * from "./updateDomain";
|
||||||
@@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
|||||||
type: domains.type,
|
type: domains.type,
|
||||||
failed: domains.failed,
|
failed: domains.failed,
|
||||||
tries: domains.tries,
|
tries: domains.tries,
|
||||||
configManaged: domains.configManaged
|
configManaged: domains.configManaged,
|
||||||
|
certResolver: domains.certResolver,
|
||||||
|
preferWildcardCert: domains.preferWildcardCert
|
||||||
})
|
})
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.orgId, orgId))
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
|||||||
161
server/routers/domain/updateDomain.ts
Normal file
161
server/routers/domain/updateDomain.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,27 @@ authenticated.get(
|
|||||||
domain.listDomains
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/invitations",
|
"/org/:orgId/invitations",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ async function copyInDomains() {
|
|||||||
const configDomains = Object.entries(rawDomains).map(
|
const configDomains = Object.entries(rawDomains).map(
|
||||||
([key, value]) => ({
|
([key, value]) => ({
|
||||||
domainId: key,
|
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)) {
|
if (existingDomainKeys.has(domainId)) {
|
||||||
await trx
|
await trx
|
||||||
.update(domains)
|
.update(domains)
|
||||||
.set({ baseDomain, verified: true, type: "wildcard" })
|
.set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert })
|
||||||
.where(eq(domains.domainId, domainId))
|
.where(eq(domains.domainId, domainId))
|
||||||
.execute();
|
.execute();
|
||||||
} else {
|
} else {
|
||||||
@@ -74,7 +76,9 @@ async function copyInDomains() {
|
|||||||
baseDomain,
|
baseDomain,
|
||||||
configManaged: true,
|
configManaged: true,
|
||||||
type: "wildcard",
|
type: "wildcard",
|
||||||
verified: true
|
verified: true,
|
||||||
|
certResolver,
|
||||||
|
preferWildcardCert
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/app/[orgId]/settings/domains/[domainId]/layout.tsx
Normal file
32
src/app/[orgId]/settings/domains/[domainId]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/app/[orgId]/settings/domains/[domainId]/page.tsx
Normal file
104
src/app/[orgId]/settings/domains/[domainId]/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export default async function DomainsPage(props: Props) {
|
|||||||
title={t("domains")}
|
title={t("domains")}
|
||||||
description={t("domainsDescription")}
|
description={t("domainsDescription")}
|
||||||
/>
|
/>
|
||||||
<DomainsTable domains={domains} />
|
<DomainsTable domains={domains} orgId={org.org.orgId} />
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
FormDescription
|
FormDescription
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
@@ -45,6 +46,8 @@ import {
|
|||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { toASCII, toUnicode } from 'punycode';
|
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
|
// Helper functions for Unicode domain handling
|
||||||
@@ -96,7 +99,9 @@ const formSchema = z.object({
|
|||||||
.min(1, "Domain is required")
|
.min(1, "Domain is required")
|
||||||
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
|
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
|
||||||
.transform((val) => toPunycode(val)),
|
.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>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
@@ -107,6 +112,12 @@ type CreateDomainFormProps = {
|
|||||||
onCreated?: (domain: CreateDomainResponse) => void;
|
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({
|
export default function CreateDomainForm({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@@ -120,20 +131,32 @@ export default function CreateDomainForm({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
baseDomain: "",
|
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();
|
form.reset();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setCreatedDomain(null);
|
setCreatedDomain(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -149,6 +172,7 @@ export default function CreateDomainForm({
|
|||||||
description: t("domainCreatedDescription")
|
description: t("domainCreatedDescription")
|
||||||
});
|
});
|
||||||
onCreated?.(domainData);
|
onCreated?.(domainData);
|
||||||
|
router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
@@ -158,17 +182,9 @@ export default function CreateDomainForm({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 = [];
|
let domainOptions: any = [];
|
||||||
if (build != "oss" && env.flags.usePangolinDns) {
|
if (build != "oss" && env.flags.usePangolinDns) {
|
||||||
domainOptions = [
|
domainOptions = [
|
||||||
@@ -209,7 +225,6 @@ export default function CreateDomainForm({
|
|||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
{!createdDomain ? (
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@@ -260,331 +275,95 @@ export default function CreateDomainForm({
|
|||||||
</FormItem>
|
</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>
|
||||||
</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>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
|
|||||||
130
src/components/DNSRecordTable.tsx
Normal file
130
src/components/DNSRecordTable.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/components/DNSRecordsDataTable.tsx
Normal file
177
src/components/DNSRecordsDataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
src/components/DomainInfoCard.tsx
Normal file
397
src/components/DomainInfoCard.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { DomainsDataTable } from "@app/components/DomainsDataTable";
|
import { DomainsDataTable } from "@app/components/DomainsDataTable";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
@@ -15,6 +15,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import CreateDomainForm from "@app/components/CreateDomainForm";
|
import CreateDomainForm from "@app/components/CreateDomainForm";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export type DomainRow = {
|
export type DomainRow = {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
@@ -24,13 +26,16 @@ export type DomainRow = {
|
|||||||
failed: boolean;
|
failed: boolean;
|
||||||
tries: number;
|
tries: number;
|
||||||
configManaged: boolean;
|
configManaged: boolean;
|
||||||
|
certResolver: string;
|
||||||
|
preferWildcardCert: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
domains: DomainRow[];
|
domains: DomainRow[];
|
||||||
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DomainsTable({ domains }: Props) {
|
export default function DomainsTable({ domains, orgId }: Props) {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
|
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
|
||||||
@@ -205,12 +210,51 @@ export default function DomainsTable({ domains }: Props) {
|
|||||||
>
|
>
|
||||||
{isRestarting
|
{isRestarting
|
||||||
? t("restarting", {
|
? t("restarting", {
|
||||||
fallback: "Restarting..."
|
fallback: "Restarting..."
|
||||||
})
|
})
|
||||||
: t("restart", { fallback: "Restart" })}
|
: t("restart", { fallback: "Restart" })}
|
||||||
</Button>
|
</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"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={domain.configManaged}
|
disabled={domain.configManaged}
|
||||||
@@ -220,7 +264,7 @@ export default function DomainsTable({ domains }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/contexts/domainContext.ts
Normal file
19
src/contexts/domainContext.ts
Normal 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;
|
||||||
10
src/hooks/useDomainContext.ts
Normal file
10
src/hooks/useDomainContext.ts
Normal 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;
|
||||||
|
}
|
||||||
45
src/providers/DomainProvider.tsx
Normal file
45
src/providers/DomainProvider.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user