set auth daemon type on resource

This commit is contained in:
miloschwartz
2026-02-20 17:33:21 -08:00
parent 6442eb12fb
commit d6ba34aeea
33 changed files with 2010 additions and 2800 deletions

View File

@@ -0,0 +1,121 @@
import { CommandModule } from "yargs";
import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/private/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
type GenerateOrgCaKeysArgs = {
orgId: string;
secret?: string;
force?: boolean;
};
export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = {
command: "generate-org-ca-keys",
describe:
"Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)",
builder: (yargs) => {
return yargs
.option("orgId", {
type: "string",
demandOption: true,
describe: "The organization ID"
})
.option("secret", {
type: "string",
describe:
"Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)."
})
.option("force", {
type: "boolean",
default: false,
describe:
"Overwrite existing CA keys for the org if they already exist"
});
},
handler: async (argv: {
orgId: string;
secret?: string;
force?: boolean;
}) => {
try {
const { orgId, force } = argv;
let secret = argv.secret;
if (!secret) {
const configPath = fs.existsSync(configFilePath1)
? configFilePath1
: fs.existsSync(configFilePath2)
? configFilePath2
: null;
if (!configPath) {
console.error(
"Error: No server secret provided and config file not found. " +
"Expected config.yml or config.yaml in the config directory, or pass --secret."
);
process.exit(1);
}
const configContent = fs.readFileSync(configPath, "utf8");
const config = yaml.load(configContent) as {
server?: { secret?: string };
};
if (!config?.server?.secret) {
console.error(
"Error: No server.secret in config file. Pass --secret or set server.secret in config."
);
process.exit(1);
}
secret = config.server.secret;
}
const [org] = await db
.select({
orgId: orgs.orgId,
sshCaPrivateKey: orgs.sshCaPrivateKey,
sshCaPublicKey: orgs.sshCaPublicKey
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
console.error(`Error: Organization with orgId "${orgId}" not found.`);
process.exit(1);
}
if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) {
if (!force) {
console.error(
"Error: This organization already has CA keys. Use --force to overwrite."
);
process.exit(1);
}
}
const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
await db
.update(orgs)
.set({
sshCaPrivateKey: encryptedPrivateKey,
sshCaPublicKey: ca.publicKeyOpenSSH
})
.where(eq(orgs.orgId, orgId));
console.log("SSH CA keys generated and stored for org:", orgId);
console.log("\nPublic key (OpenSSH format):");
console.log(ca.publicKeyOpenSSH);
process.exit(0);
} catch (error) {
console.error("Error generating org CA keys:", error);
process.exit(1);
}
}
};

View File

@@ -8,6 +8,7 @@ import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret"; import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient"; import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -17,5 +18,6 @@ yargs(hideBin(process.argv))
.command(rotateServerSecret) .command(rotateServerSecret)
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient) .command(deleteClient)
.command(generateOrgCaKeys)
.demandCommand() .demandCommand()
.help().argv; .help().argv;

View File

@@ -1250,6 +1250,7 @@
"sidebarClientResources": "Private", "sidebarClientResources": "Private",
"sidebarAccessControl": "Access Control", "sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",
"sidebarUsers": "Users", "sidebarUsers": "Users",
"sidebarAdmin": "Admin", "sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations", "sidebarInvitations": "Invitations",
@@ -1290,8 +1291,7 @@
"contents": "Contents", "contents": "Contents",
"parsedContents": "Parsed Contents (Read Only)", "parsedContents": "Parsed Contents (Read Only)",
"enableDockerSocket": "Enable Docker Blueprint", "enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in <docsLink>the documentation</docsLink>.",
"enableDockerSocketLink": "Learn More",
"viewDockerContainers": "View Docker Containers", "viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}", "containersIn": "Containers in {siteName}",
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
@@ -2008,8 +2008,8 @@
"orgAuthNoAccount": "Don't have an account?", "orgAuthNoAccount": "Don't have an account?",
"subscriptionRequiredToUse": "A subscription is required to use this feature.", "subscriptionRequiredToUse": "A subscription is required to use this feature.",
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.", "mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
"subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink> or higher.", "subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink>.",
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> or higher to use this feature.", "upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> to use this feature.",
"subscriptionTierTier1": "Home", "subscriptionTierTier1": "Home",
"subscriptionTierTier2": "Team", "subscriptionTierTier2": "Team",
"subscriptionTierTier3": "Business", "subscriptionTierTier3": "Business",
@@ -2325,7 +2325,7 @@
"logRetentionEndOfFollowingYear": "End of following year", "logRetentionEndOfFollowingYear": "End of following year",
"actionLogsDescription": "View a history of actions performed in this organization", "actionLogsDescription": "View a history of actions performed in this organization",
"accessLogsDescription": "View access auth requests for resources in this organization", "accessLogsDescription": "View access auth requests for resources in this organization",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Certificate Resolver", "certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.", "certResolverDescription": "Select the certificate resolver to use for this resource.",
@@ -2523,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Access Control", "editInternalResourceDialogAccessControl": "Access Control",
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.", "editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.", "editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.",
"internalResourceAuthDaemonStrategy": "SSH Auth Daemon Location",
"internalResourceAuthDaemonStrategyDescription": "Choose where the SSH authentication daemon runs: on the site (Newt) or on a remote host.",
"internalResourceAuthDaemonDescription": "The SSH authentication daemon handles SSH key signing and PAM authentication for this resource. Choose whether it runs on the site (Newt) or on a separate remote host. See <docsLink>the documentation</docsLink> for more.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Select Strategy",
"internalResourceAuthDaemonStrategyLabel": "Location",
"internalResourceAuthDaemonSite": "On Site",
"internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
"internalResourceAuthDaemonRemote": "Remote Host",
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.",
"internalResourceAuthDaemonPort": "Daemon Port (optional)",
"orgAuthWhatsThis": "Where can I find my organization ID?", "orgAuthWhatsThis": "Where can I find my organization ID?",
"learnMore": "Learn more", "learnMore": "Learn more",
"backToHome": "Go back to home", "backToHome": "Go back to home",

View File

@@ -232,7 +232,11 @@ export const siteResources = pgTable("siteResources", {
aliasAddress: varchar("aliasAddress"), aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false) disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort"),
authDaemonMode: varchar("authDaemonMode", { length: 32 }).$type<
"site" | "remote"
>()
}); });
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable("clientSiteResources", {

View File

@@ -257,7 +257,9 @@ export const siteResources = sqliteTable("siteResources", {
udpPortRangeString: text("udpPortRangeString").notNull().default("*"), udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
disableIcmp: integer("disableIcmp", { mode: "boolean" }) disableIcmp: integer("disableIcmp", { mode: "boolean" })
.notNull() .notNull()
.default(false) .default(false),
authDaemonPort: integer("authDaemonPort"),
authDaemonMode: text("authDaemonMode").$type<"site" | "remote">()
}); });
export const clientSiteResources = sqliteTable("clientSiteResources", { export const clientSiteResources = sqliteTable("clientSiteResources", {

View File

@@ -48,5 +48,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise" "enterprise"
], ],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] [TierFeature.SshPam]: ["enterprise"]
}; };

View File

@@ -61,7 +61,10 @@ function encodeUInt64(value: bigint): Buffer {
* Decode a string from SSH wire format at the given offset * Decode a string from SSH wire format at the given offset
* Returns the string buffer and the new offset * Returns the string buffer and the new offset
*/ */
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } { function decodeString(
data: Buffer,
offset: number
): { value: Buffer; newOffset: number } {
const len = data.readUInt32BE(offset); const len = data.readUInt32BE(offset);
const value = data.subarray(offset + 4, offset + 4 + len); const value = data.subarray(offset + 4, offset + 4 + len);
return { value, newOffset: offset + 4 + len }; return { value, newOffset: offset + 4 + len };
@@ -91,7 +94,9 @@ function parseOpenSSHPublicKey(pubKeyLine: string): {
// Verify the key type in the blob matches // Verify the key type in the blob matches
const { value: blobKeyType } = decodeString(keyData, 0); const { value: blobKeyType } = decodeString(keyData, 0);
if (blobKeyType.toString("utf8") !== keyType) { if (blobKeyType.toString("utf8") !== keyType) {
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`); throw new Error(
`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`
);
} }
return { keyType, keyData, comment }; return { keyType, keyData, comment };
@@ -238,7 +243,7 @@ export interface SignedCertificate {
* @param comment - Optional comment for the CA public key * @param comment - Optional comment for the CA public key
* @returns CA key pair and configuration info * @returns CA key pair and configuration info
*/ */
export function generateCA(comment: string = "ssh-ca"): CAKeyPair { export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair {
// Generate Ed25519 key pair // Generate Ed25519 key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", { const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" },
@@ -269,7 +274,7 @@ export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
/** /**
* Get and decrypt the SSH CA keys for an organization. * Get and decrypt the SSH CA keys for an organization.
* *
* @param orgId - Organization ID * @param orgId - Organization ID
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config) * @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
* @returns CA key pair or null if not found * @returns CA key pair or null if not found
@@ -307,7 +312,10 @@ export async function getOrgCAKeys(
key: privateKeyPem, key: privateKeyPem,
format: "pem" format: "pem"
}); });
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string; const publicKeyPem = pubKeyObj.export({
type: "spki",
format: "pem"
}) as string;
return { return {
privateKeyPem, privateKeyPem,
@@ -365,8 +373,8 @@ export function signPublicKey(
const serial = options.serial ?? BigInt(Date.now()); const serial = options.serial ?? BigInt(Date.now());
const certType = options.certType ?? 1; // 1 = user cert const certType = options.certType ?? 1; // 1 = user cert
const now = BigInt(Math.floor(Date.now() / 1000)); const now = BigInt(Math.floor(Date.now() / 1000));
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago const validAfter = options.validAfter ?? now - 60n; // 1 minute ago
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now
// Default extensions for user certificates // Default extensions for user certificates
const defaultExtensions = [ const defaultExtensions = [
@@ -422,10 +430,7 @@ export function signPublicKey(
]); ]);
// Build complete certificate // Build complete certificate
const certificate = Buffer.concat([ const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]);
certBody,
encodeString(signatureBlob)
]);
// Format as OpenSSH certificate line // Format as OpenSSH certificate line
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`; const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;

View File

@@ -25,7 +25,8 @@ import {
loginPageOrg, loginPageOrg,
orgs, orgs,
resources, resources,
roles roles,
siteResources
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -320,17 +321,9 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
} }
async function disableSshPam(orgId: string): Promise<void> { async function disableSshPam(orgId: string): Promise<void> {
await db logger.info(
.update(roles) `Disabled SSH PAM options on all roles and site resources for org ${orgId}`
.set({ );
sshSudoMode: "none",
sshSudoCommands: "[]",
sshCreateHomeDir: false,
sshUnixGroups: "[]"
})
.where(eq(roles.orgId, orgId));
logger.info(`Disabled SSH PAM options on all roles for org ${orgId}`);
} }
async function disableLoginPageBranding(orgId: string): Promise<void> { async function disableLoginPageBranding(orgId: string): Promise<void> {

View File

@@ -514,7 +514,7 @@ authenticated.post(
verifyValidSubscription(tierMatrix.sshPam), verifyValidSubscription(tierMatrix.sshPam),
verifyOrgAccess, verifyOrgAccess,
verifyLimits, verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey), verifyUserHasAction(ActionsEnum.signSshKey),
logActionAudit(ActionsEnum.signSshKey), logActionAudit(ActionsEnum.signSshKey),
ssh.signSshKey ssh.signSshKey
); );

View File

@@ -22,7 +22,7 @@ import {
sites, sites,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -35,8 +35,6 @@ import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResourc
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendToClient } from "#private/routers/ws"; import { sendToClient } from "#private/routers/ws";
import { groups } from "d3";
import { homedir } from "os";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -402,7 +400,8 @@ export async function signSshKey(
data: { data: {
messageId: message.messageId, messageId: message.messageId,
orgId: orgId, orgId: orgId,
agentPort: 22123, agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination, agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH, caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse, username: usernameToUse,

View File

@@ -181,7 +181,10 @@ export async function createOrg(
} }
if (build == "saas" && billingOrgIdForNewOrg) { if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS); const usage = await usageService.getUsage(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS
);
if (!usage) { if (!usage) {
return next( return next(
createHttpError( createHttpError(
@@ -218,11 +221,6 @@ export async function createOrg(
.from(domains) .from(domains)
.where(eq(domains.configManaged, true)); .where(eq(domains.configManaged, true));
// Generate SSH CA keys for the org
// const ca = generateCA(`${orgId}-ca`);
// const encryptionKey = config.getRawConfig().server.secret!;
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
const saasBillingFields = const saasBillingFields =
build === "saas" && req.user && isFirstOrg !== null build === "saas" && req.user && isFirstOrg !== null
? isFirstOrg ? isFirstOrg
@@ -233,6 +231,19 @@ export async function createOrg(
} }
: {}; : {};
const encryptionKey = config.getRawConfig().server.secret;
let sshCaFields: {
sshCaPrivateKey?: string;
sshCaPublicKey?: string;
} = {};
if (encryptionKey) {
const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
sshCaFields = {
sshCaPrivateKey: encrypt(ca.privateKeyPem, encryptionKey),
sshCaPublicKey: ca.publicKeyOpenSSH
};
}
const newOrg = await trx const newOrg = await trx
.insert(orgs) .insert(orgs)
.values({ .values({
@@ -241,8 +252,7 @@ export async function createOrg(
subnet, subnet,
utilitySubnet, utilitySubnet,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
// sshCaPrivateKey: encryptedCaPrivateKey, ...sshCaFields,
// sshCaPublicKey: ca.publicKeyOpenSSH,
...saasBillingFields ...saasBillingFields
}) })
.returning(); .returning();

View File

@@ -16,6 +16,8 @@ import {
isIpInCidr, isIpInCidr,
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -53,7 +55,9 @@ const createSiteResourceSchema = z
clientIds: z.array(z.int()), clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional() disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional()
}) })
.strict() .strict()
.refine( .refine(
@@ -168,7 +172,9 @@ export async function createSiteResource(
clientIds, clientIds,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp disableIcmp,
authDaemonPort,
authDaemonMode
} = parsedBody.data; } = parsedBody.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
@@ -267,6 +273,11 @@ export async function createSiteResource(
} }
} }
const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
);
const niceId = await getUniqueSiteResourceName(orgId); const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (mode == "host") { if (mode == "host") {
@@ -277,25 +288,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Create the site resource // Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId,
orgId,
name,
mode: mode as "host" | "cidr",
destination,
enabled,
alias,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
}
[newSiteResource] = await trx [newSiteResource] = await trx
.insert(siteResources) .insert(siteResources)
.values({ .values(insertValues)
siteId,
niceId,
orgId,
name,
mode: mode as "host" | "cidr",
// protocol: mode === "port" ? protocol : null,
// proxyPort: mode === "port" ? proxyPort : null,
// destinationPort: mode === "port" ? destinationPort : null,
destination,
enabled,
alias,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
})
.returning(); .returning();
const siteResourceId = newSiteResource.siteResourceId; const siteResourceId = newSiteResource.siteResourceId;

View File

@@ -78,6 +78,8 @@ function querySiteResourcesBase() {
tcpPortRangeString: siteResources.tcpPortRangeString, tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString, udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name, siteName: sites.name,
siteNiceId: sites.niceId, siteNiceId: sites.niceId,
siteAddress: sites.address siteAddress: sites.address

View File

@@ -32,6 +32,8 @@ import {
getClientSiteResourceAccess, getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations"; } from "@server/lib/rebuildClientAssociations";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive()) siteResourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -61,7 +63,9 @@ const updateSiteResourceSchema = z
clientIds: z.array(z.int()), clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional() disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional()
}) })
.strict() .strict()
.refine( .refine(
@@ -172,7 +176,9 @@ export async function updateSiteResource(
clientIds, clientIds,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp disableIcmp,
authDaemonPort,
authDaemonMode
} = parsedBody.data; } = parsedBody.data;
const [site] = await db const [site] = await db
@@ -198,6 +204,11 @@ export async function updateSiteResource(
); );
} }
const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix.sshPam
);
const [org] = await db const [org] = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -308,6 +319,18 @@ export async function updateSiteResource(
// wait some time to allow for messages to be handled // wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750)); await new Promise((resolve) => setTimeout(resolve, 750));
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
}),
...(authDaemonMode !== undefined && {
authDaemonMode
})
}
: {};
[updatedSiteResource] = await trx [updatedSiteResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
@@ -319,7 +342,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null, alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString, tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString, udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp disableIcmp: disableIcmp,
...sshPamSet
}) })
.where( .where(
and( and(
@@ -397,6 +421,18 @@ export async function updateSiteResource(
); );
} else { } else {
// Update the site resource // Update the site resource
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
}),
...(authDaemonMode !== undefined && {
authDaemonMode
})
}
: {};
[updatedSiteResource] = await trx [updatedSiteResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
@@ -408,7 +444,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null, alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString, tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString, udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp disableIcmp: disableIcmp,
...sshPamSet
}) })
.where( .where(
and(eq(siteResources.siteResourceId, siteResourceId)) and(eq(siteResources.siteResourceId, siteResourceId))

View File

@@ -14,6 +14,7 @@ export default async function migration() {
// all roles set hoemdir to true // all roles set hoemdir to true
// generate ca certs for all orgs? // generate ca certs for all orgs?
// set authDaemonMode to "site" for all orgs
try { try {
db.transaction(() => {})(); db.transaction(() => {})();

View File

@@ -74,7 +74,9 @@ export default async function ClientResourcesPage(
niceId: siteResource.niceId, niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null
}; };
} }
); );

View File

@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react"; import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
@@ -187,21 +187,22 @@ export default function GeneralPage() {
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
{t( {t.rich(
"enableDockerSocketDescription" "enableDockerSocketDescription",
)}{" "} {
<Link docsLink: (chunks) => (
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration" <a
target="_blank" href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
rel="noopener noreferrer" target="_blank"
className="text-primary hover:underline inline-flex items-center" rel="noopener noreferrer"
> className="text-primary hover:underline inline-flex items-center gap-1"
<span> >
{t( {chunks}
"enableDockerSocketLink" <ExternalLink className="size-3.5 shrink-0" />
)} </a>
</span> )
</Link> }
)}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}

View File

@@ -125,9 +125,9 @@ export default async function RootLayout({
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
{process.env.NODE_ENV === "development" && ( {/*process.env.NODE_ENV === "development" && (
<TailwindIndicator /> <TailwindIndicator />
)} )*/}
</body> </body>
</html> </html>
); );

View File

@@ -110,14 +110,19 @@ export const orgNavSections = (
heading: "access", heading: "access",
items: [ items: [
{ {
title: "sidebarUsers", title: "sidebarTeam",
icon: <User className="size-4 flex-none" />, icon: <Users className="size-4 flex-none" />,
items: [ items: [
{ {
title: "sidebarUsers", title: "sidebarUsers",
href: "/{orgId}/settings/access/users", href: "/{orgId}/settings/access/users",
icon: <User className="size-4 flex-none" /> icon: <User className="size-4 flex-none" />
}, },
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
{ {
title: "sidebarInvitations", title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations", href: "/{orgId}/settings/access/invitations",
@@ -125,11 +130,6 @@ export const orgNavSections = (
} }
] ]
}, },
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
// PaidFeaturesAlert // PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" || build === "saas" ||

View File

@@ -51,6 +51,8 @@ export type InternalResourceRow = {
tcpPortRangeString: string | null; tcpPortRangeString: string | null;
udpPortRangeString: string | null; udpPortRangeString: string | null;
disableIcmp: boolean; disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
}; };
type ClientResourcesTableProps = { type ClientResourcesTableProps = {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@ export async function Layout({
<div <div
className={cn( className={cn(
"container mx-auto max-w-12xl mb-12", "container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)} )}
> >
{children} {children}

View File

@@ -48,8 +48,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
}, [theme]); }, [theme]);
return ( return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block border-b border-border"> <div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-card" /> <div className="absolute inset-0 bg-background/83 backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2"> <div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between"> <div className="h-16 flex items-center justify-between">

View File

@@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build"; import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { ArrowRight, ExternalLink, Server } from "lucide-react"; import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
@@ -190,31 +190,55 @@ export function LayoutSidebar({
</div> </div>
)} )}
<div className="w-full border-t border-border" /> {isSidebarCollapsed && (
<div className="shrink-0 flex justify-center py-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setIsSidebarCollapsed(false);
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("sidebarExpand")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className="p-4 pt-1 flex flex-col shrink-0"> <div className="w-full border-t border-border mb-3" />
{canShowProductUpdates ? (
<div className="mb-3"> <div className="p-4 pt-0 mt-0 flex flex-col shrink-0">
{canShowProductUpdates && (
<div className="mb-3 empty:mb-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} /> <ProductUpdates isCollapsed={isSidebarCollapsed} />
</div> </div>
) : (
<div className="mb-3"></div>
)} )}
{build === "enterprise" && ( {build === "enterprise" && (
<div className="mb-3"> <div className="mb-3 empty:mb-0">
<SidebarLicenseButton <SidebarLicenseButton
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
/> />
</div> </div>
)} )}
{build === "oss" && ( {build === "oss" && (
<div className="mb-3"> <div className="mb-3 empty:mb-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} /> <SupporterStatus isCollapsed={isSidebarCollapsed} />
</div> </div>
)} )}
{build === "saas" && ( {build === "saas" && (
<div className="mb-3"> <div className="mb-3 empty:mb-0">
<SidebarSupportButton <SidebarSupportButton
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
/> />
@@ -230,19 +254,19 @@ export function LayoutSidebar({
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{link.href ? ( {link.href ? (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
<Link <Link
href={link.href} href={link.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-1" className="flex items-center justify-start gap-1"
> >
{link.text} {link.text}
<ExternalLink size={12} /> <ExternalLink size={12} />
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
{link.text} {link.text}
</div> </div>
)} )}
@@ -251,12 +275,12 @@ export function LayoutSidebar({
</> </>
) : ( ) : (
<> <>
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
<Link <Link
href="https://github.com/fosrl/pangolin" href="https://github.com/fosrl/pangolin"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-1" className="flex items-center justify-start gap-1"
> >
{build === "oss" {build === "oss"
? t("communityEdition") ? t("communityEdition")
@@ -269,22 +293,22 @@ export function LayoutSidebar({
{build === "enterprise" && {build === "enterprise" &&
isUnlocked() && isUnlocked() &&
licenseStatus?.tier === "personal" ? ( licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
{t("personalUseOnly")} {t("personalUseOnly")}
</div> </div>
) : null} ) : null}
{build === "enterprise" && !isUnlocked() ? ( {build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
{t("unlicensed")} {t("unlicensed")}
</div> </div>
) : null} ) : null}
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-left">
<Link <Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`} href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-1" className="flex items-center justify-start gap-1"
> >
v{env.app.version} v{env.app.version}
<ExternalLink size={12} /> <ExternalLink size={12} />

View File

@@ -98,15 +98,6 @@ export function OrgSelector({
align="start" align="start"
sideOffset={12} sideOffset={12}
> >
{/* Peak pointing up to the trigger */}
<div
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-full w-0 h-0 border-[7px] border-transparent border-b-border"
aria-hidden
/>
<div
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-full w-0 h-0 border-[6px] border-transparent border-b-[var(--color-popover)]"
aria-hidden
/>
<Command className="rounded-lg border-0 flex-1 min-h-0"> <Command className="rounded-lg border-0 flex-1 min-h-0">
<CommandInput <CommandInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
@@ -124,10 +115,14 @@ export function OrgSelector({
key={org.orgId} key={org.orgId}
onSelect={() => { onSelect={() => {
setOpen(false); setOpen(false);
const newPath = pathname.replace( const newPath = pathname.includes(
/^\/[^/]+/, "/settings/"
`/${org.orgId}` )
); ? pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
)
: `/${org.orgId}`;
router.push(newPath); router.push(newPath);
}} }}
className="mx-1 rounded-md py-1.5 h-auto min-h-0" className="mx-1 rounded-md py-1.5 h-auto min-h-0"
@@ -166,8 +161,7 @@ export function OrgSelector({
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
{(!env.flags.disableUserCreateOrg || {(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
user.serverAdmin) && (
<div className="p-2 border-t border-border"> <div className="p-2 border-t border-border">
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -12,34 +12,42 @@ import { useParams } from "next/navigation";
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"]; const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = { const TIER_TRANSLATION_KEYS: Record<
Tier,
| "subscriptionTierTier1"
| "subscriptionTierTier2"
| "subscriptionTierTier3"
| "subscriptionTierEnterprise"
> = {
tier1: "subscriptionTierTier1", tier1: "subscriptionTierTier1",
tier2: "subscriptionTierTier2", tier2: "subscriptionTierTier2",
tier3: "subscriptionTierTier3", tier3: "subscriptionTierTier3",
enterprise: "subscriptionTierEnterprise" enterprise: "subscriptionTierEnterprise"
}; };
function getRequiredTier(tiers: Tier[]): Tier | null { function formatRequiredTiersList(
tiers: Tier[],
t: (key: (typeof TIER_TRANSLATION_KEYS)[Tier]) => string
): string | null {
if (tiers.length === 0) return null; if (tiers.length === 0) return null;
let min: Tier | null = null; const sorted = [...tiers]
for (const tier of tiers) { .filter((tier) => TIER_ORDER.includes(tier))
const idx = TIER_ORDER.indexOf(tier); .sort((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b));
if (idx === -1) continue; if (sorted.length === 0) return null;
if (min === null || TIER_ORDER.indexOf(min) > idx) { const names = sorted.map((tier) => t(TIER_TRANSLATION_KEYS[tier]));
min = tier; if (names.length === 1) return names[0];
} if (names.length === 2) return `${names[0]} or ${names[1]}`;
} return `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
return min;
} }
const bannerClassName = const bannerClassName =
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden"; "mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
const bannerContentClassName = "py-3 px-4"; const bannerContentClassName = "py-3 px-4";
const bannerRowClassName = const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground"; "flex items-center gap-2.5 text-sm text-muted-foreground";
const bannerIconClassName = "size-4 shrink-0 text-purple-500"; const bannerIconClassName = "size-4 shrink-0 text-black-500";
const docsLinkClassName = const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-purple-600 underline"; "inline-flex items-center gap-1 font-medium text-black-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/"; const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL = const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition"; "https://docs.pangolin.net/self-host/enterprise-edition";
@@ -94,11 +102,17 @@ export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations(); const t = useTranslations();
const params = useParams(); const params = useParams();
const orgId = params?.orgId as string | undefined; const orgId = params?.orgId as string | undefined;
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus(); const {
hasSaasSubscription,
hasEnterpriseLicense,
isActive,
subscriptionTier
} = usePaidStatus();
const { env } = useEnvContext(); const { env } = useEnvContext();
const requiredTier = getRequiredTier(tiers); const requiredTiersLabel = formatRequiredTiersList(tiers, t);
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null; const billingHref = orgId
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing"; ? `/${orgId}/settings/billing`
: "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref); const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer(); const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL); const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
@@ -115,16 +129,16 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<div className={bannerRowClassName}> <div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{requiredTierName {requiredTiersLabel
? isActive ? isActive
? t.rich("upgradeToTierToUse", { ? t.rich("upgradeToTierToUse", {
tier: requiredTierName, tier: requiredTiersLabel,
tierLink: tierLinkRenderer tierLink: tierLinkRenderer
}) })
: t.rich("subscriptionRequiredTierToUse", { : t.rich("upgradeToTierToUse", {
tier: requiredTierName, tier: requiredTiersLabel,
tierLink: tierLinkRenderer tierLink: tierLinkRenderer
}) })
: isActive : isActive
? t("mustUpgradeToUse") ? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")} : t("subscriptionRequiredToUse")}
@@ -141,7 +155,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{t.rich("licenseRequiredToUse", { {t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseDocsLinkRenderer, enterpriseLicenseLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer pangolinCloudLink: pangolinCloudLinkRenderer
})} })}
</span> </span>
@@ -157,7 +172,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{t.rich("ossEnterpriseEditionRequired", { {t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: enterpriseDocsLinkRenderer, enterpriseEditionLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer pangolinCloudLink: pangolinCloudLinkRenderer
})} })}
</span> </span>

View File

@@ -105,7 +105,7 @@ export default function ProductUpdates({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<small <small
className={cn( className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2", "text-xs text-muted-foreground flex items-center gap-1 mt-2 empty:mt-0",
showMoreUpdatesText showMoreUpdatesText
? "animate-in fade-in duration-300" ? "animate-in fade-in duration-300"
: "opacity-0" : "opacity-0"

View File

@@ -139,6 +139,12 @@ export function RoleForm({
const sshDisabled = !isPaidUser(tierMatrix.sshPam); const sshDisabled = !isPaidUser(tierMatrix.sshPam);
const sshSudoMode = form.watch("sshSudoMode"); const sshSudoMode = form.watch("sshSudoMode");
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -291,14 +297,18 @@ export function RoleForm({
<OptionSelect<"allow" | "disallow"> <OptionSelect<"allow" | "disallow">
options={allowSshOptions} options={allowSshOptions}
value={ value={
field.value sshDisabled
? "allow" ? "disallow"
: "disallow" : field.value
} ? "allow"
onChange={(v) => : "disallow"
field.onChange(v === "allow")
} }
onChange={(v) => {
if (sshDisabled) return;
field.onChange(v === "allow");
}}
cols={2} cols={2}
disabled={sshDisabled}
/> />
<FormDescription> <FormDescription>
{t( {t(

View File

@@ -111,7 +111,7 @@ export default function UsersTable({ roles }: RolesTableProps) {
<Button <Button
variant="ghost" variant="ghost"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
disabled={isAdmin} disabled={isAdmin || false}
> >
<span className="sr-only"> <span className="sr-only">
{t("openMenu")} {t("openMenu")}
@@ -121,7 +121,7 @@ export default function UsersTable({ roles }: RolesTableProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
disabled={isAdmin} disabled={isAdmin || false}
onClick={() => { onClick={() => {
setRoleToRemove(roleRow); setRoleToRemove(roleRow);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -135,7 +135,7 @@ export default function UsersTable({ roles }: RolesTableProps) {
</DropdownMenu> </DropdownMenu>
<Button <Button
variant={"outline"} variant={"outline"}
disabled={isAdmin} disabled={isAdmin || false}
onClick={() => { onClick={() => {
setEditingRole(roleRow); setEditingRole(roleRow);
setIsEditDialogOpen(true); setIsEditDialogOpen(true);

View File

@@ -122,13 +122,13 @@ function CollapsibleNavItem({
"px-3 py-1.5", "px-3 py-1.5",
isActive isActive
? "bg-secondary font-medium" ? "bg-secondary font-medium"
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
disabled={isDisabled} disabled={isDisabled}
> >
{item.icon && ( {item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center opacity-50"> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon} {item.icon}
</span> </span>
)} )}
@@ -192,6 +192,167 @@ function CollapsibleNavItem({
); );
} }
type CollapsedNavItemWithPopoverProps = {
item: SidebarNavItem;
tooltipText: string;
isActive: boolean;
isChildActive: boolean;
isDisabled: boolean;
hydrateHref: (val?: string) => string | undefined;
pathname: string;
build: string;
isUnlocked: () => boolean;
disabled: boolean;
t: (key: string) => string;
onItemClick?: () => void;
};
const TOOLTIP_SUPPRESS_MS = 400;
function CollapsedNavItemWithPopover({
item,
tooltipText,
isActive,
isChildActive,
isDisabled,
hydrateHref,
pathname,
build,
isUnlocked,
disabled,
t,
onItemClick
}: CollapsedNavItemWithPopoverProps) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const suppressTooltipRef = React.useRef(false);
const handlePopoverOpenChange = React.useCallback((open: boolean) => {
setPopoverOpen(open);
if (!open) {
setTooltipOpen(false);
suppressTooltipRef.current = true;
window.setTimeout(() => {
suppressTooltipRef.current = false;
}, TOOLTIP_SUPPRESS_MS);
}
}, []);
const handleTooltipOpenChange = React.useCallback((open: boolean) => {
if (open && suppressTooltipRef.current) return;
setTooltipOpen(open);
}, []);
return (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<Popover
open={popoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref = hydrateHref(
childItem.href
);
const childIsActive = childHydratedHref
? pathname.startsWith(childHydratedHref)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled = disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else {
handlePopoverOpenChange(false);
onItemClick?.();
}
}}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
);
}
export function SidebarNav({ export function SidebarNav({
className, className,
sections, sections,
@@ -290,7 +451,7 @@ export function SidebarNav({
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive isActive
? "bg-secondary font-medium" ? "bg-secondary font-medium"
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
onClick={(e) => { onClick={(e) => {
@@ -306,7 +467,10 @@ export function SidebarNav({
{item.icon && level === 0 && ( {item.icon && level === 0 && (
<span <span
className={cn( className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center opacity-50", "flex-shrink-0 w-5 h-5 flex items-center justify-center",
isCollapsed
? "text-muted-foreground"
: "text-muted-foreground",
!isCollapsed && "mr-3" !isCollapsed && "mr-3"
)} )}
> >
@@ -361,12 +525,12 @@ export function SidebarNav({
className={cn( className={cn(
"flex items-center rounded-md transition-colors", "flex items-center rounded-md transition-colors",
"px-3 py-1.5", "px-3 py-1.5",
"text-foreground/80", "text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
> >
{item.icon && level === 0 && ( {item.icon && level === 0 && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center opacity-50"> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon} {item.icon}
</span> </span>
)} )}
@@ -406,115 +570,21 @@ export function SidebarNav({
// If item has nested items, show both tooltip and popover // If item has nested items, show both tooltip and popover
if (hasNestedItems) { if (hasNestedItems) {
return ( return (
<TooltipProvider key={item.title}> <CollapsedNavItemWithPopover
<Tooltip> key={item.title}
<Popover> item={item}
<PopoverTrigger asChild> tooltipText={tooltipText}
<TooltipTrigger asChild> isActive={isActive}
<button isChildActive={isChildActive}
className={cn( isDisabled={!!isDisabled}
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full", hydrateHref={hydrateHref}
isActive || isChildActive pathname={pathname}
? "bg-secondary font-medium" build={build}
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", isUnlocked={isUnlocked}
isDisabled && disabled={disabled ?? false}
"cursor-not-allowed opacity-60" t={t}
)} onItemClick={onItemClick}
disabled={isDisabled} />
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center opacity-50">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref =
hydrateHref(childItem.href);
const childIsActive =
childHydratedHref
? pathname.startsWith(
childHydratedHref
)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled =
disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-foreground/80 hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else if (
onItemClick
) {
onItemClick();
}
}}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t(
"licenseBadge"
)}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
); );
} }
@@ -549,7 +619,7 @@ export function SidebarNav({
className={cn(sectionIndex > 0 && "mt-4")} className={cn(sectionIndex > 0 && "mt-4")}
> >
{!isCollapsed && ( {!isCollapsed && (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider"> <div className="px-3 py-2 text-xs font-medium text-foreground uppercase tracking-wider">
{t(`${section.heading}`)} {t(`${section.heading}`)}
</div> </div>
)} )}

View File

@@ -14,6 +14,7 @@ export interface StrategyOption<TValue extends string> {
interface StrategySelectProps<TValue extends string> { interface StrategySelectProps<TValue extends string> {
options: ReadonlyArray<StrategyOption<TValue>>; options: ReadonlyArray<StrategyOption<TValue>>;
value?: TValue | null;
defaultValue?: TValue; defaultValue?: TValue;
onChange?: (value: TValue) => void; onChange?: (value: TValue) => void;
cols?: number; cols?: number;
@@ -21,18 +22,21 @@ interface StrategySelectProps<TValue extends string> {
export function StrategySelect<TValue extends string>({ export function StrategySelect<TValue extends string>({
options, options,
value: controlledValue,
defaultValue, defaultValue,
onChange, onChange,
cols cols
}: StrategySelectProps<TValue>) { }: StrategySelectProps<TValue>) {
const [selected, setSelected] = useState<TValue | undefined>(defaultValue); const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
return ( return (
<RadioGroup <RadioGroup
defaultValue={defaultValue} value={selected ?? ""}
onValueChange={(value: string) => { onValueChange={(value: string) => {
const typedValue = value as TValue; const typedValue = value as TValue;
setSelected(typedValue); if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue); onChange?.(typedValue);
}} }}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}