Compare commits

..

4 Commits

Author SHA1 Message Date
Owen
756f3f32ca Merge branch 'dev' 2026-02-17 21:57:49 -08:00
Owen
5987f6b2cd Allow enterprise 2026-02-17 21:55:57 -08:00
Owen
09a9457021 Fix transaction issue 2026-02-17 21:27:23 -08:00
Owen Schwartz
ca4643ec36 Merge pull request #2494 from fosrl/dev
1.15.4-s.3
2026-02-17 21:07:04 -08:00
55 changed files with 3575 additions and 3337 deletions

View File

@@ -1,121 +0,0 @@
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,7 +8,6 @@ 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")
@@ -18,6 +17,5 @@ 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

@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "User and role-based access control", "resourcesUsersRolesAccess": "User and role-based access control",
"resourcesErrorUpdate": "Failed to toggle resource", "resourcesErrorUpdate": "Failed to toggle resource",
"resourcesErrorUpdateDescription": "An error occurred while updating the resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access Control", "access": "Access",
"shareLink": "{resource} Share Link", "shareLink": "{resource} Share Link",
"resourceSelect": "Select resource", "resourceSelect": "Select resource",
"shareLinks": "Share Links", "shareLinks": "Share Links",
@@ -790,7 +790,6 @@
"accessRoleRemoved": "Role removed", "accessRoleRemoved": "Role removed",
"accessRoleRemovedDescription": "The role has been successfully removed.", "accessRoleRemovedDescription": "The role has been successfully removed.",
"accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.", "accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.",
"network": "Network",
"manage": "Manage", "manage": "Manage",
"sitesNotFound": "No sites found.", "sitesNotFound": "No sites found.",
"pangolinServerAdmin": "Server Admin - Pangolin", "pangolinServerAdmin": "Server Admin - Pangolin",
@@ -1250,7 +1249,6 @@
"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",
@@ -1269,7 +1267,6 @@
"sidebarLogAndAnalytics": "Log & Analytics", "sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints", "sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization", "sidebarOrganization": "Organization",
"sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses", "sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics", "sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints", "blueprints": "Blueprints",
@@ -1291,7 +1288,8 @@
"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. Read about how this works in <docsLink>the documentation</docsLink>.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
"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.",
@@ -1645,24 +1643,6 @@
"timeIsInSeconds": "Time is in seconds", "timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals", "requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshAccess": "SSH Access",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",
"roleAllowSshDisallow": "Disallow",
"roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.",
"sshSudoMode": "Sudo Access",
"sshSudoModeNone": "None",
"sshSudoModeNoneDescription": "User cannot run commands with sudo.",
"sshSudoModeFull": "Full Sudo",
"sshSudoModeFullDescription": "User can run any command with sudo.",
"sshSudoModeCommands": "Commands",
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts", "retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes", "expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -2008,8 +1988,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>.", "subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink> or higher.",
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> to use this feature.", "upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> or higher to use this feature.",
"subscriptionTierTier1": "Home", "subscriptionTierTier1": "Home",
"subscriptionTierTier2": "Team", "subscriptionTierTier2": "Team",
"subscriptionTierTier3": "Business", "subscriptionTierTier3": "Business",
@@ -2099,7 +2079,7 @@
"manageMachineClients": "Manage Machine Clients", "manageMachineClients": "Manage Machine Clients",
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
"machineClientsBannerTitle": "Servers & Automated Systems", "machineClientsBannerTitle": "Servers & Automated Systems",
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.", "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Container", "machineClientsBannerOlmContainer": "Container",
@@ -2325,7 +2305,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 or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license 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>.", "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,17 +2503,6 @@
"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,11 +232,7 @@ 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", {
@@ -376,11 +372,7 @@ export const roles = pgTable("roles", {
isAdmin: boolean("isAdmin"), isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(), name: varchar("name").notNull(),
description: varchar("description"), description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false), requireDeviceApproval: boolean("requireDeviceApproval").default(false)
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(false),
sshUnixGroups: text("sshUnixGroups").default("[]")
}); });
export const roleActions = pgTable("roleActions", { export const roleActions = pgTable("roleActions", {

View File

@@ -257,9 +257,7 @@ 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", {
@@ -681,13 +679,7 @@ export const roles = sqliteTable("roles", {
description: text("description"), description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", { requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean" mode: "boolean"
}).default(false), }).default(false)
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshUnixGroups: text("sshUnixGroups").default("[]")
}); });
export const roleActions = sqliteTable("roleActions", { export const roleActions = sqliteTable("roleActions", {

View File

@@ -46,8 +46,6 @@ export class UsageService {
return null; return null;
} }
let orgIdToUse = await this.getBillingOrg(orgId, transaction);
// Truncate value to 11 decimal places // Truncate value to 11 decimal places
value = this.truncateValue(value); value = this.truncateValue(value);
@@ -59,6 +57,7 @@ export class UsageService {
try { try {
let usage; let usage;
if (transaction) { if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,
@@ -67,6 +66,7 @@ export class UsageService {
); );
} else { } else {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const orgIdToUse = await this.getBillingOrg(orgId, trx);
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgIdToUse, orgIdToUse,
featureId, featureId,
@@ -92,7 +92,7 @@ export class UsageService {
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` `Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
@@ -100,7 +100,7 @@ export class UsageService {
} }
logger.error( logger.error(
`Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`, `Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`,
error error
); );
break; break;
@@ -169,7 +169,7 @@ export class UsageService {
return; return;
} }
let orgIdToUse = await this.getBillingOrg(orgId); const orgIdToUse = await this.getBillingOrg(orgId);
try { try {
// Truncate value to 11 decimal places if provided // Truncate value to 11 decimal places if provided
@@ -227,7 +227,7 @@ export class UsageService {
orgId: string, orgId: string,
featureId: FeatureId featureId: FeatureId
): Promise<string | null> { ): Promise<string | null> {
let orgIdToUse = await this.getBillingOrg(orgId); const orgIdToUse = await this.getBillingOrg(orgId);
const cacheKey = `customer_${orgIdToUse}_${featureId}`; const cacheKey = `customer_${orgIdToUse}_${featureId}`;
const cached = cache.get<string>(cacheKey); const cached = cache.get<string>(cacheKey);
@@ -274,7 +274,7 @@ export class UsageService {
return null; return null;
} }
let orgIdToUse = await this.getBillingOrg(orgId, trx); const orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`; const usageId = `${orgIdToUse}-${featureId}`;
@@ -382,7 +382,7 @@ export class UsageService {
return false; return false;
} }
let orgIdToUse = await this.getBillingOrg(orgId, trx); const orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization // This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org // and kick out all of the sites on the org

View File

@@ -23,14 +23,9 @@ export async function verifyApiKeyRoleAccess(
); );
} }
let allRoleIds: number[] = []; const { roleIds } = req.body;
if (!isNaN(singleRoleId)) { const allRoleIds =
// If roleId is provided in URL params, query params, or body (single), use it exclusively roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
allRoleIds = [singleRoleId];
} else if (req.body?.roleIds) {
// Only use body.roleIds if no single roleId was provided
allRoleIds = req.body.roleIds;
}
if (allRoleIds.length === 0) { if (allRoleIds.length === 0) {
return next(); return next();

View File

@@ -23,14 +23,8 @@ export async function verifyRoleAccess(
); );
} }
let allRoleIds: number[] = []; const roleIds = req.body?.roleIds;
if (!isNaN(singleRoleId)) { const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
// If roleId is provided in URL params, query params, or body (single), use it exclusively
allRoleIds = [singleRoleId];
} else if (req.body?.roleIds) {
// Only use body.roleIds if no single roleId was provided
allRoleIds = req.body.roleIds;
}
if (allRoleIds.length === 0) { if (allRoleIds.length === 0) {
return next(); return next();

View File

@@ -78,7 +78,8 @@ export async function getOrgTierData(
if ( if (
subscription.type === "tier1" || subscription.type === "tier1" ||
subscription.type === "tier2" || subscription.type === "tier2" ||
subscription.type === "tier3" subscription.type === "tier3" ||
subscription.type === "enterprise"
) { ) {
tier = subscription.type; tier = subscription.type;
active = true; active = true;

View File

@@ -61,10 +61,7 @@ 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( function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
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 };
@@ -94,9 +91,7 @@ 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( throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`
);
} }
return { keyType, keyData, comment }; return { keyType, keyData, comment };
@@ -243,7 +238,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 = "pangolin-ssh-ca"): CAKeyPair { export function generateCA(comment: string = "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" },
@@ -312,10 +307,7 @@ export async function getOrgCAKeys(
key: privateKeyPem, key: privateKeyPem,
format: "pem" format: "pem"
}); });
const publicKeyPem = pubKeyObj.export({ const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
type: "spki",
format: "pem"
}) as string;
return { return {
privateKeyPem, privateKeyPem,
@@ -373,8 +365,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 = [
@@ -430,7 +422,10 @@ export function signPublicKey(
]); ]);
// Build complete certificate // Build complete certificate
const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]); const certificate = Buffer.concat([
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,8 +25,7 @@ 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";
@@ -287,10 +286,6 @@ async function disableFeature(
await disableAutoProvisioning(orgId); await disableAutoProvisioning(orgId);
break; break;
case TierFeature.SshPam:
await disableSshPam(orgId);
break;
default: default:
logger.warn( logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping` `Unknown feature ${feature} for org ${orgId}, skipping`
@@ -320,12 +315,6 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`); logger.info(`Disabled device approvals on all roles for org ${orgId}`);
} }
async function disableSshPam(orgId: string): Promise<void> {
logger.info(
`Disabled SSH PAM options on all roles and site resources for org ${orgId}`
);
}
async function disableLoginPageBranding(orgId: string): Promise<void> { async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db const [existingBranding] = await db
.select() .select()

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

@@ -13,17 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
db,
newts,
roles,
roundTripMessageTracker,
siteResources,
sites,
userOrgs
} from "@server/db";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
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";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -145,26 +135,11 @@ export async function signSshKey(
); );
} }
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH key signing requires a paid plan"
)
);
}
let usernameToUse; let usernameToUse;
if (!userOrg.pamUsername) { if (!userOrg.pamUsername) {
if (req.user?.email) { if (req.user?.email) {
// Extract username from email (first part before @) // Extract username from email (first part before @)
usernameToUse = req.user?.email usernameToUse = req.user?.email.split("@")[0];
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) { if (!usernameToUse) {
return next( return next(
createHttpError( createHttpError(
@@ -326,29 +301,6 @@ export async function signSshKey(
); );
} }
const [roleRow] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
let parsedSudoCommands: string[] = [];
let parsedGroups: string[] = [];
try {
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
} catch {
parsedSudoCommands = [];
}
try {
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (!Array.isArray(parsedGroups)) parsedGroups = [];
} catch {
parsedGroups = [];
}
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site // get the site
const [newt] = await db const [newt] = await db
.select() .select()
@@ -382,7 +334,7 @@ export async function signSshKey(
.values({ .values({
wsClientId: newt.newtId, wsClientId: newt.newtId,
messageType: `newt/pam/connection`, messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000) sentAt: Math.floor(Date.now() / 1000),
}) })
.returning(); .returning();
@@ -400,17 +352,14 @@ export async function signSshKey(
data: { data: {
messageId: message.messageId, messageId: message.messageId,
orgId: orgId, orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123, agentPort: 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination, agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH, caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse, username: usernameToUse,
niceId: resource.niceId, niceId: resource.niceId,
metadata: { metadata: {
sudoMode: sudoMode, sudo: true, // we are hardcoding these for now but should make configurable from the role or something
sudoCommands: parsedSudoCommands, homedir: true
homedir: homedir,
groups: parsedGroups
} }
} }
}); });

View File

@@ -6,7 +6,7 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq, and, ne } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -93,8 +93,7 @@ export async function updateClient(
.where( .where(
and( and(
eq(clients.niceId, niceId), eq(clients.niceId, niceId),
eq(clients.orgId, clients.orgId), eq(clients.orgId, clients.orgId)
ne(clients.clientId, clientId)
) )
) )
.limit(1); .limit(1);

View File

@@ -197,7 +197,6 @@ export async function updateSiteBandwidth(
usageService usageService
.checkLimitSet( .checkLimitSet(
orgId, orgId,
FeatureId.EGRESS_DATA_MB, FeatureId.EGRESS_DATA_MB,
bandwidthUsage bandwidthUsage
) )

View File

@@ -181,10 +181,7 @@ export async function createOrg(
} }
if (build == "saas" && billingOrgIdForNewOrg) { if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage( const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS
);
if (!usage) { if (!usage) {
return next( return next(
createHttpError( createHttpError(
@@ -221,6 +218,11 @@ 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
@@ -231,19 +233,6 @@ 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({
@@ -252,7 +241,8 @@ export async function createOrg(
subnet, subnet,
utilitySubnet, utilitySubnet,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...sshCaFields, // sshCaPrivateKey: encryptedCaPrivateKey,
// sshCaPublicKey: ca.publicKeyOpenSSH,
...saasBillingFields ...saasBillingFields
}) })
.returning(); .returning();
@@ -272,8 +262,7 @@ export async function createOrg(
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
isAdmin: true, isAdmin: true,
name: "Admin", name: "Admin",
description: "Admin role with the most permissions", description: "Admin role with the most permissions"
sshSudoMode: "full"
}) })
.returning({ roleId: roles.roleId }); .returning({ roleId: roles.roleId });

View File

@@ -9,7 +9,7 @@ import {
Resource, Resource,
resources resources
} from "@server/db"; } from "@server/db";
import { eq, and, ne } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -33,15 +33,7 @@ const updateResourceParamsSchema = z.strictObject({
const updateHttpResourceBodySchema = z const updateHttpResourceBodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
niceId: z niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
.string()
.min(1)
.max(255)
.regex(
/^[a-zA-Z0-9-]+$/,
"niceId can only contain letters, numbers, and dashes"
)
.optional(),
subdomain: subdomainSchema.nullable().optional(), subdomain: subdomainSchema.nullable().optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
sso: z.boolean().optional(), sso: z.boolean().optional(),
@@ -256,13 +248,14 @@ async function updateHttpResource(
.where( .where(
and( and(
eq(resources.niceId, updateData.niceId), eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId), eq(resources.orgId, resource.orgId)
ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search
) )
) );
.limit(1);
if (existingResource) { if (
existingResource &&
existingResource.resourceId !== resource.resourceId
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.CONFLICT, HttpCode.CONFLICT,
@@ -350,10 +343,7 @@ async function updateHttpResource(
headers = null; headers = null;
} }
const isLicensed = await isLicensedOrSubscribed( const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
resource.orgId,
tierMatrix.maintencePage
);
if (!isLicensed) { if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined; updateData.maintenanceModeType = undefined;

View File

@@ -18,17 +18,10 @@ const createRoleParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
const createRoleSchema = z.strictObject({ const createRoleSchema = z.strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().optional(),
requireDeviceApproval: z.boolean().optional(), requireDeviceApproval: z.boolean().optional()
allowSsh: z.boolean().optional(),
sshSudoMode: sshSudoModeSchema.optional(),
sshSudoCommands: z.array(z.string()).optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.array(z.string()).optional()
}); });
export const defaultRoleAllowedActions: ActionsEnum[] = [ export const defaultRoleAllowedActions: ActionsEnum[] = [
@@ -108,40 +101,24 @@ export async function createRole(
); );
} }
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensedDeviceApprovals) { if (!isLicensed) {
roleData.requireDeviceApproval = undefined; roleData.requireDeviceApproval = undefined;
} }
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
const roleInsertValues: Record<string, unknown> = {
name: roleData.name,
orgId
};
if (roleData.description !== undefined) roleInsertValues.description = roleData.description;
if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval;
if (isLicensedSshPam) {
if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode;
if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands);
if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir;
if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups);
}
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newRole = await trx const newRole = await trx
.insert(roles) .insert(roles)
.values(roleInsertValues as typeof roles.$inferInsert) .values({
...roleData,
orgId
})
.returning(); .returning();
const actionsToInsert = [...defaultRoleAllowedActions];
if (roleData.allowSsh) {
actionsToInsert.push(ActionsEnum.signSshKey);
}
await trx await trx
.insert(roleActions) .insert(roleActions)
.values( .values(
actionsToInsert.map((action) => ({ defaultRoleAllowedActions.map((action) => ({
roleId: newRole[0].roleId, roleId: newRole[0].roleId,
actionId: action, actionId: action,
orgId orgId

View File

@@ -1,10 +1,9 @@
import { db, orgs, roleActions, roles } from "@server/db"; import { db, orgs, roles } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -38,11 +37,7 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
name: roles.name, name: roles.name,
description: roles.description, description: roles.description,
orgName: orgs.name, orgName: orgs.name,
requireDeviceApproval: roles.requireDeviceApproval, requireDeviceApproval: roles.requireDeviceApproval
sshSudoMode: roles.sshSudoMode,
sshSudoCommands: roles.sshSudoCommands,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshUnixGroups: roles.sshUnixGroups
}) })
.from(roles) .from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId)) .leftJoin(orgs, eq(roles.orgId, orgs.orgId))
@@ -111,28 +106,9 @@ export async function listRoles(
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;
let rolesWithAllowSsh = rolesList;
if (rolesList.length > 0) {
const roleIds = rolesList.map((r) => r.roleId);
const signSshKeyRows = await db
.select({ roleId: roleActions.roleId })
.from(roleActions)
.where(
and(
inArray(roleActions.roleId, roleIds),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
);
const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId));
rolesWithAllowSsh = rolesList.map((r) => ({
...r,
allowSsh: roleIdsWithSsh.has(r.roleId)
}));
}
return response(res, { return response(res, {
data: { data: {
roles: rolesWithAllowSsh, roles: rolesList,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, limit,

View File

@@ -1,9 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, type Role } from "@server/db"; import { db, type Role } from "@server/db";
import { roleActions, roles } from "@server/db"; import { roles } from "@server/db";
import { and, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -17,18 +16,11 @@ const updateRoleParamsSchema = z.strictObject({
roleId: z.string().transform(Number).pipe(z.int().positive()) roleId: z.string().transform(Number).pipe(z.int().positive())
}); });
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
const updateRoleBodySchema = z const updateRoleBodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
requireDeviceApproval: z.boolean().optional(), requireDeviceApproval: z.boolean().optional()
allowSsh: z.boolean().optional(),
sshSudoMode: sshSudoModeSchema.optional(),
sshSudoCommands: z.array(z.string()).optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.array(z.string()).optional()
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update" error: "At least one field must be provided for update"
@@ -83,9 +75,7 @@ export async function updateRole(
} }
const { roleId } = parsedParams.data; const { roleId } = parsedParams.data;
const body = parsedBody.data; const updateData = parsedBody.data;
const { allowSsh, ...restBody } = body;
const updateData: Record<string, unknown> = { ...restBody };
const role = await db const role = await db
.select() .select()
@@ -102,14 +92,16 @@ export async function updateRole(
); );
} }
const orgId = role[0].orgId; if (role[0].isAdmin) {
const isAdminRole = role[0].isAdmin; return next(
createHttpError(
if (isAdminRole) { HttpCode.FORBIDDEN,
delete updateData.name; `Cannot update a Admin role`
delete updateData.description; )
);
} }
const orgId = role[0].orgId;
if (!orgId) { if (!orgId) {
return next( return next(
createHttpError( createHttpError(
@@ -119,70 +111,18 @@ export async function updateRole(
); );
} }
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensedDeviceApprovals) { if (!isLicensed) {
updateData.requireDeviceApproval = undefined; updateData.requireDeviceApproval = undefined;
} }
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); const updatedRole = await db
if (!isLicensedSshPam) { .update(roles)
delete updateData.sshSudoMode; .set(updateData)
delete updateData.sshSudoCommands; .where(eq(roles.roleId, roleId))
delete updateData.sshCreateHomeDir; .returning();
delete updateData.sshUnixGroups;
} else {
if (Array.isArray(updateData.sshSudoCommands)) {
updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands);
}
if (Array.isArray(updateData.sshUnixGroups)) {
updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups);
}
}
const updatedRole = await db.transaction(async (trx) => { if (updatedRole.length === 0) {
const result = await trx
.update(roles)
.set(updateData as typeof roles.$inferInsert)
.where(eq(roles.roleId, roleId))
.returning();
if (result.length === 0) {
return null;
}
if (allowSsh === true) {
const existing = await trx
.select()
.from(roleActions)
.where(
and(
eq(roleActions.roleId, roleId),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
)
.limit(1);
if (existing.length === 0) {
await trx.insert(roleActions).values({
roleId,
actionId: ActionsEnum.signSshKey,
orgId: orgId!
});
}
} else if (allowSsh === false) {
await trx
.delete(roleActions)
.where(
and(
eq(roleActions.roleId, roleId),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
);
}
return result[0];
});
if (!updatedRole) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
@@ -192,7 +132,7 @@ export async function updateRole(
} }
return response(res, { return response(res, {
data: updatedRole, data: updatedRole[0],
success: true, success: true,
error: false, error: false,
message: "Role updated successfully", message: "Role updated successfully",

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -19,8 +19,8 @@ const updateSiteBodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional() dockerSocketEnabled: z.boolean().optional(),
// remoteSubnets: z.string().optional() remoteSubnets: z.string().optional()
// subdomain: z // subdomain: z
// .string() // .string()
// .min(1) // .min(1)
@@ -86,19 +86,18 @@ export async function updateSite(
// if niceId is provided, check if it's already in use by another site // if niceId is provided, check if it's already in use by another site
if (updateData.niceId) { if (updateData.niceId) {
const [existingSite] = await db const existingSite = await db
.select() .select()
.from(sites) .from(sites)
.where( .where(
and( and(
eq(sites.niceId, updateData.niceId), eq(sites.niceId, updateData.niceId),
eq(sites.orgId, sites.orgId), eq(sites.orgId, sites.orgId)
ne(sites.siteId, siteId)
) )
) )
.limit(1); .limit(1);
if (existingSite) { if (existingSite.length > 0 && existingSite[0].siteId !== siteId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.CONFLICT, HttpCode.CONFLICT,
@@ -108,22 +107,22 @@ export async function updateSite(
} }
} }
// // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
// if (updateData.remoteSubnets) { if (updateData.remoteSubnets) {
// const subnets = updateData.remoteSubnets const subnets = updateData.remoteSubnets
// .split(",") .split(",")
// .map((s) => s.trim()); .map((s) => s.trim());
// for (const subnet of subnets) { for (const subnet of subnets) {
// if (!isValidCIDR(subnet)) { if (!isValidCIDR(subnet)) {
// return next( return next(
// createHttpError( createHttpError(
// HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
// `Invalid CIDR format: ${subnet}` `Invalid CIDR format: ${subnet}`
// ) )
// ); );
// } }
// } }
// } }
const updatedSite = await db const updatedSite = await db
.update(sites) .update(sites)

View File

@@ -16,8 +16,6 @@ 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";
@@ -55,9 +53,7 @@ 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(
@@ -172,9 +168,7 @@ 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
@@ -273,11 +267,6 @@ 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") {
@@ -288,29 +277,25 @@ 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(insertValues) .values({
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,8 +78,6 @@ 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,8 +32,6 @@ 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())
@@ -63,9 +61,7 @@ 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(
@@ -176,9 +172,7 @@ 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
@@ -204,11 +198,6 @@ 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)
@@ -319,18 +308,6 @@ 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({
@@ -342,8 +319,7 @@ 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(
@@ -421,18 +397,6 @@ 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({
@@ -444,8 +408,7 @@ 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

@@ -1,29 +0,0 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.16.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
// set all admin role sudo to "full"; all other roles to "none"
// all roles set hoemdir to true
// generate ca certs for all orgs?
// set authDaemonMode to "site" for all orgs
try {
db.transaction(() => {})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/> />
<ClientProvider client={client}> <ClientProvider client={client}>
<div className="space-y-4"> <div className="space-y-6">
<ClientInfoCard /> <ClientInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs> <HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div> </div>

View File

@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
description={t("orgSettingsDescription")} description={t("orgSettingsDescription")}
/> />
<div className="space-y-4"> <div className="space-y-6">
<OrgInfoCard /> <OrgInfoCard />
<HorizontalTabs items={navItems}> <HorizontalTabs items={navItems}>
{children} {children}

View File

@@ -74,9 +74,7 @@ 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,22 +187,21 @@ export default function GeneralPage() {
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
{t.rich( {t(
"enableDockerSocketDescription", "enableDockerSocketDescription"
{ )}{" "}
docsLink: (chunks) => ( <Link
<a href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration" target="_blank"
target="_blank" rel="noopener noreferrer"
rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center"
className="text-primary hover:underline inline-flex items-center gap-1" >
> <span>
{chunks} {t(
<ExternalLink className="size-3.5 shrink-0" /> "enableDockerSocketLink"
</a> )}
) </span>
} </Link>
)}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}

View File

@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/> />
<SiteProvider site={site}> <SiteProvider site={site}>
<div className="space-y-4"> <div className="space-y-6">
<SiteInfoCard /> <SiteInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs> <HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div> </div>

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

@@ -2,7 +2,6 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env"; import { Env } from "@app/lib/types/env";
import { build } from "@server/build"; import { build } from "@server/build";
import { import {
Building2,
ChartLine, ChartLine,
Combine, Combine,
CreditCard, CreditCard,
@@ -12,11 +11,10 @@ import {
KeyRound, KeyRound,
Laptop, Laptop,
Link as LinkIcon, Link as LinkIcon,
Logs, Logs, // Added from 'dev' branch
MonitorUp, MonitorUp,
Plug,
ReceiptText, ReceiptText,
ScanEye, ScanEye, // Added from 'dev' branch
Server, Server,
Settings, Settings,
SquareMousePointer, SquareMousePointer,
@@ -51,12 +49,12 @@ export const orgNavSections = (
options?: OrgNavSectionsOptions options?: OrgNavSectionsOptions
): SidebarNavSection[] => [ ): SidebarNavSection[] => [
{ {
heading: "network", heading: "sidebarGeneral",
items: [ items: [
{ {
title: "sidebarSites", title: "sidebarSites",
href: "/{orgId}/settings/sites", href: "/{orgId}/settings/sites",
icon: <Plug className="size-4 flex-none" /> icon: <Combine className="size-4 flex-none" />
}, },
{ {
title: "sidebarResources", title: "sidebarResources",
@@ -110,19 +108,14 @@ export const orgNavSections = (
heading: "access", heading: "access",
items: [ items: [
{ {
title: "sidebarTeam", title: "sidebarUsers",
icon: <Users className="size-4 flex-none" />, icon: <User 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",
@@ -130,6 +123,11 @@ 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" ||
@@ -159,88 +157,92 @@ export const orgNavSections = (
} }
] ]
}, },
{
heading: "sidebarLogsAndAnalytics",
items: (() => {
const logItems: SidebarNavItem[] = [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
};
// If only one log item, return it directly without grouping
if (logItems.length === 1) {
return [analytics, ...logItems];
}
// If multiple log items, create a group
return [
analytics,
{
title: "sidebarLogs",
icon: <Logs className="size-4 flex-none" />,
items: logItems
}
];
})()
},
{ {
heading: "sidebarOrganization", heading: "sidebarOrganization",
items: [ items: [
{ {
title: "sidebarLogsAndAnalytics", title: "sidebarApiKeys",
icon: <ChartLine className="size-4 flex-none" />, href: "/{orgId}/settings/api-keys",
items: [ icon: <KeyRound className="size-4 flex-none" />
{
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="size-4 flex-none" />
},
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: (
<SquareMousePointer className="size-4 flex-none" />
)
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
]
}, },
{ {
title: "sidebarManagement", title: "sidebarBluePrints",
icon: <Building2 className="size-4 flex-none" />, href: "/{orgId}/settings/blueprints",
items: [ icon: <ReceiptText className="size-4 flex-none" />
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
}
]
}, },
...(build == "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",
icon: <CreditCard className="size-4 flex-none" />,
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: (
<CreditCard className="size-4 flex-none" />
)
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: (
<TicketCheck className="size-4 flex-none" />
)
}
]
}
]
: []),
{ {
title: "sidebarSettings", title: "sidebarSettings",
href: "/{orgId}/settings/general", href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" /> icon: <Settings className="size-4 flex-none" />
} }
] ]
} },
...(build == "saas" && options?.isPrimaryOrg
? [
{
heading: "sidebarBillingAndLicenses",
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="size-4 flex-none" />
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="size-4 flex-none" />
}
]
}
]
: [])
]; ];
export const adminNavSections = (env?: Env): SidebarNavSection[] => [ export const adminNavSections = (env?: Env): SidebarNavSection[] => [

View File

@@ -51,8 +51,6 @@ 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

View File

@@ -11,19 +11,31 @@ import {
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { import { zodResolver } from "@hookform/resolvers/zod";
CreateRoleBody, import { build } from "@server/build";
CreateRoleResponse import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
} from "@server/routers/role";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = { type CreateRoleFormProps = {
@@ -40,39 +52,35 @@ export default function CreateRoleForm({
const { org } = useOrgContext(); const { org } = useOrgContext();
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional()
});
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
requireDeviceApproval: false
}
});
const [loading, startTransition] = useTransition(); const [loading, startTransition] = useTransition();
async function onSubmit(values: RoleFormValues) { async function onSubmit(values: z.infer<typeof formSchema>) {
const payload: CreateRoleBody = {
name: values.name,
description: values.description || undefined,
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.sshPam)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api const res = await api
.put<AxiosResponse<CreateRoleResponse>>( .put<
`/org/${org?.org.orgId}/role`, AxiosResponse<CreateRoleResponse>
payload >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -90,42 +98,143 @@ export default function CreateRoleForm({
title: t("accessRoleCreated"), title: t("accessRoleCreated"),
description: t("accessRoleCreatedDescription") description: t("accessRoleCreatedDescription")
}); });
if (open) setOpen(false);
if (open) {
setOpen(false);
}
afterCreate?.(res.data.data); afterCreate?.(res.data.data);
} }
} }
return ( return (
<Credenza open={open} onOpenChange={setOpen}> <>
<CredenzaContent> <Credenza
<CredenzaHeader> open={open}
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle> onOpenChange={(val) => {
<CredenzaDescription> setOpen(val);
{t("accessRoleCreateDescription")} form.reset();
</CredenzaDescription> }}
</CredenzaHeader> >
<CredenzaBody> <CredenzaContent>
<RoleForm <CredenzaHeader>
variant="create" <CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
onSubmit={(values) => <CredenzaDescription>
startTransition(() => onSubmit(values)) {t("accessRoleCreateDescription")}
} </CredenzaDescription>
/> </CredenzaHeader>
</CredenzaBody> <CredenzaBody>
<CredenzaFooter> <Form {...form}>
<CredenzaClose asChild> <form
<Button variant="outline">{t("close")}</Button> onSubmit={form.handleSubmit((values) =>
</CredenzaClose> startTransition(() => onSubmit(values))
<Button )}
type="submit" className="space-y-4"
form="create-role-form" id="create-role-form"
loading={loading} >
disabled={loading} <FormField
> control={form.control}
{t("accessRoleCreateSubmit")} name="name"
</Button> render={({ field }) => (
</CredenzaFooter> <FormItem>
</CredenzaContent> <FormLabel>
</Credenza> {t("accessRoleName")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleCreateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
); );
} }

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaContent <CredenzaContent
className={cn( className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0", "overflow-y-auto max-h-[100dvh] md:max-h-screen",
className className
)} )}
{...props} {...props}

File diff suppressed because it is too large Load Diff

View File

@@ -11,26 +11,44 @@ import {
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import type { Role } from "@server/db"; import type { Role } from "@server/db";
import type { import type {
CreateRoleBody,
CreateRoleResponse,
UpdateRoleBody, UpdateRoleBody,
UpdateRoleResponse UpdateRoleResponse
} from "@server/routers/role"; } from "@server/routers/role";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTransition } from "react"; import { useTransition } from "react";
import { RoleForm, type RoleFormValues } from "./RoleForm"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = { type CreateRoleFormProps = {
role: Role; role: Role;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
onSuccess?: (res: UpdateRoleResponse) => void; onSuccess?: (res: CreateRoleResponse) => void;
}; };
export default function EditRoleForm({ export default function EditRoleForm({
@@ -38,44 +56,39 @@ export default function EditRoleForm({
role, role,
setOpen, setOpen,
onSuccess onSuccess
}: EditRoleFormProps) { }: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional()
});
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false
}
});
const [loading, startTransition] = useTransition(); const [loading, startTransition] = useTransition();
async function onSubmit(values: RoleFormValues) { async function onSubmit(values: z.infer<typeof formSchema>) {
const payload: UpdateRoleBody = {
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (!role.isAdmin) {
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.sshPam)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api const res = await api
.post<AxiosResponse<UpdateRoleResponse>>( .post<
`/role/${role.roleId}`, AxiosResponse<UpdateRoleResponse>
payload >(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -93,43 +106,143 @@ export default function EditRoleForm({
title: t("accessRoleUpdated"), title: t("accessRoleUpdated"),
description: t("accessRoleUpdatedDescription") description: t("accessRoleUpdatedDescription")
}); });
if (open) setOpen(false);
if (open) {
setOpen(false);
}
onSuccess?.(res.data.data); onSuccess?.(res.data.data);
} }
} }
return ( return (
<Credenza open={open} onOpenChange={setOpen}> <>
<CredenzaContent> <Credenza
<CredenzaHeader> open={open}
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle> onOpenChange={(val) => {
<CredenzaDescription> setOpen(val);
{t("accessRoleEditDescription")} form.reset();
</CredenzaDescription> }}
</CredenzaHeader> >
<CredenzaBody> <CredenzaContent>
<RoleForm <CredenzaHeader>
variant="edit" <CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
role={role} <CredenzaDescription>
onSubmit={(values) => {t("accessRoleEditDescription")}
startTransition(() => onSubmit(values)) </CredenzaDescription>
} </CredenzaHeader>
/> <CredenzaBody>
</CredenzaBody> <Form {...form}>
<CredenzaFooter> <form
<CredenzaClose asChild> onSubmit={form.handleSubmit((values) =>
<Button variant="outline">{t("close")}</Button> startTransition(() => onSubmit(values))
</CredenzaClose> )}
<Button className="space-y-4"
type="submit" id="create-role-form"
form="create-role-form" >
loading={loading} <FormField
disabled={loading} control={form.control}
> name="name"
{t("accessRoleUpdateSubmit")} render={({ field }) => (
</Button> <FormItem>
</CredenzaFooter> <FormLabel>
</CredenzaContent> {t("accessRoleName")}
</Credenza> </FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleUpdateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
); );
} }

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-14" // Add top padding only on desktop to account for fixed header showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
)} )}
> >
{children} {children}

View File

@@ -73,14 +73,14 @@ export function LayoutMobileMenu({
{t("navbarDescription")} {t("navbarDescription")}
</SheetDescription> </SheetDescription>
<div className="flex-1 overflow-y-auto relative"> <div className="flex-1 overflow-y-auto relative">
<div className="px-1"> <div className="px-3">
<OrgSelector <OrgSelector
orgId={orgId} orgId={orgId}
orgs={orgs} orgs={orgs}
/> />
</div> </div>
<div className="w-full border-b border-border" /> <div className="w-full border-b border-border" />
<div className="px-3 pt-3"> <div className="px-3">
{!isAdminPage && {!isAdminPage &&
user.serverAdmin && ( user.serverAdmin && (
<div className="py-2"> <div className="py-2">

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, PanelRightOpen, Server } from "lucide-react"; import { ExternalLink, 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";
@@ -145,7 +145,37 @@ export function LayoutSidebar({
)} )}
/> />
<div className="flex-1 overflow-y-auto relative"> <div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-3"> <div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="py-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<SidebarNav <SidebarNav
sections={navItems} sections={navItems}
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
@@ -156,89 +186,31 @@ export function LayoutSidebar({
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" /> <div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div> </div>
{!isAdminPage && user.serverAdmin && ( <div className="w-full border-t border-border" />
<div className="shrink-0 px-2 pb-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed ? t("serverAdmin") : undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</>
)}
</Link>
</div>
)}
{isSidebarCollapsed && ( <div className="p-4 pt-1 flex flex-col shrink-0">
<div className="shrink-0 flex justify-center py-2"> {canShowProductUpdates ? (
<TooltipProvider> <div className="mb-3">
<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="w-full border-t border-border 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 empty:mb-0"> <div className="mb-3">
<SidebarLicenseButton <SidebarLicenseButton
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
/> />
</div> </div>
)} )}
{build === "oss" && ( {build === "oss" && (
<div className="mb-3 empty:mb-0"> <div className="mb-3">
<SupporterStatus isCollapsed={isSidebarCollapsed} /> <SupporterStatus isCollapsed={isSidebarCollapsed} />
</div> </div>
)} )}
{build === "saas" && ( {build === "saas" && (
<div className="mb-3 empty:mb-0"> <div className="mb-3">
<SidebarSupportButton <SidebarSupportButton
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
/> />
@@ -254,19 +226,19 @@ export function LayoutSidebar({
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{link.href ? ( {link.href ? (
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
<Link <Link
href={link.href} href={link.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-start gap-1" className="flex items-center justify-center gap-1"
> >
{link.text} {link.text}
<ExternalLink size={12} /> <ExternalLink size={12} />
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
{link.text} {link.text}
</div> </div>
)} )}
@@ -275,12 +247,12 @@ export function LayoutSidebar({
</> </>
) : ( ) : (
<> <>
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
<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-start gap-1" className="flex items-center justify-center gap-1"
> >
{build === "oss" {build === "oss"
? t("communityEdition") ? t("communityEdition")
@@ -293,22 +265,22 @@ export function LayoutSidebar({
{build === "enterprise" && {build === "enterprise" &&
isUnlocked() && isUnlocked() &&
licenseStatus?.tier === "personal" ? ( licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
{t("personalUseOnly")} {t("personalUseOnly")}
</div> </div>
) : null} ) : null}
{build === "enterprise" && !isUnlocked() ? ( {build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
{t("unlicensed")} {t("unlicensed")}
</div> </div>
) : null} ) : null}
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-left"> <div className="text-xs text-muted-foreground text-center">
<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-start gap-1" className="flex items-center justify-center gap-1"
> >
v{env.app.version} v{env.app.version}
<ExternalLink size={12} /> <ExternalLink size={12} />

View File

@@ -1,70 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import type { ReactNode } from "react";
export type OptionSelectOption<TValue extends string> = {
value: TValue;
label: string;
icon?: ReactNode;
};
type OptionSelectProps<TValue extends string> = {
options: ReadonlyArray<OptionSelectOption<TValue>>;
value: TValue;
onChange: (value: TValue) => void;
label?: string;
/** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */
cols?: number;
className?: string;
disabled?: boolean;
};
export function OptionSelect<TValue extends string>({
options,
value,
onChange,
label,
cols = 5,
className,
disabled = false
}: OptionSelectProps<TValue>) {
return (
<div className={className}>
{label && (
<p className="font-bold mb-3">{label}</p>
)}
<div
className={cn(
"grid gap-2",
cols === 2 && "grid-cols-2",
cols === 3 && "grid-cols-2 md:grid-cols-3",
cols === 4 && "grid-cols-2 md:grid-cols-4",
cols === 5 && "grid-cols-2 md:grid-cols-5",
cols === 6 && "grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
)}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<Button
key={option.value}
type="button"
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
className={cn(
"flex-1 min-w-30 shadow-none",
isSelected && "bg-primary/10"
)}
onClick={() => onChange(option.value)}
disabled={disabled}
>
{option.icon}
{option.label}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -6,7 +6,8 @@ import {
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList CommandList,
CommandSeparator
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { import {
Popover, Popover,
@@ -24,7 +25,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
@@ -71,7 +71,7 @@ export function OrgSelector({
"cursor-pointer transition-colors", "cursor-pointer transition-colors",
isCollapsed isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-muted" ? "w-full h-16 flex items-center justify-center hover:bg-muted"
: "w-full px-5 py-4 hover:bg-muted" : "w-full px-4 py-4 hover:bg-muted"
)} )}
> >
{isCollapsed ? ( {isCollapsed ? (
@@ -93,45 +93,68 @@ export function OrgSelector({
)} )}
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-[320px] p-0" align="start">
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible" <Command className="rounded-lg">
align="start"
sideOffset={12}
>
<Command className="rounded-lg border-0 flex-1 min-h-0">
<CommandInput <CommandInput
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0 h-9 rounded-b-none" className="border-0 focus:ring-0"
/> />
<CommandList className="max-h-[280px]"> <CommandEmpty className="py-6 text-center">
<CommandEmpty className="py-4 text-center"> <div className="text-muted-foreground text-sm">
<div className="text-muted-foreground text-sm"> {t("orgNotFound2")}
{t("orgNotFound2")} </div>
</div> </CommandEmpty>
</CommandEmpty> {(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<CommandGroup className="p-1" heading={t("orgs")}> <>
<CommandGroup
heading={t("create")}
className="py-2"
>
<CommandList>
<CommandItem
onSelect={() => {
setOpen(false);
router.push("/setup");
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">
{t("setupNewOrg")}
</span>
<span className="text-xs text-muted-foreground">
{t("createNewOrgDescription")}
</span>
</div>
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator className="my-2" />
</>
)}
<CommandGroup heading={t("orgs")} className="py-2">
<CommandList>
{sortedOrgs.map((org) => ( {sortedOrgs.map((org) => (
<CommandItem <CommandItem
key={org.orgId} key={org.orgId}
onSelect={() => { onSelect={() => {
setOpen(false); setOpen(false);
const newPath = pathname.includes( const newPath = pathname.replace(
"/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-2 rounded-md"
> >
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-3.5 w-3.5 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</div> </div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5"> <div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate text-sm"> <span className="font-medium truncate">
{org.name} {org.name}
</span> </span>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -150,7 +173,7 @@ export function OrgSelector({
</div> </div>
<Check <Check
className={cn( className={cn(
"h-4 w-4 text-primary flex-shrink-0", "h-4 w-4 text-primary",
orgId === org.orgId orgId === org.orgId
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0"
@@ -158,25 +181,9 @@ export function OrgSelector({
/> />
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandList>
</CommandList> </CommandGroup>
</Command> </Command>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<div className="p-2 border-t border-border">
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
onClick={() => {
setOpen(false);
router.push("/setup");
}}
>
<Plus className="h-3.5 w-3.5 mr-2" />
{t("setupNewOrg")}
</Button>
</div>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -12,42 +12,34 @@ 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< const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
Tier,
| "subscriptionTierTier1"
| "subscriptionTierTier2"
| "subscriptionTierTier3"
| "subscriptionTierEnterprise"
> = {
tier1: "subscriptionTierTier1", tier1: "subscriptionTierTier1",
tier2: "subscriptionTierTier2", tier2: "subscriptionTierTier2",
tier3: "subscriptionTierTier3", tier3: "subscriptionTierTier3",
enterprise: "subscriptionTierEnterprise" enterprise: "subscriptionTierEnterprise"
}; };
function formatRequiredTiersList( function getRequiredTier(tiers: Tier[]): Tier | null {
tiers: Tier[],
t: (key: (typeof TIER_TRANSLATION_KEYS)[Tier]) => string
): string | null {
if (tiers.length === 0) return null; if (tiers.length === 0) return null;
const sorted = [...tiers] let min: Tier | null = null;
.filter((tier) => TIER_ORDER.includes(tier)) for (const tier of tiers) {
.sort((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b)); const idx = TIER_ORDER.indexOf(tier);
if (sorted.length === 0) return null; if (idx === -1) continue;
const names = sorted.map((tier) => t(TIER_TRANSLATION_KEYS[tier])); if (min === null || TIER_ORDER.indexOf(min) > idx) {
if (names.length === 1) return names[0]; min = tier;
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-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden"; "mb-6 border-purple-500/30 bg-linear-to-br from-purple-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-black-500"; const bannerIconClassName = "size-4 shrink-0 text-purple-500";
const docsLinkClassName = const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-black-600 underline"; "inline-flex items-center gap-1 font-medium text-purple-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";
@@ -102,17 +94,11 @@ 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 { const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
hasSaasSubscription,
hasEnterpriseLicense,
isActive,
subscriptionTier
} = usePaidStatus();
const { env } = useEnvContext(); const { env } = useEnvContext();
const requiredTiersLabel = formatRequiredTiersList(tiers, t); const requiredTier = getRequiredTier(tiers);
const billingHref = orgId const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
? `/${orgId}/settings/billing` const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
: "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);
@@ -129,16 +115,16 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<div className={bannerRowClassName}> <div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{requiredTiersLabel {requiredTierName
? isActive ? isActive
? t.rich("upgradeToTierToUse", { ? t.rich("upgradeToTierToUse", {
tier: requiredTiersLabel, tier: requiredTierName,
tierLink: tierLinkRenderer tierLink: tierLinkRenderer
}) })
: t.rich("upgradeToTierToUse", { : t.rich("subscriptionRequiredTierToUse", {
tier: requiredTiersLabel, tier: requiredTierName,
tierLink: tierLinkRenderer tierLink: tierLinkRenderer
}) })
: isActive : isActive
? t("mustUpgradeToUse") ? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")} : t("subscriptionRequiredToUse")}
@@ -155,8 +141,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{t.rich("licenseRequiredToUse", { {t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseLicenseLink: enterpriseDocsLinkRenderer,
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer pangolinCloudLink: pangolinCloudLinkRenderer
})} })}
</span> </span>
@@ -172,8 +157,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} /> <KeyRound className={bannerIconClassName} />
<span> <span>
{t.rich("ossEnterpriseEditionRequired", { {t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: enterpriseEditionLink: enterpriseDocsLinkRenderer,
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 empty:mt-0", "text-xs text-muted-foreground flex items-center gap-1 mt-2",
showMoreUpdatesText showMoreUpdatesText
? "animate-in fade-in duration-300" ? "animate-in fade-in duration-300"
: "opacity-0" : "opacity-0"

View File

@@ -1,468 +0,0 @@
"use client";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Role } from "@server/db";
export const SSH_SUDO_MODE_VALUES = ["none", "full", "commands"] as const;
export type SshSudoMode = (typeof SSH_SUDO_MODE_VALUES)[number];
function parseRoleJsonArray(value: string | null | undefined): string[] {
if (value == null || value === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toSshSudoMode(value: string | null | undefined): SshSudoMode {
if (value === "none" || value === "full" || value === "commands")
return value;
return "none";
}
export type RoleFormValues = {
name: string;
description?: string;
requireDeviceApproval?: boolean;
allowSsh?: boolean;
sshSudoMode: SshSudoMode;
sshSudoCommands?: string;
sshCreateHomeDir?: boolean;
sshUnixGroups?: string;
};
type RoleFormProps = {
variant: "create" | "edit";
role?: Role;
onSubmit: (values: RoleFormValues) => void | Promise<void>;
formId?: string;
};
export function RoleForm({
variant,
role,
onSubmit,
formId = "create-role-form"
}: RoleFormProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: z.string().optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.string().optional()
});
const defaultValues: RoleFormValues = role
? {
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false,
allowSsh:
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
}
: {
name: "",
description: "",
requireDeviceApproval: false,
allowSsh: false,
sshSudoMode: "none",
sshSudoCommands: "",
sshCreateHomeDir: true,
sshUnixGroups: ""
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues
});
useEffect(() => {
if (variant === "edit" && role) {
form.reset({
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false,
allowSsh:
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
});
}
}, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => onSubmit(values))}
className="space-y-4"
id={formId}
>
{env.flags.disableEnterpriseFeatures ? (
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("accessRoleName")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("description")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : (
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
...(env.flags.disableEnterpriseFeatures
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
>
{/* General tab */}
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("accessRoleName")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(checked) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<FormField
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
},
{
value: "disallow",
label: t("roleAllowSshDisallow")
}
];
return (
<FormItem>
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<"allow" | "disallow">
options={allowSshOptions}
value={
sshDisabled
? "disallow"
: field.value
? "allow"
: "disallow"
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(v === "allow");
}}
cols={2}
disabled={sshDisabled}
/>
<FormDescription>
{t(
"roleAllowSshDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshSudoMode"
render={({ field }) => {
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
[
{
value: "none",
label: t("sshSudoModeNone")
},
{
value: "full",
label: t("sshSudoModeFull")
},
{
value: "commands",
label: t(
"sshSudoModeCommands"
)
}
];
return (
<FormItem>
<FormLabel>
{t("sshSudoMode")}
</FormLabel>
<OptionSelect<SshSudoMode>
options={sudoOptions}
value={field.value}
onChange={field.onChange}
cols={3}
disabled={sshDisabled}
/>
<FormMessage />
</FormItem>
);
}}
/>
{sshSudoMode === "commands" && (
<FormField
control={form.control}
name="sshSudoCommands"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={sshDisabled}
/>
</FormControl>
<FormDescription>
{t(
"sshSudoCommandsDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="sshUnixGroups"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={sshDisabled}
/>
</FormControl>
<FormDescription>
{t("sshUnixGroupsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshCreateHomeDir"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
value="on"
checked={form.watch(
"sshCreateHomeDir"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"sshCreateHomeDir",
checked
);
}
}}
label={t(
"sshCreateHomeDir"
)}
disabled={sshDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</HorizontalTabs>
)}
</form>
</Form>
);
}

View File

@@ -103,46 +103,45 @@ export default function UsersTable({ roles }: RolesTableProps) {
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => { cell: ({ row }) => {
const roleRow = row.original; const roleRow = row.original;
const isAdmin = roleRow.isAdmin;
return ( return (
<div className="flex items-center gap-2 justify-end"> !roleRow.isAdmin && (
<DropdownMenu> <div className="flex items-center gap-2 justify-end">
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
className="h-8 w-8 p-0" variant="ghost"
disabled={isAdmin || false} className="h-8 w-8 p-0"
> >
<span className="sr-only"> <span className="sr-only">
{t("openMenu")} {t("openMenu")}
</span> </span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
disabled={isAdmin || false} onClick={() => {
onClick={() => { setRoleToRemove(roleRow);
setRoleToRemove(roleRow); setIsDeleteModalOpen(true);
setIsDeleteModalOpen(true); }}
}} >
> <span className="text-red-500">
<span className="text-red-500"> {t("delete")}
{t("delete")} </span>
</span> </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> <Button
<Button variant={"outline"}
variant={"outline"} onClick={() => {
onClick={() => { setEditingRole(roleRow);
setEditingRole(roleRow); setIsEditDialogOpen(true);
setIsEditDialogOpen(true); }}
}} >
> {t("edit")}
{t("edit")} </Button>
</Button> </div>
</div> )
); );
} }
} }

View File

@@ -119,7 +119,7 @@ function CollapsibleNavItem({
<button <button
className={cn( className={cn(
"flex items-center w-full rounded-md transition-colors", "flex items-center w-full rounded-md transition-colors",
"px-3 py-1.5", level === 0 ? "px-3 py-1.5" : "px-3 py-1",
isActive isActive
? "bg-secondary font-medium" ? "bg-secondary font-medium"
: "text-muted-foreground 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",
@@ -128,7 +128,7 @@ function CollapsibleNavItem({
disabled={isDisabled} disabled={isDisabled}
> >
{item.icon && ( {item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground"> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{item.icon} {item.icon}
</span> </span>
)} )}
@@ -167,192 +167,22 @@ function CollapsibleNavItem({
</div> </div>
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent forceMount> <CollapsibleContent>
<div <div
className={cn( className={cn(
"grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out", "border-l ml-3 pl-3 mt-0 space-y-0",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]" "border-border"
)} )}
> >
<div className="min-h-0"> {item.items!.map((childItem) =>
<div renderNavItem(childItem, level + 1)
className={cn( )}
"border-l ml-[22px] pl-[9px] mt-0 space-y-0",
"border-border"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
</div>
</div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
} }
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,
@@ -448,7 +278,11 @@ export function SidebarNav({
href={isDisabled ? "#" : hydratedHref} href={isDisabled ? "#" : hydratedHref}
className={cn( className={cn(
"flex items-center rounded-md transition-colors relative", "flex items-center rounded-md transition-colors relative",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isCollapsed
? "px-2 py-2 justify-center"
: level === 0
? "px-3 py-1.5"
: "px-3 py-1",
isActive isActive
? "bg-secondary font-medium" ? "bg-secondary font-medium"
: "text-muted-foreground 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",
@@ -464,13 +298,10 @@ export function SidebarNav({
tabIndex={isDisabled ? -1 : undefined} tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled} aria-disabled={isDisabled}
> >
{item.icon && level === 0 && ( {item.icon && (
<span <span
className={cn( className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center", "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"
)} )}
> >
@@ -524,13 +355,13 @@ export function SidebarNav({
<div <div
className={cn( className={cn(
"flex items-center rounded-md transition-colors", "flex items-center rounded-md transition-colors",
"px-3 py-1.5", level === 0 ? "px-3 py-1.5" : "px-3 py-1",
"text-muted-foreground", "text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
> >
{item.icon && level === 0 && ( {item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground"> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{item.icon} {item.icon}
</span> </span>
)} )}
@@ -570,21 +401,120 @@ 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 (
<CollapsedNavItemWithPopover <TooltipProvider key={item.title}>
key={item.title} <Tooltip>
item={item} <Popover>
tooltipText={tooltipText} <PopoverTrigger asChild>
isActive={isActive} <TooltipTrigger asChild>
isChildActive={isChildActive} <button
isDisabled={!!isDisabled} className={cn(
hydrateHref={hydrateHref} "flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
pathname={pathname} isActive || isChildActive
build={build} ? "bg-secondary font-medium"
isUnlocked={isUnlocked} : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
disabled={disabled ?? false} isDisabled &&
t={t} "cursor-not-allowed opacity-60"
onItemClick={onItemClick} )}
/> disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{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 if (
onItemClick
) {
onItemClick();
}
}}
>
{childItem.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{childItem.icon}
</span>
)}
<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>
); );
} }
@@ -619,7 +549,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-foreground uppercase tracking-wider"> <div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider">
{t(`${section.heading}`)} {t(`${section.heading}`)}
</div> </div>
)} )}

View File

@@ -14,7 +14,6 @@ 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;
@@ -22,21 +21,18 @@ 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 [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue); const [selected, setSelected] = useState<TValue | undefined>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
return ( return (
<RadioGroup <RadioGroup
value={selected ?? ""} defaultValue={defaultValue}
onValueChange={(value: string) => { onValueChange={(value: string) => {
const typedValue = value as TValue; const typedValue = value as TValue;
if (!isControlled) setUncontrolledSelected(typedValue); setSelected(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`}

View File

@@ -155,72 +155,62 @@ export default function UsersTable({ users: u }: UsersTableProps) {
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => { cell: ({ row }) => {
const userRow = row.original; const userRow = row.original;
const isCurrentUser =
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
return ( return (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<div> <div>
<DropdownMenu> {!userRow.isOwner && (
<DropdownMenuTrigger asChild> <>
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
className="h-8 w-8 p-0" <Button
disabled={isDisabled} variant="ghost"
> className="h-8 w-8 p-0"
<span className="sr-only"> >
{t("openMenu")} <span className="sr-only">
</span> {t("openMenu")}
<MoreHorizontal className="h-4 w-4" /> </span>
</Button> <MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<Link <DropdownMenuContent align="end">
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} <Link
className="block w-full" href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
aria-disabled={isDisabled} className="block w-full"
onClick={(e) => >
isDisabled && e.preventDefault() <DropdownMenuItem>
} {t("accessUsersManage")}
> </DropdownMenuItem>
<DropdownMenuItem </Link>
disabled={isDisabled} {`${userRow.username}-${userRow.idpId}` !==
> `${user?.username}-${user?.idpId}` && (
{t("accessUsersManage")} <DropdownMenuItem
</DropdownMenuItem> onClick={() => {
</Link> setIsDeleteModalOpen(
{!isDisabled && ( true
<DropdownMenuItem );
onClick={() => { setSelectedUser(
setIsDeleteModalOpen(true); userRow
setSelectedUser(userRow); );
}} }}
> >
<span className="text-red-500"> <span className="text-red-500">
{t("accessUserRemove")} {t("accessUserRemove")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
)}
</div> </div>
{isDisabled ? ( {!userRow.isOwner && (
<Button
variant={"outline"}
className="ml-2"
disabled
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
) : (
<Link <Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
> >
<Button <Button
variant={"outline"} variant={"outline"}
className="ml-2" className="ml-2"
disabled={userRow.isOwner}
> >
{t("manage")} {t("manage")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -8,7 +8,7 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } from "./Settings";
import { CheckboxWithLabel } from "./ui/checkbox"; import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { Button } from "./ui/button";
import { useState } from "react"; import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa"; import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react"; import { Terminal } from "lucide-react";
@@ -138,14 +138,6 @@ WantedBy=default.target`
const commands = commandList[platform][architecture]; const commands = commandList[platform][architecture];
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
(os) => ({
value: os,
label: getPlatformName(os),
icon: getPlatformIcon(os)
})
);
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -157,33 +149,53 @@ WantedBy=default.target`
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<OptionSelect<Platform> <div>
label={t("operatingSystem")} <p className="font-bold mb-3">{t("operatingSystem")}</p>
options={platformOptions} <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
value={platform} {PLATFORMS.map((os) => (
onChange={(os) => { <Button
setPlatform(os); key={os}
const architectures = getArchitectures(os); variant={
setArchitecture(architectures[0]); platform === os
}} ? "squareOutlinePrimary"
cols={5} : "squareOutline"
/> }
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<OptionSelect<string> <div>
label={ <p className="font-bold mb-3">
["docker", "podman"].includes(platform) {["docker", "podman"].includes(platform)
? t("method") ? t("method")
: t("architecture") : t("architecture")}
} </p>
options={getArchitectures(platform).map((arch) => ({ <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
value: arch, {getArchitectures(platform).map((arch) => (
label: arch <Button
}))} key={arch}
value={architecture} variant={
onChange={setArchitecture} architecture === arch
cols={5} ? "squareOutlinePrimary"
className="mt-4" : "squareOutline"
/> }
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() => setArchitecture(arch)}
>
{arch}
</Button>
))}
</div>
<div className="pt-4"> <div className="pt-4">
<p className="font-bold mb-3"> <p className="font-bold mb-3">
@@ -238,6 +250,7 @@ WantedBy=default.target`
})} })}
</div> </div>
</div> </div>
</div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
); );

View File

@@ -10,7 +10,7 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } from "./Settings";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { Button } from "./ui/button";
export type CommandItem = string | { title: string; command: string }; export type CommandItem = string | { title: string; command: string };
@@ -88,15 +88,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
}; };
const commands = commandList[platform][architecture]; const commands = commandList[platform][architecture];
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
(os) => ({
value: os,
label: getPlatformName(os),
icon: getPlatformIcon(os)
})
);
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
@@ -108,35 +99,54 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<OptionSelect<Platform> <div>
label={t("operatingSystem")} <p className="font-bold mb-3">{t("operatingSystem")}</p>
options={platformOptions} <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
value={platform} {PLATFORMS.map((os) => (
onChange={(os) => { <Button
setPlatform(os); key={os}
const architectures = getArchitectures(os); variant={
setArchitecture(architectures[0]); platform === os
}} ? "squareOutlinePrimary"
cols={5} : "squareOutline"
/> }
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<OptionSelect<string> <div>
label={ <p className="font-bold mb-3">
platform === "docker" {["docker", "podman"].includes(platform)
? t("method") ? t("method")
: t("architecture") : t("architecture")}
} </p>
options={getArchitectures(platform).map((arch) => ({ <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
value: arch, {getArchitectures(platform).map((arch) => (
label: arch <Button
}))} key={arch}
value={architecture} variant={
onChange={setArchitecture} architecture === arch
cols={5} ? "squareOutlinePrimary"
className="mt-4" : "squareOutline"
/> }
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
<div className="pt-4"> onClick={() => setArchitecture(arch)}
>
{arch}
</Button>
))}
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p> <p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{commands.map((item, index) => { {commands.map((item, index) => {
@@ -164,6 +174,7 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
); );
})} })}
</div> </div>
</div>
</div> </div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -20,7 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try { try {
const subRes = await getCachedSubscription(orgId); const subRes = await getCachedSubscription(orgId);
subscribed = subscribed =
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3") && (subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
subRes.data.data.active; subRes.data.data.active;
} catch {} } catch {}
} }

View File

@@ -42,7 +42,8 @@ export function SubscriptionStatusProvider({
if ( if (
subscription.type == "tier1" || subscription.type == "tier1" ||
subscription.type == "tier2" || subscription.type == "tier2" ||
subscription.type == "tier3" subscription.type == "tier3" ||
subscription.type == "enterprise"
) { ) {
return { return {
tier: subscription.type, tier: subscription.type,
@@ -61,7 +62,7 @@ export function SubscriptionStatusProvider({
const isSubscribed = () => { const isSubscribed = () => {
const { tier, active } = getTier(); const { tier, active } = getTier();
return ( return (
(tier == "tier1" || tier == "tier2" || tier == "tier3") && (tier == "tier1" || tier == "tier2" || tier == "tier3" || tier == "enterprise") &&
active active
); );
}; };