Compare commits

..

13 Commits

Author SHA1 Message Date
Owen
31b66cd911 Warning -> debug 2025-11-01 10:46:09 -07:00
miloschwartz
da0196a308 no reset password for external users 2025-10-30 22:24:07 -07:00
miloschwartz
e585972b7b remove useSubscriptionStatusContext from HorizontalTabs 2025-10-30 21:31:48 -07:00
miloschwartz
cc62cd4add remove sqlite driver logger 2025-10-30 21:23:05 -07:00
Owen
25225a452c Return instead of throwing error 2025-10-30 21:18:26 -07:00
Owen
678644c7fb Fix empty blueprint 2025-10-30 21:09:20 -07:00
Owen
32f20ed984 Bugfixes for remote nodes 2025-10-30 21:01:45 -07:00
Owen
4eb5bf08d5 UI fixes 2025-10-30 17:44:22 -07:00
Owen
35c93f38e0 Fix small ui issues 2025-10-30 17:32:03 -07:00
Owen
f60c2f4fb9 Make refresh work 2025-10-30 17:25:49 -07:00
Owen
b2cf152b9e Add copy to clip 2025-10-30 16:17:20 -07:00
Owen
444928dffd Add wildcard 2025-10-30 15:27:24 -07:00
Owen
4d7e2d5840 Minor fixes to rc 2025-10-30 11:42:31 -07:00
28 changed files with 766 additions and 1018 deletions

View File

@@ -207,7 +207,7 @@
"alwaysAllow": "Always Allow",
"alwaysDeny": "Always Deny",
"passToAuth": "Pass to Auth",
"orgSettingsDescription": "Configure your organization's general settings",
"orgSettingsDescription": "Configure your organization's settings",
"orgGeneralSettings": "Organization Settings",
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
"saveGeneralSettings": "Save General Settings",
@@ -2057,9 +2057,9 @@
"proxyProtocolVersion": "Proxy Protocol Version",
"version1": " Version 1 (Recommended)",
"version2": "Version 2",
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.",
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
"warning": "Warning",
"proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
"proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
"restarting": "Restarting...",
"manual": "Manual",
"messageSupport": "Message Support",
@@ -2080,5 +2080,6 @@
"supportSending": "Sending...",
"supportSend": "Send",
"supportMessageSent": "Message Sent!",
"supportWillContact": "We'll be in touch shortly!"
"supportWillContact": "We'll be in touch shortly!",
"selectLogRetention": "Select log retention"
}

View File

@@ -167,6 +167,7 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
secretHash: varchar("secretHash").notNull(),
dateCreated: varchar("dateCreated").notNull(),
version: varchar("version"),
secondaryVersion: varchar("secondaryVersion"), // This is to detect the new nodes after the transition to pangolin-node
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "cascade"
})

View File

@@ -14,8 +14,7 @@ bootstrapVolume();
function createDb() {
const sqlite = new Database(location);
return DrizzleSqlite(sqlite, {
schema,
logger: process.env.NODE_ENV === "development"
schema
});
}

View File

@@ -162,6 +162,7 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(),
version: text("version"),
secondaryVersion: text("secondaryVersion"), // This is to detect the new nodes after the transition to pangolin-node
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "cascade"
})

View File

@@ -29,6 +29,19 @@ export async function applyNewtDockerBlueprint(
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
// make sure this is not an empty object
if (isEmptyObject(blueprint)) {
return;
}
if (isEmptyObject(blueprint["proxy-resources"])) {
return;
}
if (isEmptyObject(blueprint["client-resources"])) {
return;
}
// Update the blueprint in the database
await applyBlueprint({
orgId: site.orgId,
@@ -42,7 +55,7 @@ export async function applyNewtDockerBlueprint(
type: "newt/blueprint/results",
data: {
success: false,
message: `Failed to update database from config: ${error}`
message: `Failed to apply blueprint from config: ${error}`
}
});
return;
@@ -56,3 +69,10 @@ export async function applyNewtDockerBlueprint(
}
});
}
function isEmptyObject(obj: any) {
if (obj === null || obj === undefined) {
return true;
}
return Object.keys(obj).length === 0 && obj.constructor === Object;
}

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.12.0";
export const APP_VERSION = "1.12.0-rc.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -378,7 +378,7 @@ export async function getTraefikConfig(
(cert) => cert.queriedDomain === resource.fullDomain
);
if (!matchingCert) {
logger.warn(
logger.debug(
`No matching certificate found for domain: ${resource.fullDomain}`
);
continue;

View File

@@ -1077,7 +1077,12 @@ hybridRouter.get(
.where(eq(resourceRules.resourceId, resourceId));
// backward compatibility: COUNTRY -> GEOIP
if ((remoteExitNode.version && semver.lt(remoteExitNode.version, "1.1.0")) || !remoteExitNode.version) {
// TODO: remove this after a few versions once all exit nodes are updated
if (
(remoteExitNode.secondaryVersion &&
semver.lt(remoteExitNode.secondaryVersion, "1.1.0")) ||
!remoteExitNode.secondaryVersion
) {
for (const rule of rules) {
if (rule.match == "COUNTRY") {
rule.match = "GEOIP";
@@ -1085,6 +1090,10 @@ hybridRouter.get(
}
}
logger.debug(
`Retrieved ${rules.length} rules for resource ID ${resourceId}: ${JSON.stringify(rules)}`
);
return response<(typeof resourceRules.$inferSelect)[]>(res, {
data: rules,
success: true,
@@ -1692,23 +1701,9 @@ const batchLogsSchema = z.object({
});
hybridRouter.post(
"/org/:orgId/logs/batch",
"/logs/batch",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getOrgLoginPageParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = batchLogsSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
@@ -1732,39 +1727,48 @@ hybridRouter.post(
);
}
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
// If the exit node is not allowed for the org, return an error
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
const exitNodeOrgsRes = await db
.select()
.from(exitNodeOrgs)
.where(
and(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId))
)
.limit(1);
// Batch insert all logs in a single query
const logEntries = logs.map((logEntry) => ({
timestamp: logEntry.timestamp,
orgId: logEntry.orgId,
actorType: logEntry.actorType,
actor: logEntry.actor,
actorId: logEntry.actorId,
metadata: logEntry.metadata,
action: logEntry.action,
resourceId: logEntry.resourceId,
reason: logEntry.reason,
location: logEntry.location,
// userAgent: data.userAgent, // TODO: add this
// headers: data.body.headers,
// query: data.body.query,
originalRequestURL: logEntry.originalRequestURL,
scheme: logEntry.scheme,
host: logEntry.host,
path: logEntry.path,
method: logEntry.method,
ip: logEntry.ip,
tls: logEntry.tls
}));
const logEntries = logs
.filter((logEntry) => {
if (!logEntry.orgId) {
return false;
}
const isOrgAllowed = exitNodeOrgsRes.some(
(eno) => eno.orgId === logEntry.orgId
);
return isOrgAllowed;
})
.map((logEntry) => ({
timestamp: logEntry.timestamp,
orgId: logEntry.orgId,
actorType: logEntry.actorType,
actor: logEntry.actor,
actorId: logEntry.actorId,
metadata: logEntry.metadata,
action: logEntry.action,
resourceId: logEntry.resourceId,
reason: logEntry.reason,
location: logEntry.location,
// userAgent: data.userAgent, // TODO: add this
// headers: data.body.headers,
// query: data.body.query,
originalRequestURL: logEntry.originalRequestURL,
scheme: logEntry.scheme,
host: logEntry.host,
path: logEntry.path,
method: logEntry.method,
ip: logEntry.ip,
tls: logEntry.tls
}));
await db.insert(requestAuditLog).values(logEntries);

View File

@@ -29,7 +29,7 @@ export const handleRemoteExitNodeRegisterMessage: MessageHandler = async (
return;
}
const { remoteExitNodeVersion } = message.data;
const { remoteExitNodeVersion, remoteExitNodeSecondaryVersion } = message.data;
if (!remoteExitNodeVersion) {
logger.warn("Remote exit node version not found");
@@ -39,7 +39,7 @@ export const handleRemoteExitNodeRegisterMessage: MessageHandler = async (
// update the version
await db
.update(remoteExitNodes)
.set({ version: remoteExitNodeVersion })
.set({ version: remoteExitNodeVersion, secondaryVersion: remoteExitNodeSecondaryVersion })
.where(
eq(
remoteExitNodes.remoteExitNodeId,

View File

@@ -15,13 +15,11 @@ import config from "@server/lib/config";
import { sendEmail } from "@server/emails";
import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode";
import { hashPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
export const requestPasswordResetBody = z
.object({
email: z
.string()
.toLowerCase()
.email(),
email: z.string().toLowerCase().email()
})
.strict();
@@ -56,12 +54,35 @@ export async function requestPasswordReset(
.where(eq(users.email, email));
if (!existingUser || !existingUser.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email does not exist"
)
await randomDelay(2000);
logger.debug(
`Password reset requested for ${email}, but no such user exists`
);
return response<RequestPasswordResetResponse>(res, {
data: {
sentEmail: true
},
success: true,
error: false,
message: "Password reset requested",
status: HttpCode.OK
});
}
if (existingUser[0].type !== UserType.Internal) {
await randomDelay(2000);
logger.debug(
`Password reset requested for ${email}, but user is of type ${existingUser[0].type}`
);
return response<RequestPasswordResetResponse>(res, {
data: {
sentEmail: true
},
success: true,
error: false,
message: "Password reset requested",
status: HttpCode.OK
});
}
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
@@ -120,3 +141,8 @@ export async function requestPasswordReset(
);
}
}
async function randomDelay(maxDelayMs: number) {
const delay = Math.floor(Math.random() * maxDelayMs);
return new Promise((resolve) => setTimeout(resolve, delay));
}

View File

@@ -1,315 +0,0 @@
import WebSocket from 'ws';
import axios from 'axios';
import { URL } from 'url';
import { EventEmitter } from 'events';
import logger from '@server/logger';
export interface Config {
id: string;
secret: string;
endpoint: string;
}
export interface WSMessage {
type: string;
data: any;
}
export type MessageHandler = (message: WSMessage) => void;
export interface ClientOptions {
baseURL?: string;
reconnectInterval?: number;
pingInterval?: number;
pingTimeout?: number;
}
export class WebSocketClient extends EventEmitter {
private conn: WebSocket | null = null;
private baseURL: string;
private handlers: Map<string, MessageHandler> = new Map();
private reconnectInterval: number;
private isConnected: boolean = false;
private pingInterval: number;
private pingTimeout: number;
private shouldReconnect: boolean = true;
private reconnectTimer: NodeJS.Timeout | null = null;
private pingTimer: NodeJS.Timeout | null = null;
private pingTimeoutTimer: NodeJS.Timeout | null = null;
private token: string;
private isConnecting: boolean = false;
constructor(
token: string,
endpoint: string,
options: ClientOptions = {}
) {
super();
this.token = token;
this.baseURL = options.baseURL || endpoint;
this.reconnectInterval = options.reconnectInterval || 5000;
this.pingInterval = options.pingInterval || 30000;
this.pingTimeout = options.pingTimeout || 10000;
}
public async connect(): Promise<void> {
this.shouldReconnect = true;
if (!this.isConnecting) {
await this.connectWithRetry();
}
}
public async close(): Promise<void> {
this.shouldReconnect = false;
// Clear timers
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
this.pingTimeoutTimer = null;
}
if (this.conn) {
this.conn.close(1000, 'Client closing');
this.conn = null;
}
this.setConnected(false);
}
public sendMessage(messageType: string, data: any): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.conn || this.conn.readyState !== WebSocket.OPEN) {
reject(new Error('Not connected'));
return;
}
const message: WSMessage = {
type: messageType,
data: data
};
logger.debug(`Sending message: ${messageType}`, data);
this.conn.send(JSON.stringify(message), (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
public sendMessageInterval(
messageType: string,
data: any,
interval: number
): () => void {
// Send immediately
this.sendMessage(messageType, data).catch(err => {
logger.error('Failed to send initial message:', err);
});
// Set up interval
const intervalId = setInterval(() => {
this.sendMessage(messageType, data).catch(err => {
logger.error('Failed to send message:', err);
});
}, interval);
// Return stop function
return () => {
clearInterval(intervalId);
};
}
public registerHandler(messageType: string, handler: MessageHandler): void {
this.handlers.set(messageType, handler);
}
public unregisterHandler(messageType: string): void {
this.handlers.delete(messageType);
}
public isClientConnected(): boolean {
return this.isConnected;
}
private async connectWithRetry(): Promise<void> {
if (this.isConnecting || this.isConnected) return;
this.isConnecting = true;
while (this.shouldReconnect && !this.isConnected && this.isConnecting) {
try {
await this.establishConnection();
this.isConnecting = false;
return;
} catch (error) {
logger.error(`Failed to connect: ${error}. Retrying in ${this.reconnectInterval}ms...`);
if (!this.shouldReconnect || !this.isConnecting) {
this.isConnecting = false;
return;
}
await new Promise(resolve => {
this.reconnectTimer = setTimeout(resolve, this.reconnectInterval);
});
}
}
this.isConnecting = false;
}
private async establishConnection(): Promise<void> {
// Clean up any existing connection before establishing a new one
if (this.conn) {
this.conn.removeAllListeners();
this.conn.close();
this.conn = null;
}
// Parse the base URL to determine protocol and hostname
const baseURL = new URL(this.baseURL);
const wsProtocol = baseURL.protocol === 'https:' ? 'wss' : 'ws';
const wsURL = new URL(`${wsProtocol}://${baseURL.host}/api/v1/ws`);
// Add token and client type to query parameters
wsURL.searchParams.set('token', this.token);
wsURL.searchParams.set('clientType', "remoteExitNode");
return new Promise((resolve, reject) => {
const conn = new WebSocket(wsURL.toString());
conn.on('open', () => {
logger.debug('WebSocket connection established');
this.conn = conn;
this.setConnected(true);
this.isConnecting = false;
this.startPingMonitor();
this.emit('connect');
resolve();
});
conn.on('message', (data: WebSocket.Data) => {
try {
const message: WSMessage = JSON.parse(data.toString());
const handler = this.handlers.get(message.type);
if (handler) {
handler(message);
}
this.emit('message', message);
} catch (error) {
logger.error('Failed to parse message:', error);
}
});
conn.on('close', (code, reason) => {
logger.debug(`WebSocket connection closed: ${code} ${reason}`);
this.handleDisconnect();
});
conn.on('error', (error) => {
logger.error('WebSocket error:', error);
if (this.conn === null) {
// Connection failed during establishment
reject(error);
}
// Don't call handleDisconnect here as the 'close' event will handle it
});
conn.on('pong', () => {
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
this.pingTimeoutTimer = null;
}
});
});
}
private startPingMonitor(): void {
// Clear any existing ping timer to prevent duplicates
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
this.pingTimer = setInterval(() => {
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.ping();
// Set timeout for pong response
this.pingTimeoutTimer = setTimeout(() => {
logger.error('Ping timeout - no pong received');
this.handleDisconnect();
}, this.pingTimeout);
}
}, this.pingInterval);
}
private handleDisconnect(): void {
// Prevent multiple disconnect handlers from running simultaneously
if (!this.isConnected && !this.isConnecting) {
return;
}
this.setConnected(false);
this.isConnecting = false;
// Clear ping timers
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
if (this.pingTimeoutTimer) {
clearTimeout(this.pingTimeoutTimer);
this.pingTimeoutTimer = null;
}
// Clear any existing reconnect timer to prevent multiple reconnection attempts
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.conn) {
this.conn.removeAllListeners();
this.conn = null;
}
this.emit('disconnect');
// Reconnect if needed
if (this.shouldReconnect) {
// Add a small delay before starting reconnection to prevent immediate retry
this.reconnectTimer = setTimeout(() => {
this.connectWithRetry();
}, 1000);
}
}
private setConnected(status: boolean): void {
this.isConnected = status;
}
}
// Factory function for easier instantiation
export function createWebSocketClient(
token: string,
endpoint: string,
options?: ClientOptions
): WebSocketClient {
return new WebSocketClient(token, endpoint, options);
}
export default WebSocketClient;

View File

@@ -100,7 +100,7 @@ async function copyInDomains() {
{
domainId,
recordType: "A",
baseDomain,
baseDomain: `*.${baseDomain}`,
value: "Server IP Address",
verified: true
}
@@ -127,7 +127,7 @@ async function copyInDomains() {
{
domainId,
recordType: "A",
baseDomain,
baseDomain: `*.${baseDomain}`,
value: "Server IP Address",
verified: true
}

View File

@@ -94,6 +94,7 @@ export default async function migration() {
await db.execute(sql`ALTER TABLE "blueprints" ADD CONSTRAINT "blueprints_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`);
await db.execute(sql`ALTER TABLE "remoteExitNode" ADD COLUMN "secondaryVersion" varchar;`);
await db.execute(sql`ALTER TABLE "resources" DROP CONSTRAINT "resources_skipToIdpId_idp_idpId_fk";`);
await db.execute(sql`ALTER TABLE "domains" ADD COLUMN "certResolver" varchar;`);
await db.execute(sql`ALTER TABLE "domains" ADD COLUMN "customCertResolver" varchar;`);

View File

@@ -212,6 +212,7 @@ export default async function migration() {
db.prepare(
`ALTER TABLE 'user' ADD 'lastPasswordChange' integer;`
).run();
db.prepare(`ALTER TABLE 'remoteExitNode' ADD 'secondaryVersion' text;`).run();
// get all of the domains
const domains = db.prepare(`SELECT domainId, baseDomain from domains`).all() as {

View File

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

View File

@@ -1,75 +1,53 @@
"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";
import RestartDomainButton from "@app/components/RestartDomainButton";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { pullEnv } from "@app/lib/pullEnv";
import { getTranslations } from "next-intl/server";
import RefreshButton from "@app/components/RefreshButton";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm";
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 { env } = useEnvContext();
interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>;
}
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);
}
};
export default async function DomainSettingsPage({
params
}: DomainSettingsPageProps) {
const { domainId, orgId } = await params;
const t = await getTranslations();
const env = pullEnv();
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;
});
}
};
let domain: GetDomainResponse | null = null;
try {
const res = await internal.get(
`/org/${orgId}/domain/${domainId}`,
await authCookieHeader()
);
domain = res.data.data;
} catch {
return null;
}
let dnsRecords;
try {
const response = await internal.get(
`/org/${orgId}/domain/${domainId}/dns-records`,
await authCookieHeader()
);
dnsRecords = response.data.data;
} catch (error) {
return null;
}
if (!domain) {
return null;
}
const isRestarting = restartingDomains.has(domain.domainId);
return (
<>
<div className="flex justify-between">
@@ -77,32 +55,31 @@ export default function DomainSettingsPage() {
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && (
<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>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
/>
) : (
<RefreshButton />
)}
</div>
<div className="space-y-6">
<DomainInfoCard orgId={orgId} domainId={domain.domainId} />
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
/>
<DNSRecordsTable records={dnsRecords} type={domain.type} />
{domain.type == "wildcard" && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
);

View File

@@ -72,7 +72,7 @@ export default async function GeneralSettingsPage({
<OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}>
<SettingsSectionTitle
title={t('general')}
title={t('orgGeneralSettings')}
description={t('orgSettingsDescription')}
/>

View File

@@ -318,7 +318,7 @@ export default function GeneralPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("orgGeneralSettings")}
{t("general")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("orgGeneralSettingsDescription")}
@@ -420,10 +420,14 @@ export default function GeneralPage() {
}
).map((option) => (
<SelectItem
key={option.value}
key={
option.value
}
value={option.value.toString()}
>
{t(option.label)}
{t(
option.label
)}
</SelectItem>
))}
</SelectContent>
@@ -444,8 +448,7 @@ export default function GeneralPage() {
render={({ field }) => {
const isDisabled =
(build == "saas" &&
!subscription
?.subscribed) ||
!subscription?.subscribed) ||
(build == "enterprise" &&
!isUnlocked());
@@ -493,9 +496,7 @@ export default function GeneralPage() {
key={
option.value
}
value={
option.value.toString()
}
value={option.value.toString()}
>
{t(
option.label
@@ -517,8 +518,7 @@ export default function GeneralPage() {
render={({ field }) => {
const isDisabled =
(build == "saas" &&
!subscription
?.subscribed) ||
!subscription?.subscribed) ||
(build == "enterprise" &&
!isUnlocked());
@@ -566,9 +566,7 @@ export default function GeneralPage() {
key={
option.value
}
value={
option.value.toString()
}
value={option.value.toString()}
>
{t(
option.label
@@ -589,29 +587,20 @@ export default function GeneralPage() {
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</form>
</Form>
{build !== "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SecurityFeaturesAlert />
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="security-settings-form"
>
{build !== "oss" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("securitySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("securitySettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SecurityFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
@@ -836,12 +825,12 @@ export default function GeneralPage() {
);
}}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</form>
</Form>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}

View File

@@ -699,24 +699,24 @@ export default function GeneralPage() {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
{/* <div>
<strong>User Agent:</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.userAgent || "N/A"}
</p>
</div>
</div> */}
<div>
<strong>Original URL:</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.originalRequestURL || "N/A"}
</p>
</div>
<div>
{/* <div>
<strong>Scheme:</strong>
<p className="text-muted-foreground mt-1">
{row.scheme || "N/A"}
</p>
</div>
</div> */}
<div>
<strong>Metadata:</strong>
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">

View File

@@ -1697,7 +1697,7 @@ export default function ReverseProxyTargets(props: {
</SettingsSection>
)}
{!resource.http && resource.protocol && (
{!resource.http && resource.protocol == "tcp" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -4,10 +4,10 @@ import { ColumnDef } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
import CopyToClipboard from "@app/components/CopyToClipboard";
export type DNSRecordRow = {
id: string;
domainId: string;
recordType: string; // "NS" | "CNAME" | "A" | "TXT"
baseDomain: string | null;
value: string;
@@ -16,15 +16,11 @@ export type DNSRecordRow = {
type Props = {
records: DNSRecordRow[];
domainId: string;
isRefreshing?: boolean;
type: string | null;
};
export default function DNSRecordsTable({
records,
domainId,
isRefreshing,
type
}: Props) {
const t = useTranslations();
@@ -39,7 +35,15 @@ export default function DNSRecordsTable({
},
cell: ({ row }) => {
const baseDomain = row.original.baseDomain;
return <div>{baseDomain || "-"}</div>;
return baseDomain ? (
<CopyToClipboard
text={baseDomain}
displayText={baseDomain}
isLink={false}
/>
) : (
<div>-</div>
);
}
},
{
@@ -68,7 +72,13 @@ export default function DNSRecordsTable({
},
cell: ({ row }) => {
const value = row.original.value;
return <div>{value}</div>;
return (
<CopyToClipboard
text={value}
displayText={value}
isLink={false}
/>
);
}
},
{
@@ -99,7 +109,6 @@ export default function DNSRecordsTable({
<DNSRecordsDataTable
columns={columns}
data={records}
isRefreshing={isRefreshing}
type={type}
/>
);

View File

@@ -30,7 +30,6 @@ import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import Link from "next/link";
import { build } from "@server/build";
type TabFilter = {
id: string;
@@ -111,7 +110,7 @@ export function DNSRecordsDataTable<TData, TValue>({
<h1 className="font-bold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
</div>
<Link href="https://docs.pangolin.net/manage/domains">
<Link href="https://docs.pangolin.net/manage/domains" target="_blank" rel="noopener noreferrer">
<Button variant="outline">
<ExternalLink className="h-4 w-4 mr-1" />
{t("howToAddRecords")}

View File

@@ -0,0 +1,365 @@
"use client";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDomainContext } from "@app/hooks/useDomainContext";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
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 { createApiClient } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { GetDomainResponse } from "@server/routers/domain";
type DomainInfoCardProps = {
orgId?: string;
domainId?: string;
domain: GetDomainResponse;
};
// 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 DomainCertForm({
orgId,
domainId,
domain
}: DomainInfoCardProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const { toast } = useToast();
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 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 {
if (!values.certResolver) {
values.certResolver = null;
}
await api.patch(`/org/${orgId}/domain/${domainId}`, {
certResolver: values.certResolver,
preferWildcardCert: values.preferWildcardCert
});
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);
}
};
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("domainSetting")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="domain-settings-form"
>
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("certResolver")}
</FormLabel>
<FormControl>
<Select
value={
field.value === null
? "default"
: field.value ===
"" ||
(field.value &&
field.value !==
"default")
? "custom"
: "default"
}
onValueChange={(
val
) => {
if (
val ===
"default"
) {
field.onChange(
null
);
} else if (
val === "custom"
) {
field.onChange(
""
);
} else {
field.onChange(
val
);
}
}}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectCertResolver"
)}
/>
</SelectTrigger>
<SelectContent>
{certResolverOptions.map(
(opt) => (
<SelectItem
key={
opt.id
}
value={
opt.id
}
>
{
opt.title
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !==
"default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"enterCustomResolver"
)}
value={
field.value ||
""
}
onChange={(e) =>
field.onChange(
e.target
.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !==
"default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({
field: switchField
}) => (
<FormItem className="items-center space-y-2 mt-4">
<FormControl>
<div className="flex items-center space-x-2">
<Switch
checked={
switchField.value
}
onCheckedChange={
switchField.onChange
}
/>
<FormLabel>
{t(
"preferWildcardCert"
)}
</FormLabel>
</div>
</FormControl>
<FormDescription>
{t(
"preferWildcardCertDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="domain-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}

View File

@@ -8,233 +8,20 @@ import {
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;
failed: boolean;
verified: boolean;
type: string | null;
};
// 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
failed,
verified,
type
}: 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 {
if (!values.certResolver) {
values.certResolver = null;
}
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) {
@@ -250,243 +37,43 @@ export default function DomainInfoCard({
};
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 ? (
domain.type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
<span>{getTypeDisplay(type ? type : "")}</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{failed ? (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
) : verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<DNSRecordsTable
domainId={domain.domainId}
records={dnsRecords}
isRefreshing={isRefreshing}
type={domain.type}
/>
{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>
)}
</>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -3,7 +3,12 @@
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
RefreshCw
} from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
@@ -193,7 +198,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
);
} else if (failed) {
return (
<Badge variant="destructive">
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
@@ -217,6 +222,9 @@ export default function DomainsTable({ domains, orgId }: Props) {
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRestarting ? "animate-spin" : ""}`}
/>
{isRestarting
? t("restarting", {
fallback: "Restarting..."

View File

@@ -7,7 +7,6 @@ import { cn } from "@app/lib/cn";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
export type HorizontalTabs = Array<{
title: string;
@@ -30,7 +29,6 @@ export function HorizontalTabs({
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const t = useTranslations();
function hydrateHref(href: string) {

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
import { toast } from "@app/hooks/useToast";
export default function RefreshButton() {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
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);
}
};
return (
<Button
variant="outline"
onClick={refreshData}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("refresh", { fallback: "Refresh" })}
</Button>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
interface RestartDomainButtonProps {
orgId: string;
domainId: string;
}
export default function RestartDomainButton({ orgId, domainId }: RestartDomainButtonProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const [isRestarting, setIsRestarting] = useState(false);
const t = useTranslations();
const restartDomain = async () => {
setIsRestarting(true);
try {
await api.post(`/org/${orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", {
fallback: "Domain verification restarted successfully"
})
});
// Wait a bit before refreshing to allow the restart to take effect
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setIsRestarting(false);
}
};
return (
<Button
variant="outline"
onClick={restartDomain}
disabled={isRestarting}
>
{isRestarting ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
{t("restarting", { fallback: "Restarting..." })}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
{t("restart", { fallback: "Restart" })}
</>
)}
</Button>
);
}