mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-30 06:40:46 +00:00
Compare commits
13 Commits
1.12.0-rc.
...
1.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b66cd911 | ||
|
|
da0196a308 | ||
|
|
e585972b7b | ||
|
|
cc62cd4add | ||
|
|
25225a452c | ||
|
|
678644c7fb | ||
|
|
32f20ed984 | ||
|
|
4eb5bf08d5 | ||
|
|
35c93f38e0 | ||
|
|
f60c2f4fb9 | ||
|
|
b2cf152b9e | ||
|
|
444928dffd | ||
|
|
4d7e2d5840 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
|
||||
@@ -14,8 +14,7 @@ bootstrapVolume();
|
||||
function createDb() {
|
||||
const sqlite = new Database(location);
|
||||
return DrizzleSqlite(sqlite, {
|
||||
schema,
|
||||
logger: process.env.NODE_ENV === "development"
|
||||
schema
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1697,7 +1697,7 @@ export default function ReverseProxyTargets(props: {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{!resource.http && resource.protocol && (
|
||||
{!resource.http && resource.protocol == "tcp" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
365
src/components/DomainCertForm.tsx
Normal file
365
src/components/DomainCertForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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) {
|
||||
|
||||
43
src/components/RefreshButton.tsx
Normal file
43
src/components/RefreshButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/RestartDomainButton.tsx
Normal file
66
src/components/RestartDomainButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user