From d6ba34aeeacb90b29171e0cef8e2bac893daf6cf Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 20 Feb 2026 17:33:21 -0800 Subject: [PATCH] set auth daemon type on resource --- cli/commands/generateOrgCaKeys.ts | 121 ++ cli/index.ts | 2 + messages/en-US.json | 21 +- server/db/pg/schema/schema.ts | 6 +- server/db/sqlite/schema/schema.ts | 4 +- server/lib/billing/tierMatrix.ts | 2 +- server/private/lib/sshCA.ts | 27 +- .../routers/billing/featureLifecycle.ts | 17 +- server/private/routers/external.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 7 +- server/routers/org/createOrg.ts | 26 +- .../siteResource/createSiteResource.ts | 53 +- .../siteResource/listAllSiteResourcesByOrg.ts | 2 + .../siteResource/updateSiteResource.ts | 45 +- server/setup/scriptsSqlite/1.16.0.ts | 1 + .../settings/resources/client/page.tsx | 4 +- .../settings/sites/[niceId]/general/page.tsx | 33 +- src/app/layout.tsx | 4 +- src/app/navigation.tsx | 14 +- src/components/ClientResourcesTable.tsx | 2 + .../CreateInternalResourceDialog.tsx | 1211 +-------------- src/components/EditInternalResourceDialog.tsx | 1372 +---------------- src/components/InternalResourceForm.tsx | 1328 ++++++++++++++++ src/components/Layout.tsx | 2 +- src/components/LayoutHeader.tsx | 4 +- src/components/LayoutSidebar.tsx | 62 +- src/components/OrgSelector.tsx | 24 +- src/components/PaidFeaturesAlert.tsx | 72 +- src/components/ProductUpdates.tsx | 2 +- src/components/RoleForm.tsx | 22 +- src/components/RolesTable.tsx | 6 +- src/components/SidebarNav.tsx | 302 ++-- src/components/StrategySelect.tsx | 10 +- 33 files changed, 2010 insertions(+), 2800 deletions(-) create mode 100644 cli/commands/generateOrgCaKeys.ts create mode 100644 src/components/InternalResourceForm.tsx diff --git a/cli/commands/generateOrgCaKeys.ts b/cli/commands/generateOrgCaKeys.ts new file mode 100644 index 00000000..af822c81 --- /dev/null +++ b/cli/commands/generateOrgCaKeys.ts @@ -0,0 +1,121 @@ +import { CommandModule } from "yargs"; +import { db, orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { generateCA } from "@server/private/lib/sshCA"; +import fs from "fs"; +import yaml from "js-yaml"; + +type GenerateOrgCaKeysArgs = { + orgId: string; + secret?: string; + force?: boolean; +}; + +export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = { + command: "generate-org-ca-keys", + describe: + "Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)", + builder: (yargs) => { + return yargs + .option("orgId", { + type: "string", + demandOption: true, + describe: "The organization ID" + }) + .option("secret", { + type: "string", + describe: + "Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)." + }) + .option("force", { + type: "boolean", + default: false, + describe: + "Overwrite existing CA keys for the org if they already exist" + }); + }, + handler: async (argv: { + orgId: string; + secret?: string; + force?: boolean; + }) => { + try { + const { orgId, force } = argv; + let secret = argv.secret; + + if (!secret) { + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + if (!configPath) { + console.error( + "Error: No server secret provided and config file not found. " + + "Expected config.yml or config.yaml in the config directory, or pass --secret." + ); + process.exit(1); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + if (!config?.server?.secret) { + console.error( + "Error: No server.secret in config file. Pass --secret or set server.secret in config." + ); + process.exit(1); + } + secret = config.server.secret; + } + + const [org] = await db + .select({ + orgId: orgs.orgId, + sshCaPrivateKey: orgs.sshCaPrivateKey, + sshCaPublicKey: orgs.sshCaPublicKey + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + console.error(`Error: Organization with orgId "${orgId}" not found.`); + process.exit(1); + } + + if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) { + if (!force) { + console.error( + "Error: This organization already has CA keys. Use --force to overwrite." + ); + process.exit(1); + } + } + + const ca = generateCA(`pangolin-ssh-ca-${orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + + await db + .update(orgs) + .set({ + sshCaPrivateKey: encryptedPrivateKey, + sshCaPublicKey: ca.publicKeyOpenSSH + }) + .where(eq(orgs.orgId, orgId)); + + console.log("SSH CA keys generated and stored for org:", orgId); + console.log("\nPublic key (OpenSSH format):"); + console.log(ca.publicKeyOpenSSH); + process.exit(0); + } catch (error) { + console.error("Error generating org CA keys:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index d517064c..7605904e 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -8,6 +8,7 @@ import { clearExitNodes } from "./commands/clearExitNodes"; import { rotateServerSecret } from "./commands/rotateServerSecret"; import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { deleteClient } from "./commands/deleteClient"; +import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -17,5 +18,6 @@ yargs(hideBin(process.argv)) .command(rotateServerSecret) .command(clearLicenseKeys) .command(deleteClient) + .command(generateOrgCaKeys) .demandCommand() .help().argv; diff --git a/messages/en-US.json b/messages/en-US.json index b7341839..f12e2210 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1250,6 +1250,7 @@ "sidebarClientResources": "Private", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", + "sidebarTeam": "Team", "sidebarUsers": "Users", "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", @@ -1290,8 +1291,7 @@ "contents": "Contents", "parsedContents": "Parsed Contents (Read Only)", "enableDockerSocket": "Enable Docker Blueprint", - "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.", - "enableDockerSocketLink": "Learn More", + "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in the documentation.", "viewDockerContainers": "View Docker Containers", "containersIn": "Containers in {siteName}", "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", @@ -2008,8 +2008,8 @@ "orgAuthNoAccount": "Don't have an account?", "subscriptionRequiredToUse": "A subscription is required to use this feature.", "mustUpgradeToUse": "You must upgrade your subscription to use this feature.", - "subscriptionRequiredTierToUse": "This feature requires {tier} or higher.", - "upgradeToTierToUse": "Upgrade to {tier} or higher to use this feature.", + "subscriptionRequiredTierToUse": "This feature requires {tier}.", + "upgradeToTierToUse": "Upgrade to {tier} to use this feature.", "subscriptionTierTier1": "Home", "subscriptionTierTier2": "Team", "subscriptionTierTier3": "Business", @@ -2325,7 +2325,7 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "licenseRequiredToUse": "An Enterprise Edition license is required to use this feature. This feature is also available in Pangolin Cloud.", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature.", "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", @@ -2523,6 +2523,17 @@ "editInternalResourceDialogAccessControl": "Access Control", "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.", + "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 the documentation 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?", "learnMore": "Learn more", "backToHome": "Go back to home", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4b628675..252ef284 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -232,7 +232,11 @@ export const siteResources = pgTable("siteResources", { aliasAddress: varchar("aliasAddress"), tcpPortRangeString: varchar("tcpPortRangeString").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", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1bef04b3..42e568f9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -257,7 +257,9 @@ export const siteResources = sqliteTable("siteResources", { udpPortRangeString: text("udpPortRangeString").notNull().default("*"), disableIcmp: integer("disableIcmp", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + authDaemonPort: integer("authDaemonPort"), + authDaemonMode: text("authDaemonMode").$type<"site" | "remote">() }); export const clientSiteResources = sqliteTable("clientSiteResources", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c08bcea7..20f8001d 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -48,5 +48,5 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] + [TierFeature.SshPam]: ["enterprise"] }; diff --git a/server/private/lib/sshCA.ts b/server/private/lib/sshCA.ts index 145dac61..6c9d1209 100644 --- a/server/private/lib/sshCA.ts +++ b/server/private/lib/sshCA.ts @@ -61,7 +61,10 @@ function encodeUInt64(value: bigint): Buffer { * Decode a string from SSH wire format at the given offset * Returns the string buffer and the new offset */ -function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } { +function decodeString( + data: Buffer, + offset: number +): { value: Buffer; newOffset: number } { const len = data.readUInt32BE(offset); const value = data.subarray(offset + 4, offset + 4 + len); return { value, newOffset: offset + 4 + len }; @@ -91,7 +94,9 @@ function parseOpenSSHPublicKey(pubKeyLine: string): { // Verify the key type in the blob matches const { value: blobKeyType } = decodeString(keyData, 0); if (blobKeyType.toString("utf8") !== keyType) { - throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`); + throw new Error( + `Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}` + ); } return { keyType, keyData, comment }; @@ -238,7 +243,7 @@ export interface SignedCertificate { * @param comment - Optional comment for the CA public key * @returns CA key pair and configuration info */ -export function generateCA(comment: string = "ssh-ca"): CAKeyPair { +export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair { // Generate Ed25519 key pair const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", { publicKeyEncoding: { type: "spki", format: "pem" }, @@ -269,7 +274,7 @@ export function generateCA(comment: string = "ssh-ca"): CAKeyPair { /** * Get and decrypt the SSH CA keys for an organization. - * + * * @param orgId - Organization ID * @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config) * @returns CA key pair or null if not found @@ -307,7 +312,10 @@ export async function getOrgCAKeys( key: privateKeyPem, format: "pem" }); - const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string; + const publicKeyPem = pubKeyObj.export({ + type: "spki", + format: "pem" + }) as string; return { privateKeyPem, @@ -365,8 +373,8 @@ export function signPublicKey( const serial = options.serial ?? BigInt(Date.now()); const certType = options.certType ?? 1; // 1 = user cert const now = BigInt(Math.floor(Date.now() / 1000)); - const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago - const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now + const validAfter = options.validAfter ?? now - 60n; // 1 minute ago + const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now // Default extensions for user certificates const defaultExtensions = [ @@ -422,10 +430,7 @@ export function signPublicKey( ]); // Build complete certificate - const certificate = Buffer.concat([ - certBody, - encodeString(signatureBlob) - ]); + const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]); // Format as OpenSSH certificate line const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index af7114a2..9536a87f 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -25,7 +25,8 @@ import { loginPageOrg, orgs, resources, - roles + roles, + siteResources } from "@server/db"; import { eq } from "drizzle-orm"; @@ -320,17 +321,9 @@ async function disableDeviceApprovals(orgId: string): Promise { } async function disableSshPam(orgId: string): Promise { - await db - .update(roles) - .set({ - sshSudoMode: "none", - sshSudoCommands: "[]", - sshCreateHomeDir: false, - sshUnixGroups: "[]" - }) - .where(eq(roles.orgId, orgId)); - - logger.info(`Disabled SSH PAM options on all roles for org ${orgId}`); + logger.info( + `Disabled SSH PAM options on all roles and site resources for org ${orgId}` + ); } async function disableLoginPageBranding(orgId: string): Promise { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 17132c44..a1352342 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -514,7 +514,7 @@ authenticated.post( verifyValidSubscription(tierMatrix.sshPam), verifyOrgAccess, verifyLimits, - // verifyUserHasAction(ActionsEnum.signSshKey), + verifyUserHasAction(ActionsEnum.signSshKey), logActionAudit(ActionsEnum.signSshKey), ssh.signSshKey ); diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 41593e9f..fbdee72d 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -22,7 +22,7 @@ import { sites, userOrgs } from "@server/db"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -35,8 +35,6 @@ import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResourc import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; import config from "@server/lib/config"; import { sendToClient } from "#private/routers/ws"; -import { groups } from "d3"; -import { homedir } from "os"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -402,7 +400,8 @@ export async function signSshKey( data: { messageId: message.messageId, orgId: orgId, - agentPort: 22123, + agentPort: resource.authDaemonPort ?? 22123, + externalAuthDaemon: resource.authDaemonMode === "remote", agentHost: resource.destination, caCert: caKeys.publicKeyOpenSSH, username: usernameToUse, diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 59aa86d2..729cf211 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -181,7 +181,10 @@ export async function createOrg( } if (build == "saas" && billingOrgIdForNewOrg) { - const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS); + const usage = await usageService.getUsage( + billingOrgIdForNewOrg, + FeatureId.ORGINIZATIONS + ); if (!usage) { return next( createHttpError( @@ -218,11 +221,6 @@ export async function createOrg( .from(domains) .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 = build === "saas" && req.user && isFirstOrg !== null ? isFirstOrg @@ -233,6 +231,19 @@ export async function createOrg( } : {}; + const encryptionKey = config.getRawConfig().server.secret; + let sshCaFields: { + sshCaPrivateKey?: string; + sshCaPublicKey?: string; + } = {}; + if (encryptionKey) { + const ca = generateCA(`pangolin-ssh-ca-${orgId}`); + sshCaFields = { + sshCaPrivateKey: encrypt(ca.privateKeyPem, encryptionKey), + sshCaPublicKey: ca.publicKeyOpenSSH + }; + } + const newOrg = await trx .insert(orgs) .values({ @@ -241,8 +252,7 @@ export async function createOrg( subnet, utilitySubnet, createdAt: new Date().toISOString(), - // sshCaPrivateKey: encryptedCaPrivateKey, - // sshCaPublicKey: ca.publicKeyOpenSSH, + ...sshCaFields, ...saasBillingFields }) .returning(); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 48c298d3..bbdc3638 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -16,6 +16,8 @@ import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -53,7 +55,9 @@ const createSiteResourceSchema = z clientIds: z.array(z.int()), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, - disableIcmp: z.boolean().optional() + disableIcmp: z.boolean().optional(), + authDaemonPort: z.int().positive().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( @@ -168,7 +172,9 @@ export async function createSiteResource( clientIds, tcpPortRangeString, udpPortRangeString, - disableIcmp + disableIcmp, + authDaemonPort, + authDaemonMode } = parsedBody.data; // Verify the site exists and belongs to the org @@ -267,6 +273,11 @@ export async function createSiteResource( } } + const isLicensedSshPam = await isLicensedOrSubscribed( + orgId, + tierMatrix.sshPam + ); + const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; if (mode == "host") { @@ -277,25 +288,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // 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 .insert(siteResources) - .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 - }) + .values(insertValues) .returning(); const siteResourceId = newSiteResource.siteResourceId; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index ead1fc8a..5aec53c7 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -78,6 +78,8 @@ function querySiteResourcesBase() { tcpPortRangeString: siteResources.tcpPortRangeString, udpPortRangeString: siteResources.udpPortRangeString, disableIcmp: siteResources.disableIcmp, + authDaemonMode: siteResources.authDaemonMode, + authDaemonPort: siteResources.authDaemonPort, siteName: sites.name, siteNiceId: sites.niceId, siteAddress: sites.address diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 4c19bea1..242b9226 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -32,6 +32,8 @@ import { getClientSiteResourceAccess, rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const updateSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -61,7 +63,9 @@ const updateSiteResourceSchema = z clientIds: z.array(z.int()), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, - disableIcmp: z.boolean().optional() + disableIcmp: z.boolean().optional(), + authDaemonPort: z.int().positive().nullish(), + authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( @@ -172,7 +176,9 @@ export async function updateSiteResource( clientIds, tcpPortRangeString, udpPortRangeString, - disableIcmp + disableIcmp, + authDaemonPort, + authDaemonMode } = parsedBody.data; const [site] = await db @@ -198,6 +204,11 @@ export async function updateSiteResource( ); } + const isLicensedSshPam = await isLicensedOrSubscribed( + existingSiteResource.orgId, + tierMatrix.sshPam + ); + const [org] = await db .select() .from(orgs) @@ -308,6 +319,18 @@ export async function updateSiteResource( // wait some time to allow for messages to be handled await new Promise((resolve) => setTimeout(resolve, 750)); + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || authDaemonMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }) + } + : {}; [updatedSiteResource] = await trx .update(siteResources) .set({ @@ -319,7 +342,8 @@ export async function updateSiteResource( alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, - disableIcmp: disableIcmp + disableIcmp: disableIcmp, + ...sshPamSet }) .where( and( @@ -397,6 +421,18 @@ export async function updateSiteResource( ); } else { // Update the site resource + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || authDaemonMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }) + } + : {}; [updatedSiteResource] = await trx .update(siteResources) .set({ @@ -408,7 +444,8 @@ export async function updateSiteResource( alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, - disableIcmp: disableIcmp + disableIcmp: disableIcmp, + ...sshPamSet }) .where( and(eq(siteResources.siteResourceId, siteResourceId)) diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts index 969053bf..1e8ca4fd 100644 --- a/server/setup/scriptsSqlite/1.16.0.ts +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -14,6 +14,7 @@ export default async function migration() { // all roles set hoemdir to true // generate ca certs for all orgs? + // set authDaemonMode to "site" for all orgs try { db.transaction(() => {})(); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index f5e1a701..f0f582f0 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -74,7 +74,9 @@ export default async function ClientResourcesPage( niceId: siteResource.niceId, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, - disableIcmp: siteResource.disableIcmp || false + disableIcmp: siteResource.disableIcmp || false, + authDaemonMode: siteResource.authDaemonMode ?? null, + authDaemonPort: siteResource.authDaemonPort ?? null }; } ); diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index d536e78e..71dc32e7 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; +import { ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; -import Link from "next/link"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -187,21 +187,22 @@ export default function GeneralPage() { - {t( - "enableDockerSocketDescription" - )}{" "} - - - {t( - "enableDockerSocketLink" - )} - - + {t.rich( + "enableDockerSocketDescription", + { + docsLink: (chunks) => ( + + {chunks} + + + ) + } + )} )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0844eb62..aeb9dfc1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -125,9 +125,9 @@ export default async function RootLayout({ - {process.env.NODE_ENV === "development" && ( + {/*process.env.NODE_ENV === "development" && ( - )} + )*/} ); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 162d9966..915e5f04 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -110,14 +110,19 @@ export const orgNavSections = ( heading: "access", items: [ { - title: "sidebarUsers", - icon: , + title: "sidebarTeam", + icon: , items: [ { title: "sidebarUsers", href: "/{orgId}/settings/access/users", icon: }, + { + title: "sidebarRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, { title: "sidebarInvitations", href: "/{orgId}/settings/access/invitations", @@ -125,11 +130,6 @@ export const orgNavSections = ( } ] }, - { - title: "sidebarRoles", - href: "/{orgId}/settings/access/roles", - icon: - }, // PaidFeaturesAlert ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || build === "saas" || diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 126eb242..68c72b9e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -51,6 +51,8 @@ export type InternalResourceRow = { tcpPortRangeString: string | null; udpPortRangeString: string | null; disableIcmp: boolean; + authDaemonMode?: "site" | "remote" | null; + authDaemonPort?: number | null; }; type ClientResourcesTableProps = { diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 25e5a721..d5ca61ac 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -10,141 +10,20 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { cn } from "@app/lib/cn"; -import { orgQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; import { ListSitesResponse } from "@server/routers/site"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; -import { Check, ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; -// import { InfoPopup } from "@app/components/ui/info-popup"; - -// Helper to validate port range string format -const isValidPortRangeString = (val: string | undefined | null): boolean => { - if (!val || val.trim() === "" || val.trim() === "*") { - return true; - } - - const parts = val.split(",").map((p) => p.trim()); - - for (const part of parts) { - if (part === "") { - return false; - } - - if (part.includes("-")) { - const [start, end] = part.split("-").map((p) => p.trim()); - if (!start || !end) { - return false; - } - - const startPort = parseInt(start, 10); - const endPort = parseInt(end, 10); - - if (isNaN(startPort) || isNaN(endPort)) { - return false; - } - - if ( - startPort < 1 || - startPort > 65535 || - endPort < 1 || - endPort > 65535 - ) { - return false; - } - - if (startPort > endPort) { - return false; - } - } else { - const port = parseInt(part, 10); - if (isNaN(port)) { - return false; - } - if (port < 1 || port > 65535) { - return false; - } - } - } - - return true; -}; - -// Port range string schema for client-side validation -// Note: This schema is defined outside the component, so we'll use a function to get the message -const getPortRangeValidationMessage = (t: (key: string) => string) => - t("editInternalResourceDialogPortRangeValidationError"); - -const createPortRangeStringSchema = (t: (key: string) => string) => - z - .string() - .optional() - .nullable() - .refine((val) => isValidPortRangeString(val), { - message: getPortRangeValidationMessage(t) - }); - -// Helper to determine the port mode from a port range string -type PortMode = "all" | "blocked" | "custom"; -const getPortModeFromString = (val: string | undefined | null): PortMode => { - if (val === "*") return "all"; - if (!val || val.trim() === "") return "blocked"; - return "custom"; -}; - -// Helper to get the port string for API from mode and custom value -const getPortStringFromMode = ( - mode: PortMode, - customValue: string -): string | undefined => { - if (mode === "all") return "*"; - if (mode === "blocked") return ""; - return customValue; -}; +import { useState } from "react"; +import { + cleanForFQDN, + InternalResourceForm, + isHostname, + type InternalResourceFormValues +} from "./InternalResourceForm"; type Site = ListSitesResponse["sites"][0]; @@ -167,1112 +46,84 @@ export default function CreateInternalResourceDialog({ const api = createApiClient(useEnvContext()); const [isSubmitting, setIsSubmitting] = useState(false); - const formSchema = z.object({ - name: z - .string() - .min(1, t("createInternalResourceDialogNameRequired")) - .max(255, t("createInternalResourceDialogNameMaxLength")), - siteId: z - .int() - .positive(t("createInternalResourceDialogPleaseSelectSite")), - // mode: z.enum(["host", "cidr", "port"]), - mode: z.enum(["host", "cidr"]), - // protocol: z.enum(["tcp", "udp"]).nullish(), - // proxyPort: z.int().positive().min(1, t("createInternalResourceDialogProxyPortMin")).max(65535, t("createInternalResourceDialogProxyPortMax")).nullish(), - destination: z.string().min(1, { - message: t("createInternalResourceDialogDestinationRequired") - }), - // destinationPort: z.int().positive().min(1, t("createInternalResourceDialogDestinationPortMin")).max(65535, t("createInternalResourceDialogDestinationPortMax")).nullish(), - alias: z.string().nullish(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - roles: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional(), - users: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional(), - clients: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional() - }); - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.protocol !== undefined && data.protocol !== null; - // } - // return true; - // }, - // { - // error: t("createInternalResourceDialogProtocol") + " is required for port mode", - // path: ["protocol"] - // } - // ) - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.proxyPort !== undefined && data.proxyPort !== null; - // } - // return true; - // }, - // { - // error: t("createInternalResourceDialogSitePort") + " is required for port mode", - // path: ["proxyPort"] - // } - // ) - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.destinationPort !== undefined && data.destinationPort !== null; - // } - // return true; - // }, - // { - // error: t("targetPort") + " is required for port mode", - // path: ["destinationPort"] - // } - // ); - - type FormData = z.infer; - - const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId })); - const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); - const { data: clientsResponse = [] } = useQuery( - orgQueries.clients({ - orgId - }) - ); - - const allRoles = rolesResponse - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"); - - const allUsers = usersResponse.map((user) => ({ - id: user.id.toString(), - text: `${getUserDisplayName({ - email: user.email, - username: user.username - })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })); - - const allClients = clientsResponse - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); - - const hasMachineClients = allClients.length > 0; - - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - - // Port restriction UI state - default to "all" (*) for new resources - const [tcpPortMode, setTcpPortMode] = useState("all"); - const [udpPortMode, setUdpPortMode] = useState("all"); - const [tcpCustomPorts, setTcpCustomPorts] = useState(""); - const [udpCustomPorts, setUdpCustomPorts] = useState(""); - - const availableSites = sites.filter( - (site) => site.type === "newt" - ); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - name: "", - siteId: availableSites[0]?.siteId || 0, - mode: "host", - // protocol: "tcp", - // proxyPort: undefined, - destination: "", - // destinationPort: undefined, - alias: "", - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - roles: [], - users: [], - clients: [] - } - }); - - const mode = form.watch("mode"); - - // Update form values when port mode or custom ports change - useEffect(() => { - const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); - form.setValue("tcpPortRangeString", tcpValue); - }, [tcpPortMode, tcpCustomPorts, form]); - - useEffect(() => { - const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); - form.setValue("udpPortRangeString", udpValue); - }, [udpPortMode, udpCustomPorts, form]); - - // Helper function to check if destination contains letters (hostname vs IP) - const isHostname = (destination: string): boolean => { - return /[a-zA-Z]/.test(destination); - }; - - // Helper function to clean resource name for FQDN format - const cleanForFQDN = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens - .replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen - .replace(/^-|-$/g, "") // Remove leading/trailing hyphens - .replace(/^\.|\.$/g, ""); // Remove leading/trailing dots - }; - - useEffect(() => { - if (open) { - form.reset({ - name: "", - siteId: availableSites[0]?.siteId || 0, - mode: "host", - // protocol: "tcp", - // proxyPort: undefined, - destination: "", - // destinationPort: undefined, - alias: "", - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - roles: [], - users: [], - clients: [] - }); - // Reset port mode state - setTcpPortMode("all"); - setUdpPortMode("all"); - setTcpCustomPorts(""); - setUdpCustomPorts(""); - } - }, [open]); - - const handleSubmit = async (data: FormData) => { + async function handleSubmit(values: InternalResourceFormValues) { setIsSubmitting(true); try { - // Validate: if mode is "host" and destination is a hostname (contains letters), - // an alias is required + let data = { ...values }; if (data.mode === "host" && isHostname(data.destination)) { const currentAlias = data.alias?.trim() || ""; - if (!currentAlias) { - // Prefill alias based on destination let aliasValue = data.destination; if (data.destination.toLowerCase() === "localhost") { - // Use resource name cleaned for FQDN with .internal suffix - const cleanedName = cleanForFQDN(data.name); - aliasValue = `${cleanedName}.internal`; + aliasValue = `${cleanForFQDN(data.name)}.internal`; } - - // Update the form with the prefilled alias - form.setValue("alias", aliasValue); - data.alias = aliasValue; + data = { ...data, alias: aliasValue }; } } - const response = await api.put>( + await api.put>( `/org/${orgId}/site-resource`, { name: data.name, siteId: data.siteId, mode: data.mode, - // protocol: data.protocol, - // proxyPort: data.mode === "port" ? data.proxyPort : undefined, - // destinationPort: data.mode === "port" ? data.destinationPort : undefined, destination: data.destination, enabled: true, - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : undefined, + alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined, tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, disableIcmp: data.disableIcmp ?? false, - roleIds: data.roles - ? data.roles.map((r) => parseInt(r.id)) - : [], + ...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }), + ...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }), + roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [], userIds: data.users ? data.users.map((u) => u.id) : [], - clientIds: data.clients - ? data.clients.map((c) => parseInt(c.id)) - : [] + clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] } ); - const siteResourceId = response.data.data.siteResourceId; - - // // Set roles and users if provided - // if (data.roles && data.roles.length > 0) { - // await api.post(`/site-resource/${siteResourceId}/roles`, { - // roleIds: data.roles.map((r) => parseInt(r.id)) - // }); - // } - - // if (data.users && data.users.length > 0) { - // await api.post(`/site-resource/${siteResourceId}/users`, { - // userIds: data.users.map((u) => u.id) - // }); - // } - - // if (data.clients && data.clients.length > 0) { - // await api.post(`/site-resource/${siteResourceId}/clients`, { - // clientIds: data.clients.map((c) => parseInt(c.id)) - // }); - // } - toast({ title: t("createInternalResourceDialogSuccess"), - description: t( - "createInternalResourceDialogInternalResourceCreatedSuccessfully" - ), + description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), variant: "default" }); - setOpen(false); onSuccess?.(); } catch (error) { - console.error("Error creating internal resource:", error); toast({ title: t("createInternalResourceDialogError"), description: formatAxiosError( error, - t( - "createInternalResourceDialogFailedToCreateInternalResource" - ) + t("createInternalResourceDialogFailedToCreateInternalResource") ), variant: "destructive" }); } finally { setIsSubmitting(false); } - }; + } return ( - - {t("createInternalResourceDialogCreateClientResource")} - + {t("createInternalResourceDialogCreateClientResource")} - {t( - "createInternalResourceDialogCreateClientResourceDescription" - )} + {t("createInternalResourceDialogCreateClientResourceDescription")} -
- - {/* Name and Site - Side by Side */} -
- ( - - - {t( - "createInternalResourceDialogName" - )} - - - - - - - )} - /> - - ( - - {t("site")} - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - (site) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> -
- - {/* Tabs for Network Settings and Access Control */} - - {/* Network Settings Tab */} -
-
-
- -
- {t( - "editInternalResourceDialogDestinationDescription" - )} -
-
- -
- {/* Mode - Smaller select */} -
- ( - - - {t( - "createInternalResourceDialogMode" - )} - - - - - )} - /> -
- - {/* Destination - Larger input */} -
- ( - - - {t( - "createInternalResourceDialogDestination" - )} - - - - - - - )} - /> -
- - {/* Alias - Equally sized input (if allowed) */} - {mode !== "cidr" && ( -
- ( - - - {t( - "createInternalResourceDialogAlias" - )} - - - - - - - )} - /> -
- )} -
-
- - {/* Ports and Restrictions */} -
- {/* TCP Ports */} -
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" - )} -
-
-
-
- - {t( - "editInternalResourceDialogTcp" - )} - -
-
- ( - -
- {/**/} - - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
- - {/* UDP Ports */} -
-
- - {t( - "editInternalResourceDialogUdp" - )} - -
-
- ( - -
- {/**/} - - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
- - {/* ICMP Toggle */} -
-
- - {t( - "editInternalResourceDialogIcmp" - )} - -
-
- ( - -
- - - field.onChange( - !checked - ) - } - /> - - - {field.value - ? t( - "blocked" - ) - : t( - "allowed" - )} - -
- -
- )} - /> -
-
-
-
- - {/* Access Control Tab */} -
-
- -
- {t( - "editInternalResourceDialogAccessControlDescription" - )} -
-
-
- {/* Roles */} - ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - {/* Users */} - ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - {/* Clients (Machines) */} - {hasMachineClients && ( - ( - - - {t( - "machineClients" - )} - - - { - form.setValue( - "clients", - newClients as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allClients - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - )} -
-
-
-
- +
- diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index d6078052..866aebe3 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -1,28 +1,5 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { Button } from "@app/components/ui/button"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; import { Credenza, CredenzaBody, @@ -33,144 +10,25 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { useTranslations } from "next-intl"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { UserType } from "@server/types/UserTypes"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { cn } from "@app/lib/cn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { resourceQueries } from "@app/lib/queries"; import { ListSitesResponse } from "@server/routers/site"; -import { Check, ChevronsUpDown, ChevronDown } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger -} from "@app/components/ui/collapsible"; -import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; -import { Separator } from "@app/components/ui/separator"; -// import { InfoPopup } from "@app/components/ui/info-popup"; - -// Helper to validate port range string format -const isValidPortRangeString = (val: string | undefined | null): boolean => { - if (!val || val.trim() === "" || val.trim() === "*") { - return true; - } - - const parts = val.split(",").map((p) => p.trim()); - - for (const part of parts) { - if (part === "") { - return false; - } - - if (part.includes("-")) { - const [start, end] = part.split("-").map((p) => p.trim()); - if (!start || !end) { - return false; - } - - const startPort = parseInt(start, 10); - const endPort = parseInt(end, 10); - - if (isNaN(startPort) || isNaN(endPort)) { - return false; - } - - if ( - startPort < 1 || - startPort > 65535 || - endPort < 1 || - endPort > 65535 - ) { - return false; - } - - if (startPort > endPort) { - return false; - } - } else { - const port = parseInt(part, 10); - if (isNaN(port)) { - return false; - } - if (port < 1 || port > 65535) { - return false; - } - } - } - - return true; -}; - -// Port range string schema for client-side validation -// Note: This schema is defined outside the component, so we'll use a function to get the message -const getPortRangeValidationMessage = (t: (key: string) => string) => - t("editInternalResourceDialogPortRangeValidationError"); - -const createPortRangeStringSchema = (t: (key: string) => string) => - z - .string() - .optional() - .nullable() - .refine((val) => isValidPortRangeString(val), { - message: getPortRangeValidationMessage(t) - }); - -// Helper to determine the port mode from a port range string -type PortMode = "all" | "blocked" | "custom"; -const getPortModeFromString = (val: string | undefined | null): PortMode => { - if (val === "*") return "all"; - if (!val || val.trim() === "") return "blocked"; - return "custom"; -}; - -// Helper to get the port string for API from mode and custom value -const getPortStringFromMode = ( - mode: PortMode, - customValue: string -): string | undefined => { - if (mode === "all") return "*"; - if (mode === "blocked") return ""; - return customValue; -}; + cleanForFQDN, + InternalResourceForm, + type InternalResourceData, + type InternalResourceFormValues, + isHostname +} from "./InternalResourceForm"; type Site = ListSitesResponse["sites"][0]; -type InternalResourceData = { - id: number; - name: string; - orgId: string; - siteName: string; - // mode: "host" | "cidr" | "port"; - mode: "host" | "cidr"; - // protocol: string | null; - // proxyPort: number | null; - siteId: number; - destination: string; - // destinationPort?: number | null; - alias?: string | null; - tcpPortRangeString?: string | null; - udpPortRangeString?: string | null; - disableIcmp?: boolean; -}; - type EditInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; @@ -193,289 +51,25 @@ export default function EditInternalResourceDialog({ const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); - const formSchema = z.object({ - name: z - .string() - .min(1, t("editInternalResourceDialogNameRequired")) - .max(255, t("editInternalResourceDialogNameMaxLength")), - siteId: z.number().int().positive(), - mode: z.enum(["host", "cidr", "port"]), - // protocol: z.enum(["tcp", "udp"]).nullish(), - // proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(), - destination: z.string().min(1), - // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), - alias: z.string().nullish(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - roles: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional(), - users: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional(), - clients: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .optional() - }); - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.protocol !== undefined && data.protocol !== null; - // } - // return true; - // }, - // { - // message: t("editInternalResourceDialogProtocol") + " is required for port mode", - // path: ["protocol"] - // } - // ) - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.proxyPort !== undefined && data.proxyPort !== null; - // } - // return true; - // }, - // { - // message: t("editInternalResourceDialogSitePort") + " is required for port mode", - // path: ["proxyPort"] - // } - // ) - // .refine( - // (data) => { - // if (data.mode === "port") { - // return data.destinationPort !== undefined && data.destinationPort !== null; - // } - // return true; - // }, - // { - // message: t("targetPort") + " is required for port mode", - // path: ["destinationPort"] - // } - // ); - - type FormData = z.infer; - - const queries = useQueries({ - queries: [ - orgQueries.roles({ orgId }), - orgQueries.users({ orgId }), - orgQueries.clients({ - orgId - }), - resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), - resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), - resourceQueries.siteResourceClients({ siteResourceId: resource.id }) - ], - combine: (results) => { - const [ - rolesQuery, - usersQuery, - clientsQuery, - resourceUsersQuery, - resourceRolesQuery, - resourceClientsQuery - ] = results; - - const allRoles = (rolesQuery.data ?? []) - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"); - - const allUsers = (usersQuery.data ?? []).map((user) => ({ - id: user.id.toString(), - text: `${getUserDisplayName({ - email: user.email, - username: user.username - })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })); - - const machineClients = (clientsQuery.data ?? []) - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); - - const existingClients = (resourceClientsQuery.data ?? []).map( - (c: { clientId: number; name: string }) => ({ - id: c.clientId.toString(), - text: c.name - }) - ); - - const formRoles = (resourceRolesQuery.data ?? []) - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin"); - - const formUsers = (resourceUsersQuery.data ?? []).map((i) => ({ - id: i.userId.toString(), - text: `${getUserDisplayName({ - email: i.email, - username: i.username - })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })); - - return { - allRoles, - allUsers, - machineClients, - existingClients, - formRoles, - formUsers, - hasMachineClients: - machineClients.length > 0 || existingClients.length > 0, - isLoading: results.some((query) => query.isLoading) - }; - } - }); - - const { - allRoles, - allUsers, - machineClients, - existingClients, - formRoles, - formUsers, - hasMachineClients, - isLoading: loadingRolesUsers - } = queries; - - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - - // Collapsible state for ports and restrictions - const [isPortsExpanded, setIsPortsExpanded] = useState(false); - - // Port restriction UI state - const [tcpPortMode, setTcpPortMode] = useState( - getPortModeFromString(resource.tcpPortRangeString) - ); - const [udpPortMode, setUdpPortMode] = useState( - getPortModeFromString(resource.udpPortRangeString) - ); - const [tcpCustomPorts, setTcpCustomPorts] = useState( - resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - const [udpCustomPorts, setUdpCustomPorts] = useState( - resource.udpPortRangeString && resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - - const availableSites = sites.filter( - (site) => site.type === "newt" - ); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - name: resource.name, - siteId: resource.siteId, - mode: resource.mode || "host", - // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, - // proxyPort: resource.proxyPort ?? undefined, - destination: resource.destination || "", - // destinationPort: resource.destinationPort ?? undefined, - alias: resource.alias ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - roles: [], - users: [], - clients: [] - } - }); - - const mode = form.watch("mode"); - - // Update form values when port mode or custom ports change - useEffect(() => { - const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); - form.setValue("tcpPortRangeString", tcpValue); - }, [tcpPortMode, tcpCustomPorts, form]); - - useEffect(() => { - const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); - form.setValue("udpPortRangeString", udpValue); - }, [udpPortMode, udpCustomPorts, form]); - - // Helper function to check if destination contains letters (hostname vs IP) - const isHostname = (destination: string): boolean => { - return /[a-zA-Z]/.test(destination); - }; - - // Helper function to clean resource name for FQDN format - const cleanForFQDN = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens - .replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen - .replace(/^-|-$/g, "") // Remove leading/trailing hyphens - .replace(/^\.|\.$/g, ""); // Remove leading/trailing dots - }; - - const handleSubmit = async (data: FormData) => { + async function handleSubmit(values: InternalResourceFormValues) { setIsSubmitting(true); try { - // Validate: if mode is "host" and destination is a hostname (contains letters), - // an alias is required + let data = { ...values }; if (data.mode === "host" && isHostname(data.destination)) { const currentAlias = data.alias?.trim() || ""; - if (!currentAlias) { - // Prefill alias based on destination let aliasValue = data.destination; if (data.destination.toLowerCase() === "localhost") { - // Use resource name cleaned for FQDN with .internal suffix - const cleanedName = cleanForFQDN(data.name); - aliasValue = `${cleanedName}.internal`; + aliasValue = `${cleanForFQDN(data.name)}.internal`; } - - // Update the form with the prefilled alias - form.setValue("alias", aliasValue); - data.alias = aliasValue; + data = { ...data, alias: aliasValue }; } } - // Update the site resource await api.post(`/site-resource/${resource.id}`, { name: data.name, siteId: data.siteId, mode: data.mode, - // protocol: data.mode === "port" ? data.protocol : null, - // proxyPort: data.mode === "port" ? data.proxyPort : null, - // destinationPort: data.mode === "port" ? data.destinationPort : null, destination: data.destination, alias: data.alias && @@ -486,24 +80,17 @@ export default function EditInternalResourceDialog({ tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, disableIcmp: data.disableIcmp ?? false, + ...(data.authDaemonMode != null && { + authDaemonMode: data.authDaemonMode + }), + ...(data.authDaemonMode === "remote" && { + authDaemonPort: data.authDaemonPort || null + }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), clientIds: (data.clients || []).map((c) => parseInt(c.id)) }); - // Update roles, users, and clients - // await Promise.all([ - // api.post(`/site-resource/${resource.id}/roles`, { - // roleIds: (data.roles || []).map((r) => parseInt(r.id)) - // }), - // api.post(`/site-resource/${resource.id}/users`, { - // userIds: (data.users || []).map((u) => u.id) - // }), - // api.post(`/site-resource/${resource.id}/clients`, { - // clientIds: (data.clients || []).map((c) => parseInt(c.id)) - // }) - // ]); - await queryClient.invalidateQueries( resourceQueries.siteResourceRoles({ siteResourceId: resource.id @@ -527,11 +114,9 @@ export default function EditInternalResourceDialog({ ), variant: "default" }); - setOpen(false); onSuccess?.(); } catch (error) { - console.error("Error updating internal resource:", error); toast({ title: t("editInternalResourceDialogError"), description: formatAxiosError( @@ -545,121 +130,13 @@ export default function EditInternalResourceDialog({ } finally { setIsSubmitting(false); } - }; - - const hasInitialized = useRef(false); - const previousResourceId = useRef(null); - - useEffect(() => { - if (open) { - const resourceChanged = previousResourceId.current !== resource.id; - - if (resourceChanged) { - form.reset({ - name: resource.name, - siteId: resource.siteId, - mode: resource.mode || "host", - destination: resource.destination || "", - alias: resource.alias ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - roles: [], - users: [], - clients: [] - }); - // Reset port mode state - setTcpPortMode( - getPortModeFromString(resource.tcpPortRangeString) - ); - setUdpPortMode( - getPortModeFromString(resource.udpPortRangeString) - ); - setTcpCustomPorts( - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - setUdpCustomPorts( - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - // Reset visibility states - setIsPortsExpanded(false); - previousResourceId.current = resource.id; - } - - hasInitialized.current = false; - } - }, [ - open, - resource.id, - resource.name, - resource.mode, - resource.destination, - resource.alias, - form - ]); - - useEffect(() => { - if (open && !loadingRolesUsers && !hasInitialized.current) { - hasInitialized.current = true; - form.setValue("roles", formRoles); - form.setValue("users", formUsers); - form.setValue("clients", existingClients); - } - }, [open, loadingRolesUsers, formRoles, formUsers, existingClients, form]); + } return ( { - if (!open) { - // reset only on close - form.reset({ - name: resource.name, - siteId: resource.siteId, - mode: resource.mode || "host", - // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, - // proxyPort: resource.proxyPort ?? undefined, - destination: resource.destination || "", - // destinationPort: resource.destinationPort ?? undefined, - alias: resource.alias ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - roles: [], - users: [], - clients: [] - }); - // Reset port mode state - setTcpPortMode( - getPortModeFromString(resource.tcpPortRangeString) - ); - setUdpPortMode( - getPortModeFromString(resource.udpPortRangeString) - ); - setTcpCustomPorts( - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - setUdpCustomPorts( - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - // Reset visibility states - setIsPortsExpanded(false); - // Reset previous resource ID to ensure clean state on next open - previousResourceId.current = null; - } - setOpen(open); + onOpenChange={(isOpen) => { + if (!isOpen) setOpen(false); }} > @@ -670,794 +147,23 @@ export default function EditInternalResourceDialog({ {t( "editInternalResourceDialogUpdateResourceProperties", - { resourceName: resource.name } + { + resourceName: resource.name + } )} -
- - {/* Name and Site - Side by Side */} -
- ( - - - {t( - "editInternalResourceDialogName" - )} - - - - - - - )} - /> - - ( - - {t("site")} - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - (site) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> -
- - {/* Tabs for Network Settings and Access Control */} - - {/* Network Settings Tab */} -
-
-
- -
- {t( - "editInternalResourceDialogDestinationDescription" - )} -
-
- -
- {/* Mode - Smaller select */} -
- ( - - - {t( - "editInternalResourceDialogMode" - )} - - - - - )} - /> -
- - {/* Destination - Larger input */} -
- ( - - - {t( - "editInternalResourceDialogDestination" - )} - - - - - - - )} - /> -
- - {/* Alias - Equally sized input (if allowed) */} - {mode !== "cidr" && ( -
- ( - - - {t( - "editInternalResourceDialogAlias" - )} - - - - - - - )} - /> -
- )} -
-
- - {/* Ports and Restrictions */} -
- {/* TCP Ports */} -
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" - )} -
-
-
-
- - {t( - "editInternalResourceDialogTcp" - )} - -
-
- ( - -
- {/**/} - - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
- - {/* UDP Ports */} -
-
- - {t( - "editInternalResourceDialogUdp" - )} - -
-
- ( - -
- {/**/} - - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
- - {/* ICMP Toggle */} -
-
- - {t( - "editInternalResourceDialogIcmp" - )} - -
-
- ( - -
- - - field.onChange( - !checked - ) - } - /> - - - {field.value - ? t( - "blocked" - ) - : t( - "allowed" - )} - -
- -
- )} - /> -
-
-
-
- - {/* Access Control Tab */} -
-
- -
- {t( - "editInternalResourceDialogAccessControlDescription" - )} -
-
- {loadingRolesUsers ? ( -
- {t("loading")} -
- ) : ( -
- {/* Roles */} - ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - {/* Users */} - ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - {/* Clients (Machines) */} - {hasMachineClients && ( - ( - - - {t( - "machineClients" - )} - - - { - form.setValue( - "clients", - newClients as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - machineClients - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={ - true - } - /> - - - - )} - /> - )} -
- )} -
-
-
- +
diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx new file mode 100644 index 00000000..1ed85c59 --- /dev/null +++ b/src/components/InternalResourceForm.tsx @@ -0,0 +1,1328 @@ +"use client"; + +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { cn } from "@app/lib/cn"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { ListSitesResponse } from "@server/routers/site"; +import { UserType } from "@server/types/UserTypes"; +import { Check, ChevronsUpDown, ExternalLink } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { StrategySelect } from "@app/components/StrategySelect"; + +// --- Helpers (shared) --- + +const isValidPortRangeString = (val: string | undefined | null): boolean => { + if (!val || val.trim() === "" || val.trim() === "*") return true; + const parts = val.split(",").map((p) => p.trim()); + for (const part of parts) { + if (part === "") return false; + if (part.includes("-")) { + const [start, end] = part.split("-").map((p) => p.trim()); + if (!start || !end) return false; + const startPort = parseInt(start, 10); + const endPort = parseInt(end, 10); + if (isNaN(startPort) || isNaN(endPort)) return false; + if ( + startPort < 1 || + startPort > 65535 || + endPort < 1 || + endPort > 65535 + ) + return false; + if (startPort > endPort) return false; + } else { + const port = parseInt(part, 10); + if (isNaN(port) || port < 1 || port > 65535) return false; + } + } + return true; +}; + +const getPortRangeValidationMessage = (t: (key: string) => string) => + t("editInternalResourceDialogPortRangeValidationError"); + +const createPortRangeStringSchema = (t: (key: string) => string) => + z + .string() + .optional() + .nullable() + .refine((val) => isValidPortRangeString(val), { + message: getPortRangeValidationMessage(t) + }); + +export type PortMode = "all" | "blocked" | "custom"; +export const getPortModeFromString = ( + val: string | undefined | null +): PortMode => { + if (val === "*") return "all"; + if (!val || val.trim() === "") return "blocked"; + return "custom"; +}; + +export const getPortStringFromMode = ( + mode: PortMode, + customValue: string +): string | undefined => { + if (mode === "all") return "*"; + if (mode === "blocked") return ""; + return customValue; +}; + +export const isHostname = (destination: string): boolean => + /[a-zA-Z]/.test(destination); + +export const cleanForFQDN = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9.-]/g, "-") + .replace(/[-]+/g, "-") + .replace(/^-|-$/g, "") + .replace(/^\.|\.$/g, ""); + +// --- Types --- + +type Site = ListSitesResponse["sites"][0]; + +export type InternalResourceData = { + id: number; + name: string; + orgId: string; + siteName: string; + mode: "host" | "cidr"; + siteId: number; + destination: string; + alias?: string | null; + tcpPortRangeString?: string | null; + udpPortRangeString?: string | null; + disableIcmp?: boolean; + authDaemonMode?: "site" | "remote" | null; + authDaemonPort?: number | null; +}; + +const tagSchema = z.object({ id: z.string(), text: z.string() }); + +export type InternalResourceFormValues = { + name: string; + siteId: number; + mode: "host" | "cidr"; + destination: string; + alias?: string | null; + tcpPortRangeString?: string | null; + udpPortRangeString?: string | null; + disableIcmp?: boolean; + authDaemonMode?: "site" | "remote" | null; + authDaemonPort?: number | null; + roles?: z.infer[]; + users?: z.infer[]; + clients?: z.infer[]; +}; + +type InternalResourceFormProps = { + variant: "create" | "edit"; + resource?: InternalResourceData; + open?: boolean; + sites: Site[]; + orgId: string; + siteResourceId?: number; + formId: string; + onSubmit: (values: InternalResourceFormValues) => void | Promise; +}; + +export function InternalResourceForm({ + variant, + resource, + open, + sites, + orgId, + siteResourceId, + formId, + onSubmit +}: InternalResourceFormProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); + const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; + const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); + + const nameRequiredKey = + variant === "create" + ? "createInternalResourceDialogNameRequired" + : "editInternalResourceDialogNameRequired"; + const nameMaxKey = + variant === "create" + ? "createInternalResourceDialogNameMaxLength" + : "editInternalResourceDialogNameMaxLength"; + const siteRequiredKey = + variant === "create" + ? "createInternalResourceDialogPleaseSelectSite" + : undefined; + const nameLabelKey = + variant === "create" + ? "createInternalResourceDialogName" + : "editInternalResourceDialogName"; + const modeLabelKey = + variant === "create" + ? "createInternalResourceDialogMode" + : "editInternalResourceDialogMode"; + const modeHostKey = + variant === "create" + ? "createInternalResourceDialogModeHost" + : "editInternalResourceDialogModeHost"; + const modeCidrKey = + variant === "create" + ? "createInternalResourceDialogModeCidr" + : "editInternalResourceDialogModeCidr"; + const destinationLabelKey = + variant === "create" + ? "createInternalResourceDialogDestination" + : "editInternalResourceDialogDestination"; + const destinationRequiredKey = + variant === "create" + ? "createInternalResourceDialogDestinationRequired" + : undefined; + const aliasLabelKey = + variant === "create" + ? "createInternalResourceDialogAlias" + : "editInternalResourceDialogAlias"; + + const formSchema = z.object({ + name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), + siteId: z + .number() + .int() + .positive(siteRequiredKey ? t(siteRequiredKey) : undefined), + mode: z.enum(["host", "cidr"]), + destination: z + .string() + .min( + 1, + destinationRequiredKey + ? { message: t(destinationRequiredKey) } + : undefined + ), + alias: z.string().nullish(), + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), + disableIcmp: z.boolean().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional().nullable(), + authDaemonPort: z.number().int().positive().optional().nullable(), + roles: z.array(tagSchema).optional(), + users: z.array(tagSchema).optional(), + clients: z.array(tagSchema).optional() + }); + + type FormData = z.infer; + + const availableSites = sites.filter((s) => s.type === "newt"); + + const rolesQuery = useQuery(orgQueries.roles({ orgId })); + const usersQuery = useQuery(orgQueries.users({ orgId })); + const clientsQuery = useQuery(orgQueries.clients({ orgId })); + const resourceRolesQuery = useQuery({ + ...resourceQueries.siteResourceRoles({ + siteResourceId: siteResourceId ?? 0 + }), + enabled: siteResourceId != null + }); + const resourceUsersQuery = useQuery({ + ...resourceQueries.siteResourceUsers({ + siteResourceId: siteResourceId ?? 0 + }), + enabled: siteResourceId != null + }); + const resourceClientsQuery = useQuery({ + ...resourceQueries.siteResourceClients({ + siteResourceId: siteResourceId ?? 0 + }), + enabled: siteResourceId != null + }); + + const allRoles = (rolesQuery.data ?? []) + .map((r) => ({ id: r.roleId.toString(), text: r.name })) + .filter((r) => r.text !== "Admin"); + const allUsers = (usersQuery.data ?? []).map((u) => ({ + id: u.id.toString(), + text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })); + const allClients = (clientsQuery.data ?? []) + .filter((c) => !c.userId) + .map((c) => ({ id: c.clientId.toString(), text: c.name })); + + let formRoles: FormData["roles"] = []; + let formUsers: FormData["users"] = []; + let existingClients: FormData["clients"] = []; + if (siteResourceId != null) { + const rolesData = resourceRolesQuery.data; + const usersData = resourceUsersQuery.data; + const clientsData = resourceClientsQuery.data; + if (rolesData) { + formRoles = (rolesData as { roleId: number; name: string }[]) + .map((i) => ({ id: i.roleId.toString(), text: i.name })) + .filter((r) => r.text !== "Admin"); + } + if (usersData) { + formUsers = ( + usersData as { + userId: string; + email?: string; + username?: string; + type?: string; + idpName?: string; + }[] + ).map((i) => ({ + id: i.userId.toString(), + text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + })); + } + if (clientsData) { + existingClients = ( + clientsData as { clientId: number; name: string }[] + ).map((c) => ({ + id: c.clientId.toString(), + text: c.name + })); + } + } + + const loadingRolesUsers = + rolesQuery.isLoading || + usersQuery.isLoading || + clientsQuery.isLoading || + (siteResourceId != null && + (resourceRolesQuery.isLoading || + resourceUsersQuery.isLoading || + resourceClientsQuery.isLoading)); + + const hasMachineClients = allClients.length > 0; + + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< + number | null + >(null); + + const [tcpPortMode, setTcpPortMode] = useState(() => + variant === "edit" && resource + ? getPortModeFromString(resource.tcpPortRangeString) + : "all" + ); + const [udpPortMode, setUdpPortMode] = useState(() => + variant === "edit" && resource + ? getPortModeFromString(resource.udpPortRangeString) + : "all" + ); + const [tcpCustomPorts, setTcpCustomPorts] = useState(() => + variant === "edit" && + resource && + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" + ? resource.tcpPortRangeString + : "" + ); + const [udpCustomPorts, setUdpCustomPorts] = useState(() => + variant === "edit" && + resource && + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" + ? resource.udpPortRangeString + : "" + ); + + const defaultValues: FormData = + variant === "edit" && resource + ? { + name: resource.name, + siteId: resource.siteId, + mode: resource.mode ?? "host", + destination: resource.destination ?? "", + alias: resource.alias ?? null, + tcpPortRangeString: resource.tcpPortRangeString ?? "*", + udpPortRangeString: resource.udpPortRangeString ?? "*", + disableIcmp: resource.disableIcmp ?? false, + authDaemonMode: resource.authDaemonMode ?? null, + authDaemonPort: resource.authDaemonPort ?? null, + roles: [], + users: [], + clients: [] + } + : { + name: "", + siteId: availableSites[0]?.siteId ?? 0, + mode: "host", + destination: "", + alias: null, + tcpPortRangeString: "*", + udpPortRangeString: "*", + disableIcmp: false, + authDaemonMode: "site", + authDaemonPort: null, + roles: [], + users: [], + clients: [] + }; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues + }); + + const mode = form.watch("mode"); + const authDaemonMode = form.watch("authDaemonMode"); + const hasInitialized = useRef(false); + const previousResourceId = useRef(null); + + useEffect(() => { + const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); + form.setValue("tcpPortRangeString", tcpValue); + }, [tcpPortMode, tcpCustomPorts, form]); + + useEffect(() => { + const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); + form.setValue("udpPortRangeString", udpValue); + }, [udpPortMode, udpCustomPorts, form]); + + // Reset when create dialog opens + useEffect(() => { + if (variant === "create" && open) { + form.reset({ + name: "", + siteId: availableSites[0]?.siteId ?? 0, + mode: "host", + destination: "", + alias: null, + tcpPortRangeString: "*", + udpPortRangeString: "*", + disableIcmp: false, + authDaemonMode: "site", + authDaemonPort: null, + roles: [], + users: [], + clients: [] + }); + setTcpPortMode("all"); + setUdpPortMode("all"); + setTcpCustomPorts(""); + setUdpCustomPorts(""); + } + }, [variant, open]); + + // Reset when edit dialog opens / resource changes + useEffect(() => { + if (variant === "edit" && resource) { + const resourceChanged = previousResourceId.current !== resource.id; + if (resourceChanged) { + form.reset({ + name: resource.name, + siteId: resource.siteId, + mode: resource.mode ?? "host", + destination: resource.destination ?? "", + alias: resource.alias ?? null, + tcpPortRangeString: resource.tcpPortRangeString ?? "*", + udpPortRangeString: resource.udpPortRangeString ?? "*", + disableIcmp: resource.disableIcmp ?? false, + authDaemonMode: resource.authDaemonMode ?? null, + authDaemonPort: resource.authDaemonPort ?? null, + roles: [], + users: [], + clients: [] + }); + setTcpPortMode( + getPortModeFromString(resource.tcpPortRangeString) + ); + setUdpPortMode( + getPortModeFromString(resource.udpPortRangeString) + ); + setTcpCustomPorts( + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" + ? resource.tcpPortRangeString + : "" + ); + setUdpCustomPorts( + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" + ? resource.udpPortRangeString + : "" + ); + previousResourceId.current = resource.id; + } + } + }, [variant, resource, form]); + + // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data + useEffect(() => { + if (variant === "edit" && open === false) { + previousResourceId.current = null; + } + }, [variant, open]); + + // Populate roles/users/clients when edit data is loaded + useEffect(() => { + if ( + variant === "edit" && + siteResourceId != null && + !loadingRolesUsers && + !hasInitialized.current + ) { + hasInitialized.current = true; + form.setValue("roles", formRoles); + form.setValue("users", formUsers); + form.setValue("clients", existingClients); + } + }, [ + variant, + siteResourceId, + loadingRolesUsers, + formRoles, + formUsers, + existingClients, + form + ]); + + return ( +
+ + onSubmit(values as InternalResourceFormValues) + )} + className="space-y-6" + id={formId} + > +
+ ( + + {t(nameLabelKey)} + + + + + + )} + /> + ( + + {t("site")} + + + + + + + + + + + + {t("noSitesFound")} + + + {availableSites.map( + (site) => ( + + field.onChange( + site.siteId + ) + } + > + + {site.name} + + ) + )} + + + + + + + + )} + /> +
+ + +
+
+
+ +
+ {t( + "editInternalResourceDialogDestinationDescription" + )} +
+
+
+
+ ( + + + {t(modeLabelKey)} + + + + + )} + /> +
+
+ ( + + + {t(destinationLabelKey)} + + + + + + + )} + /> +
+ {mode !== "cidr" && ( +
+ ( + + + {t(aliasLabelKey)} + + + + + + + )} + /> +
+ )} +
+
+ +
+
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
+
+ + {t("editInternalResourceDialogTcp")} + +
+
+ ( + +
+ + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e.target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t("editInternalResourceDialogUdp")} + +
+
+ ( + +
+ + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e.target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+
+
+ + {t("editInternalResourceDialogIcmp")} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t("blocked") + : t("allowed")} + +
+ +
+ )} + /> +
+
+
+
+ +
+
+ +
+ {t( + "editInternalResourceDialogAccessControlDescription" + )} +
+
+ {loadingRolesUsers ? ( +
+ {t("loading")} +
+ ) : ( +
+ ( + + {t("roles")} + + + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ) + } + enableAutocomplete={true} + autocompleteOptions={ + allRoles + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + ( + + {t("users")} + + + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ) + } + enableAutocomplete={true} + autocompleteOptions={ + allUsers + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + {hasMachineClients && ( + ( + + + {t("machineClients")} + + + + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ) + } + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + )} +
+ )} +
+ + {/* SSH Access tab */} + {!disableEnterpriseFeatures && ( +
+ +
+ +
+ {t.rich( + "internalResourceAuthDaemonDescription", + { + docsLink: (chunks) => ( + + {chunks} + + + ) + } + )} +
+
+
+ ( + + + {t( + "internalResourceAuthDaemonStrategyLabel" + )} + + + + value={field.value ?? undefined} + options={[ + { + id: "site", + title: t( + "internalResourceAuthDaemonSite" + ), + description: t( + "internalResourceAuthDaemonSiteDescription" + ), + disabled: sshSectionDisabled + }, + { + id: "remote", + title: t( + "internalResourceAuthDaemonRemote" + ), + description: t( + "internalResourceAuthDaemonRemoteDescription" + ), + disabled: sshSectionDisabled + } + ]} + onChange={(v) => { + if (sshSectionDisabled) return; + field.onChange(v); + if (v === "site") { + form.setValue( + "authDaemonPort", + null + ); + } + }} + cols={2} + /> + + + + )} + /> + {authDaemonMode === "remote" && ( + ( + + + {t( + "internalResourceAuthDaemonPort" + )} + + + { + if (sshSectionDisabled) return; + const v = + e.target.value; + if (v === "") { + field.onChange( + null + ); + return; + } + const num = parseInt( + v, + 10 + ); + field.onChange( + Number.isNaN(num) + ? null + : num + ); + }} + /> + + + + )} + /> + )} +
+
+ )} +
+
+ + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 90c7f093..dd0ef3d2 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -75,7 +75,7 @@ export async function Layout({
{children} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 0b716e1e..bef01685 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -48,8 +48,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { }, [theme]); return ( -
-
+
+
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 7b5bda60..940e91fe 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { useQuery } from "@tanstack/react-query"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { ArrowRight, ExternalLink, Server } from "lucide-react"; +import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -190,31 +190,55 @@ export function LayoutSidebar({
)} -
+ {isSidebarCollapsed && ( +
+ + + + + + +

{t("sidebarExpand")}

+
+
+
+
+ )} -
- {canShowProductUpdates ? ( -
+
+ +
+ {canShowProductUpdates && ( +
- ) : ( -
)} {build === "enterprise" && ( -
+
)} {build === "oss" && ( -
+
)} {build === "saas" && ( -
+
@@ -230,19 +254,19 @@ export function LayoutSidebar({ className="whitespace-nowrap" > {link.href ? ( -
+
{link.text}
) : ( -
+
{link.text}
)} @@ -251,12 +275,12 @@ export function LayoutSidebar({ ) : ( <> -
+
{build === "oss" ? t("communityEdition") @@ -269,22 +293,22 @@ export function LayoutSidebar({ {build === "enterprise" && isUnlocked() && licenseStatus?.tier === "personal" ? ( -
+
{t("personalUseOnly")}
) : null} {build === "enterprise" && !isUnlocked() ? ( -
+
{t("unlicensed")}
) : null} {env?.app?.version && ( -
+
v{env.app.version} diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index c0969e7e..db43b1e6 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -98,15 +98,6 @@ export function OrgSelector({ align="start" sideOffset={12} > - {/* Peak pointing up to the trigger */} -
-
{ setOpen(false); - const newPath = pathname.replace( - /^\/[^/]+/, - `/${org.orgId}` - ); + const newPath = pathname.includes( + "/settings/" + ) + ? pathname.replace( + /^\/[^/]+/, + `/${org.orgId}` + ) + : `/${org.orgId}`; router.push(newPath); }} className="mx-1 rounded-md py-1.5 h-auto min-h-0" @@ -166,8 +161,7 @@ export function OrgSelector({ - {(!env.flags.disableUserCreateOrg || - user.serverAdmin) && ( + {(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
+ + + +

{tooltipText}

+
+ +
+ {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 ( + { + if (childIsDisabled) { + e.preventDefault(); + } else { + handlePopoverOpenChange(false); + onItemClick?.(); + } + }} + > +
+ + {t(childItem.title)} + + {childItem.isBeta && ( + + {t("beta")} + + )} +
+ {build === "enterprise" && + childItem.showEE && + !isUnlocked() && ( + + {t("licenseBadge")} + + )} + + ); + })} +
+
+ + + + ); +} + export function SidebarNav({ className, sections, @@ -290,7 +451,7 @@ export function SidebarNav({ isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isActive ? "bg-secondary font-medium" - : "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", + : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} onClick={(e) => { @@ -306,7 +467,10 @@ export function SidebarNav({ {item.icon && level === 0 && ( @@ -361,12 +525,12 @@ export function SidebarNav({ className={cn( "flex items-center rounded-md transition-colors", "px-3 py-1.5", - "text-foreground/80", + "text-muted-foreground", isDisabled && "cursor-not-allowed opacity-60" )} > {item.icon && level === 0 && ( - + {item.icon} )} @@ -406,115 +570,21 @@ export function SidebarNav({ // If item has nested items, show both tooltip and popover if (hasNestedItems) { return ( - - - - - - - - - -

{tooltipText}

-
- -
- {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 ( - { - if (childIsDisabled) { - e.preventDefault(); - } else if ( - onItemClick - ) { - onItemClick(); - } - }} - > -
- - {t(childItem.title)} - - {childItem.isBeta && ( - - {t("beta")} - - )} -
- {build === "enterprise" && - childItem.showEE && - !isUnlocked() && ( - - {t( - "licenseBadge" - )} - - )} - - ); - })} -
-
-
-
-
+ ); } @@ -549,7 +619,7 @@ export function SidebarNav({ className={cn(sectionIndex > 0 && "mt-4")} > {!isCollapsed && ( -
+
{t(`${section.heading}`)}
)} diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index 0d38eba9..7f747360 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -14,6 +14,7 @@ export interface StrategyOption { interface StrategySelectProps { options: ReadonlyArray>; + value?: TValue | null; defaultValue?: TValue; onChange?: (value: TValue) => void; cols?: number; @@ -21,18 +22,21 @@ interface StrategySelectProps { export function StrategySelect({ options, + value: controlledValue, defaultValue, onChange, cols }: StrategySelectProps) { - const [selected, setSelected] = useState(defaultValue); + const [uncontrolledSelected, setUncontrolledSelected] = useState(defaultValue); + const isControlled = controlledValue !== undefined; + const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected; return ( { const typedValue = value as TValue; - setSelected(typedValue); + if (!isControlled) setUncontrolledSelected(typedValue); onChange?.(typedValue); }} className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}