mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 20:52:40 +00:00
Merge branch 'dev' into alerting-rules
This commit is contained in:
@@ -19,6 +19,7 @@ export class TraefikConfigManager {
|
|||||||
private timeoutId: NodeJS.Timeout | null = null;
|
private timeoutId: NodeJS.Timeout | null = null;
|
||||||
private lastCertificateFetch: Date | null = null;
|
private lastCertificateFetch: Date | null = null;
|
||||||
private lastKnownDomains = new Set<string>();
|
private lastKnownDomains = new Set<string>();
|
||||||
|
private pendingDeletion = new Map<string, number>(); // domain -> cycles remaining before delete
|
||||||
private lastLocalCertificateState = new Map<
|
private lastLocalCertificateState = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -1004,33 +1005,62 @@ export class TraefikConfigManager {
|
|||||||
|
|
||||||
const dirName = dirent.name;
|
const dirName = dirent.name;
|
||||||
// Only delete if NO current domain is exactly the same or ends with `.${dirName}`
|
// Only delete if NO current domain is exactly the same or ends with `.${dirName}`
|
||||||
const shouldDelete = !Array.from(currentActiveDomains).some(
|
const isUnused = !Array.from(currentActiveDomains).some(
|
||||||
(domain) =>
|
(domain) =>
|
||||||
domain === dirName || domain.endsWith(`.${dirName}`)
|
domain === dirName || domain.endsWith(`.${dirName}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldDelete) {
|
if (!isUnused) {
|
||||||
const domainDir = path.join(certsPath, dirName);
|
// Domain is still active — remove from pending deletion if it was queued
|
||||||
logger.info(
|
if (this.pendingDeletion.has(dirName)) {
|
||||||
`Cleaning up unused certificate directory: ${dirName}`
|
logger.info(
|
||||||
);
|
`Certificate ${dirName} is active again, cancelling pending deletion`
|
||||||
fs.rmSync(domainDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
// Remove from local state tracking
|
|
||||||
this.lastLocalCertificateState.delete(dirName);
|
|
||||||
|
|
||||||
// Remove from dynamic config
|
|
||||||
const certFilePath = path.join(domainDir, "cert.pem");
|
|
||||||
const keyFilePath = path.join(domainDir, "key.pem");
|
|
||||||
const before = dynamicConfig.tls.certificates.length;
|
|
||||||
dynamicConfig.tls.certificates =
|
|
||||||
dynamicConfig.tls.certificates.filter(
|
|
||||||
(entry: any) =>
|
|
||||||
entry.certFile !== certFilePath &&
|
|
||||||
entry.keyFile !== keyFilePath
|
|
||||||
);
|
);
|
||||||
if (dynamicConfig.tls.certificates.length !== before) {
|
this.pendingDeletion.delete(dirName);
|
||||||
configChanged = true;
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain is unused — add to pending deletion or decrement its counter
|
||||||
|
if (!this.pendingDeletion.has(dirName)) {
|
||||||
|
const graceCycles = 3;
|
||||||
|
logger.info(
|
||||||
|
`Certificate ${dirName} is no longer in use. Will delete after ${graceCycles} more cycles.`
|
||||||
|
);
|
||||||
|
this.pendingDeletion.set(dirName, graceCycles);
|
||||||
|
} else {
|
||||||
|
const remaining = this.pendingDeletion.get(dirName)! - 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Certificate ${dirName} pending deletion: ${remaining} cycle(s) remaining`
|
||||||
|
);
|
||||||
|
this.pendingDeletion.set(dirName, remaining);
|
||||||
|
} else {
|
||||||
|
// Grace period expired — actually delete now
|
||||||
|
this.pendingDeletion.delete(dirName);
|
||||||
|
|
||||||
|
const domainDir = path.join(certsPath, dirName);
|
||||||
|
logger.info(
|
||||||
|
`Cleaning up unused certificate directory: ${dirName}`
|
||||||
|
);
|
||||||
|
fs.rmSync(domainDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Remove from local state tracking
|
||||||
|
this.lastLocalCertificateState.delete(dirName);
|
||||||
|
|
||||||
|
// Remove from dynamic config
|
||||||
|
const certFilePath = path.join(domainDir, "cert.pem");
|
||||||
|
const keyFilePath = path.join(domainDir, "key.pem");
|
||||||
|
const before = dynamicConfig.tls.certificates.length;
|
||||||
|
dynamicConfig.tls.certificates =
|
||||||
|
dynamicConfig.tls.certificates.filter(
|
||||||
|
(entry: any) =>
|
||||||
|
entry.certFile !== certFilePath &&
|
||||||
|
entry.keyFile !== keyFilePath
|
||||||
|
);
|
||||||
|
if (dynamicConfig.tls.certificates.length !== before) {
|
||||||
|
configChanged = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,13 @@ if (build === "saas") {
|
|||||||
generateLicense.generateNewEnterpriseLicense
|
generateLicense.generateNewEnterpriseLicense
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/license/:licenseKey/clear-instance-name",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.billing),
|
||||||
|
generateLicense.clearInstanceName
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/send-support-request",
|
"/send-support-request",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
|
|||||||
87
server/private/routers/generatedLicense/clearInstanceName.ts
Normal file
87
server/private/routers/generatedLicense/clearInstanceName.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import z from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const clearInstanceNameParamsSchema = z.object({
|
||||||
|
orgId: z.string(),
|
||||||
|
licenseKey: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function clearInstanceName(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { licenseKey } = parsedParams.data;
|
||||||
|
|
||||||
|
const apiResponse = await fetch(
|
||||||
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/clear-instance-name`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key":
|
||||||
|
privateConfig.getRawPrivateConfig().server
|
||||||
|
.fossorial_api_key!,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
|
||||||
|
if (!data.success || data.error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
data.status || HttpCode.BAD_REQUEST,
|
||||||
|
data.message || "Failed to clear instance name from Fossorial API"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendResponse<null>(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Instance name cleared successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while clearing the instance name."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@
|
|||||||
export * from "./listGeneratedLicenses";
|
export * from "./listGeneratedLicenses";
|
||||||
export * from "./generateNewLicense";
|
export * from "./generateNewLicense";
|
||||||
export * from "./generateNewEnterpriseLicense";
|
export * from "./generateNewEnterpriseLicense";
|
||||||
|
export * from "./clearInstanceName";
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ registry.registerPath({
|
|||||||
path: "/org/{orgId}/client/{niceId}",
|
path: "/org/{orgId}/client/{niceId}",
|
||||||
description:
|
description:
|
||||||
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
|
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
|
||||||
tags: [OpenAPITags.Site],
|
tags: [OpenAPITags.Client],
|
||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import moment from "moment";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { isValidIP } from "@server/lib/validators";
|
import { isValidIP } from "@server/lib/validators";
|
||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip";
|
||||||
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
const createSiteParamsSchema = z.strictObject({
|
const createSiteParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -28,6 +29,7 @@ const createSiteParamsSchema = z.strictObject({
|
|||||||
const createSiteSchema = z.strictObject({
|
const createSiteSchema = z.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
exitNodeId: z.int().positive().optional(),
|
exitNodeId: z.int().positive().optional(),
|
||||||
|
niceId: z.string().min(1).max(255).optional(),
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
@@ -52,7 +54,10 @@ const createSiteSchema = z.strictObject({
|
|||||||
|
|
||||||
export type CreateSiteBody = z.infer<typeof createSiteSchema>;
|
export type CreateSiteBody = z.infer<typeof createSiteSchema>;
|
||||||
|
|
||||||
export type CreateSiteResponse = Site;
|
export type CreateSiteResponse = Site & {
|
||||||
|
newtId?: string;
|
||||||
|
secret?: string;
|
||||||
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
@@ -64,7 +69,11 @@ registry.registerPath({
|
|||||||
body: {
|
body: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createSiteSchema
|
schema: createSiteSchema,
|
||||||
|
example: {
|
||||||
|
name: "My Site",
|
||||||
|
type: "newt"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,9 +105,13 @@ export async function createSite(
|
|||||||
subnet,
|
subnet,
|
||||||
newtId,
|
newtId,
|
||||||
secret,
|
secret,
|
||||||
address
|
address,
|
||||||
|
niceId
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
const updatedNewtSecret = secret || generateId(48);
|
||||||
|
const updatedNewtId = newtId || generateId(15);
|
||||||
|
|
||||||
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
@@ -111,7 +124,10 @@ export async function createSite(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
if (
|
||||||
|
req.user &&
|
||||||
|
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
@@ -227,6 +243,18 @@ export async function createSite(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||||
|
if (!newClientAddress) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"No available address found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedAddress = newClientAddress.split("/")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subnet && exitNodeId) {
|
if (subnet && exitNodeId) {
|
||||||
@@ -285,7 +313,31 @@ export async function createSite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const niceId = await getUniqueSiteName(orgId);
|
let updatedNiceId = niceId;
|
||||||
|
if (!niceId) {
|
||||||
|
updatedNiceId = await getUniqueSiteName(orgId);
|
||||||
|
} else {
|
||||||
|
// make sure the niceId is unique
|
||||||
|
const existingSite = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.niceId, niceId),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSite.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`Nice ID ${niceId} already exists. Please choose a different one.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -295,7 +347,7 @@ export async function createSite(
|
|||||||
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
|
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId: updatedNiceId!,
|
||||||
address: updatedAddress || null,
|
address: updatedAddress || null,
|
||||||
type,
|
type,
|
||||||
dockerSocketEnabled: true,
|
dockerSocketEnabled: true,
|
||||||
@@ -353,7 +405,7 @@ export async function createSite(
|
|||||||
orgId,
|
orgId,
|
||||||
exitNodeId,
|
exitNodeId,
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId: updatedNiceId!,
|
||||||
subnet,
|
subnet,
|
||||||
type,
|
type,
|
||||||
pubKey: pubKey || null,
|
pubKey: pubKey || null,
|
||||||
@@ -367,8 +419,7 @@ export async function createSite(
|
|||||||
exitNodeId: exitNodeId || null,
|
exitNodeId: exitNodeId || null,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId: updatedNiceId!,
|
||||||
address: updatedAddress || null,
|
|
||||||
type,
|
type,
|
||||||
dockerSocketEnabled: false,
|
dockerSocketEnabled: false,
|
||||||
online: true,
|
online: true,
|
||||||
@@ -402,7 +453,10 @@ export async function createSite(
|
|||||||
siteId: newSite.siteId
|
siteId: newSite.siteId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
if (
|
||||||
|
req.user &&
|
||||||
|
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||||
|
) {
|
||||||
// make sure the user can access the site
|
// make sure the user can access the site
|
||||||
trx.insert(userSites).values({
|
trx.insert(userSites).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
@@ -412,10 +466,10 @@ export async function createSite(
|
|||||||
|
|
||||||
// add the peer to the exit node
|
// add the peer to the exit node
|
||||||
if (type == "newt") {
|
if (type == "newt") {
|
||||||
const secretHash = await hashPassword(secret!);
|
const secretHash = await hashPassword(updatedNewtSecret);
|
||||||
|
|
||||||
await trx.insert(newts).values({
|
await trx.insert(newts).values({
|
||||||
newtId: newtId!,
|
newtId: updatedNewtId,
|
||||||
secretHash,
|
secretHash,
|
||||||
siteId: newSite.siteId,
|
siteId: newSite.siteId,
|
||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
@@ -458,7 +512,11 @@ export async function createSite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response<CreateSiteResponse>(res, {
|
return response<CreateSiteResponse>(res, {
|
||||||
data: newSite,
|
data: {
|
||||||
|
...newSite,
|
||||||
|
newtId: type == "newt" ? updatedNewtId : undefined,
|
||||||
|
secret: type == "newt" ? updatedNewtSecret : undefined
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Site created successfully",
|
message: "Site created successfully",
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export async function pickSiteDefaults(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"No available subnet found"
|
"No available address"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export default function BillingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleContactUs = () => {
|
const handleContactUs = () => {
|
||||||
window.open("https://pangolin.net/talk-to-us", "_blank");
|
window.open("https://pangolin.net/contact", "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current plan ID from tier
|
// Get current plan ID from tier
|
||||||
@@ -558,6 +558,14 @@ export default function BillingPage() {
|
|||||||
// Get button label and action for each plan
|
// Get button label and action for each plan
|
||||||
const getPlanAction = (plan: PlanOption) => {
|
const getPlanAction = (plan: PlanOption) => {
|
||||||
if (plan.id === "enterprise") {
|
if (plan.id === "enterprise") {
|
||||||
|
if (plan.id === currentPlanId) {
|
||||||
|
return {
|
||||||
|
label: "Manage Current Plan",
|
||||||
|
action: handleModifySubscription,
|
||||||
|
variant: "default" as const,
|
||||||
|
disabled: false
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: "Contact Us",
|
label: "Contact Us",
|
||||||
action: handleContactUs,
|
action: handleContactUs,
|
||||||
|
|||||||
@@ -161,16 +161,13 @@ export default function Page() {
|
|||||||
description: t("siteNewtTunnelDescription"),
|
description: t("siteNewtTunnelDescription"),
|
||||||
disabled: true
|
disabled: true
|
||||||
},
|
},
|
||||||
...(env.flags.disableBasicWireguardSites
|
...(env.flags.disableBasicWireguardSites || build == "saas"
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: "wireguard" as SiteType,
|
id: "wireguard" as SiteType,
|
||||||
title: t("siteWg"),
|
title: t("siteWg"),
|
||||||
description:
|
description: t("siteWgDescription"),
|
||||||
build == "saas"
|
|
||||||
? t("siteWgDescriptionSaas")
|
|
||||||
: t("siteWgDescription"),
|
|
||||||
disabled: true
|
disabled: true
|
||||||
}
|
}
|
||||||
]),
|
]),
|
||||||
@@ -426,9 +423,22 @@ export default function Page() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setRemoteExitNodeOptions(exitNodeOptions);
|
setRemoteExitNodeOptions(exitNodeOptions);
|
||||||
|
|
||||||
|
if (exitNodeOptions.length === 0) {
|
||||||
|
// No remote exit nodes available — remove local option and default to newt
|
||||||
|
setTunnelTypes((prev: any) =>
|
||||||
|
prev.filter((item: any) => item.id !== "local")
|
||||||
|
);
|
||||||
|
form.setValue("method", "newt");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch remote exit nodes:", error);
|
console.error("Failed to fetch remote exit nodes:", error);
|
||||||
|
// If fetch fails, no remote exit nodes available — remove local option and default to newt
|
||||||
|
setTunnelTypes((prev: any) =>
|
||||||
|
prev.filter((item: any) => item.id !== "local")
|
||||||
|
);
|
||||||
|
form.setValue("method", "newt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,18 +66,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||||||
© {new Date().getFullYear()} Fossorial, Inc.
|
© {new Date().getFullYear()} Fossorial, Inc.
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<Separator orientation="vertical" />
|
{build !== "saas" && (
|
||||||
<a
|
<>
|
||||||
href="https://pangolin.net"
|
<Separator orientation="vertical" />
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href="https://pangolin.net"
|
||||||
aria-label="Built by Fossorial"
|
target="_blank"
|
||||||
className="flex items-center space-x-2 whitespace-nowrap"
|
rel="noopener noreferrer"
|
||||||
>
|
aria-label="Built by Fossorial"
|
||||||
<span>
|
className="flex items-center space-x-2 whitespace-nowrap"
|
||||||
{process.env.BRANDING_APP_NAME || "Pangolin"}
|
>
|
||||||
</span>
|
<span>
|
||||||
</a>
|
{process.env.BRANDING_APP_NAME ||
|
||||||
|
"Pangolin"}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<span>
|
<span>
|
||||||
{build === "oss"
|
{build === "oss"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ArrowUpDown } from "lucide-react";
|
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import CopyToClipboard from "./CopyToClipboard";
|
import CopyToClipboard from "./CopyToClipboard";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -16,6 +16,12 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import NewPricingLicenseForm from "./NewPricingLicenseForm";
|
import NewPricingLicenseForm from "./NewPricingLicenseForm";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
|
||||||
type GnerateLicenseKeysTableProps = {
|
type GnerateLicenseKeysTableProps = {
|
||||||
licenseKeys: GeneratedLicenseKey[];
|
licenseKeys: GeneratedLicenseKey[];
|
||||||
@@ -44,6 +50,7 @@ export default function GenerateLicenseKeysTable({
|
|||||||
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||||
|
const [isClearingInstanceName, setIsClearingInstanceName] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get(GENERATE_QUERY) !== null) {
|
if (searchParams.get(GENERATE_QUERY) !== null) {
|
||||||
@@ -63,6 +70,28 @@ export default function GenerateLicenseKeysTable({
|
|||||||
refreshData();
|
refreshData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearInstanceName = async (licenseKey: string) => {
|
||||||
|
setIsClearingInstanceName(true);
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/org/${orgId}/license/${encodeURIComponent(licenseKey)}/clear-instance-name`
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: "Instance name cleared successfully"
|
||||||
|
});
|
||||||
|
await refreshData();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(error, "Failed to clear instance name"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsClearingInstanceName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
@@ -236,6 +265,39 @@ export default function GenerateLicenseKeysTable({
|
|||||||
const termianteAt = row.original.expiresAt;
|
const termianteAt = row.original.expiresAt;
|
||||||
return moment(termianteAt).format("lll");
|
return moment(termianteAt).format("lll");
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const key = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={
|
||||||
|
!key.instanceName ||
|
||||||
|
isClearingInstanceName
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
clearInstanceName(key.licenseKey)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Clear Instance Name
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -254,6 +316,7 @@ export default function GenerateLicenseKeysTable({
|
|||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
setShowGenerateForm(true);
|
setShowGenerateForm(true);
|
||||||
}}
|
}}
|
||||||
|
stickyRightColumn="actions"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewPricingLicenseForm
|
<NewPricingLicenseForm
|
||||||
|
|||||||
Reference in New Issue
Block a user