From 4e7eac368f257e6a19cf6d98d01f0ffd03b55ff8 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Feb 2026 11:56:01 -0800 Subject: [PATCH 01/52] Uniform ne check on niceId and dont reject clients --- server/routers/client/updateClient.ts | 5 ++- server/routers/resource/updateResource.ts | 28 +++++++++----- server/routers/site/updateSite.ts | 45 ++++++++++++----------- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 12d0a199..8ef01a2f 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -6,7 +6,7 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -93,7 +93,8 @@ export async function updateClient( .where( and( eq(clients.niceId, niceId), - eq(clients.orgId, clients.orgId) + eq(clients.orgId, clients.orgId), + ne(clients.clientId, clientId) ) ) .limit(1); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 4f35739b..4a3e65fa 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -9,7 +9,7 @@ import { Resource, resources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -33,7 +33,15 @@ const updateResourceParamsSchema = z.strictObject({ const updateHttpResourceBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), + niceId: z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ) + .optional(), subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), @@ -248,14 +256,13 @@ async function updateHttpResource( .where( and( eq(resources.niceId, updateData.niceId), - eq(resources.orgId, resource.orgId) + eq(resources.orgId, resource.orgId), + ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search ) - ); + ) + .limit(1); - if ( - existingResource && - existingResource.resourceId !== resource.resourceId - ) { + if (existingResource) { return next( createHttpError( HttpCode.CONFLICT, @@ -343,7 +350,10 @@ async function updateHttpResource( headers = null; } - const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage); + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.maintencePage + ); if (!isLicensed) { updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeType = undefined; diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 44764362..ca0f7678 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -19,8 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional(), - remoteSubnets: z.string().optional() + dockerSocketEnabled: z.boolean().optional() + // remoteSubnets: z.string().optional() // subdomain: z // .string() // .min(1) @@ -86,18 +86,19 @@ export async function updateSite( // if niceId is provided, check if it's already in use by another site if (updateData.niceId) { - const existingSite = await db + const [existingSite] = await db .select() .from(sites) .where( and( eq(sites.niceId, updateData.niceId), - eq(sites.orgId, sites.orgId) + eq(sites.orgId, sites.orgId), + ne(sites.siteId, siteId) ) ) .limit(1); - if (existingSite.length > 0 && existingSite[0].siteId !== siteId) { + if (existingSite) { return next( createHttpError( HttpCode.CONFLICT, @@ -107,22 +108,22 @@ export async function updateSite( } } - // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs - if (updateData.remoteSubnets) { - const subnets = updateData.remoteSubnets - .split(",") - .map((s) => s.trim()); - for (const subnet of subnets) { - if (!isValidCIDR(subnet)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Invalid CIDR format: ${subnet}` - ) - ); - } - } - } + // // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs + // if (updateData.remoteSubnets) { + // const subnets = updateData.remoteSubnets + // .split(",") + // .map((s) => s.trim()); + // for (const subnet of subnets) { + // if (!isValidCIDR(subnet)) { + // return next( + // createHttpError( + // HttpCode.BAD_REQUEST, + // `Invalid CIDR format: ${subnet}` + // ) + // ); + // } + // } + // } const updatedSite = await db .update(sites) From 5e37c4e85fae68e756be5019a28ca903b161fdd5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Feb 2026 13:55:04 -0800 Subject: [PATCH 02/52] Resolve potential issues with processing roleIds --- .../middlewares/integration/verifyApiKeyRoleAccess.ts | 11 ++++++++--- server/middlewares/verifyRoleAccess.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts index ffe223a6..62bfb946 100644 --- a/server/middlewares/integration/verifyApiKeyRoleAccess.ts +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -23,9 +23,14 @@ export async function verifyApiKeyRoleAccess( ); } - const { roleIds } = req.body; - const allRoleIds = - roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + let allRoleIds: number[] = []; + if (!isNaN(singleRoleId)) { + // If roleId is provided in URL params, query params, or body (single), use it exclusively + allRoleIds = [singleRoleId]; + } else if (req.body?.roleIds) { + // Only use body.roleIds if no single roleId was provided + allRoleIds = req.body.roleIds; + } if (allRoleIds.length === 0) { return next(); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 91adf07c..8858ab53 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -23,8 +23,14 @@ export async function verifyRoleAccess( ); } - const roleIds = req.body?.roleIds; - const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + let allRoleIds: number[] = []; + if (!isNaN(singleRoleId)) { + // If roleId is provided in URL params, query params, or body (single), use it exclusively + allRoleIds = [singleRoleId]; + } else if (req.body?.roleIds) { + // Only use body.roleIds if no single roleId was provided + allRoleIds = req.body.roleIds; + } if (allRoleIds.length === 0) { return next(); From 874794c996e5feaaa5cc3ff30352742e6404858a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Feb 2026 14:07:50 -0800 Subject: [PATCH 03/52] Clean email --- server/private/routers/ssh/signSshKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 9ffce8c1..4967b600 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -139,7 +139,7 @@ export async function signSshKey( if (!userOrg.pamUsername) { if (req.user?.email) { // Extract username from email (first part before @) - usernameToUse = req.user?.email.split("@")[0]; + usernameToUse = req.user?.email.split("@")[0].replace(/[^a-zA-Z0-9_-]/g, ""); if (!usernameToUse) { return next( createHttpError( From 7a01a4e090934b5ec7f14d10f9a09eb372512602 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Feb 2026 17:53:11 -0800 Subject: [PATCH 04/52] ssh settings on a role --- messages/en-US.json | 18 + server/db/pg/schema/schema.ts | 6 +- server/db/sqlite/schema/schema.ts | 8 +- server/lib/billing/tierMatrix.ts | 2 +- .../routers/billing/featureLifecycle.ts | 18 + server/private/routers/ssh/signSshKey.ts | 62 ++- server/routers/role/createRole.ts | 39 +- server/routers/role/listRoles.ts | 32 +- server/routers/role/updateRole.ts | 88 +++- server/setup/scriptsSqlite/1.16.0.ts | 28 ++ src/components/CreateRoleForm.tsx | 241 +++------- src/components/EditRoleForm.tsx | 247 +++------- src/components/OptionSelect.tsx | 70 +++ src/components/RoleForm.tsx | 441 ++++++++++++++++++ src/components/newt-install-commands.tsx | 81 ++-- src/components/olm-install-commands.tsx | 85 ++-- 16 files changed, 982 insertions(+), 484 deletions(-) create mode 100644 server/setup/scriptsSqlite/1.16.0.ts create mode 100644 src/components/OptionSelect.tsx create mode 100644 src/components/RoleForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 44d980c5..3d6b5d77 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1643,6 +1643,24 @@ "timeIsInSeconds": "Time is in seconds", "requireDeviceApproval": "Require Device Approvals", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", + "sshAccess": "SSH Access", + "roleAllowSsh": "Allow SSH", + "roleAllowSshAllow": "Allow", + "roleAllowSshDisallow": "Disallow", + "roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.", + "sshSudoMode": "Sudo Access", + "sshSudoModeNone": "None", + "sshSudoModeNoneDescription": "User cannot run commands with sudo.", + "sshSudoModeFull": "Full Sudo", + "sshSudoModeFullDescription": "User can run any command with sudo.", + "sshSudoModeCommands": "Commands", + "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", + "sshSudo": "Allow sudo", + "sshSudoCommands": "Sudo Commands", + "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.", + "sshCreateHomeDir": "Create Home Directory", + "sshUnixGroups": "Unix Groups", + "sshUnixGroupsDescription": "Unix groups to add the user to on the target host.", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7c252b8b..4b628675 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -372,7 +372,11 @@ export const roles = pgTable("roles", { isAdmin: boolean("isAdmin"), name: varchar("name").notNull(), description: varchar("description"), - requireDeviceApproval: boolean("requireDeviceApproval").default(false) + requireDeviceApproval: boolean("requireDeviceApproval").default(false), + sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands" + sshSudoCommands: text("sshSudoCommands").default("[]"), + sshCreateHomeDir: boolean("sshCreateHomeDir").default(false), + sshUnixGroups: text("sshUnixGroups").default("[]") }); export const roleActions = pgTable("roleActions", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 04d4338a..1bef04b3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -679,7 +679,13 @@ export const roles = sqliteTable("roles", { description: text("description"), requireDeviceApproval: integer("requireDeviceApproval", { mode: "boolean" - }).default(false) + }).default(false), + sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands" + sshSudoCommands: text("sshSudoCommands").default("[]"), + sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( + false + ), + sshUnixGroups: text("sshUnixGroups").default("[]") }); export const roleActions = sqliteTable("roleActions", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 20f8001d..c08bcea7 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]: ["enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] }; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 3e4b8a4a..af7114a2 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -286,6 +286,10 @@ async function disableFeature( await disableAutoProvisioning(orgId); break; + case TierFeature.SshPam: + await disableSshPam(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -315,6 +319,20 @@ async function disableDeviceApprovals(orgId: string): Promise { logger.info(`Disabled device approvals on all roles for org ${orgId}`); } +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}`); +} + async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 4967b600..41593e9f 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -13,7 +13,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db"; +import { + db, + newts, + roles, + roundTripMessageTracker, + siteResources, + sites, + userOrgs +} from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -25,6 +35,8 @@ 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() @@ -135,11 +147,26 @@ export async function signSshKey( ); } + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.sshPam + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "SSH key signing requires a paid plan" + ) + ); + } + let usernameToUse; if (!userOrg.pamUsername) { if (req.user?.email) { // Extract username from email (first part before @) - usernameToUse = req.user?.email.split("@")[0].replace(/[^a-zA-Z0-9_-]/g, ""); + usernameToUse = req.user?.email + .split("@")[0] + .replace(/[^a-zA-Z0-9_-]/g, ""); if (!usernameToUse) { return next( createHttpError( @@ -301,6 +328,29 @@ export async function signSshKey( ); } + const [roleRow] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + let parsedSudoCommands: string[] = []; + let parsedGroups: string[] = []; + try { + parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; + } catch { + parsedSudoCommands = []; + } + try { + parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (!Array.isArray(parsedGroups)) parsedGroups = []; + } catch { + parsedGroups = []; + } + const homedir = roleRow?.sshCreateHomeDir ?? null; + const sudoMode = roleRow?.sshSudoMode ?? "none"; + // get the site const [newt] = await db .select() @@ -334,7 +384,7 @@ export async function signSshKey( .values({ wsClientId: newt.newtId, messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000), + sentAt: Math.floor(Date.now() / 1000) }) .returning(); @@ -358,8 +408,10 @@ export async function signSshKey( username: usernameToUse, niceId: resource.niceId, metadata: { - sudo: true, // we are hardcoding these for now but should make configurable from the role or something - homedir: true + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups } } }); diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index edb8f1bd..e732b405 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -18,10 +18,17 @@ const createRoleParamsSchema = z.strictObject({ orgId: z.string() }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const createRoleSchema = z.strictObject({ name: z.string().min(1).max(255), description: z.string().optional(), - requireDeviceApproval: z.boolean().optional() + requireDeviceApproval: z.boolean().optional(), + allowSsh: z.boolean().optional(), + sshSudoMode: sshSudoModeSchema.optional(), + sshSudoCommands: z.array(z.string()).optional(), + sshCreateHomeDir: z.boolean().optional(), + sshUnixGroups: z.array(z.string()).optional() }); export const defaultRoleAllowedActions: ActionsEnum[] = [ @@ -101,24 +108,40 @@ export async function createRole( ); } - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); - if (!isLicensed) { + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { roleData.requireDeviceApproval = undefined; } + const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + const roleInsertValues: Record = { + name: roleData.name, + orgId + }; + if (roleData.description !== undefined) roleInsertValues.description = roleData.description; + if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval; + if (isLicensedSshPam) { + if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode; + if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands); + if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; + if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups); + } + await db.transaction(async (trx) => { const newRole = await trx .insert(roles) - .values({ - ...roleData, - orgId - }) + .values(roleInsertValues as typeof roles.$inferInsert) .returning(); + const actionsToInsert = [...defaultRoleAllowedActions]; + if (roleData.allowSsh) { + actionsToInsert.push(ActionsEnum.signSshKey); + } + await trx .insert(roleActions) .values( - defaultRoleAllowedActions.map((action) => ({ + actionsToInsert.map((action) => ({ roleId: newRole[0].roleId, actionId: action, orgId diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index ec7f3b4b..d4cb580f 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -1,9 +1,10 @@ -import { db, orgs, roles } from "@server/db"; +import { db, orgs, roleActions, roles } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { ActionsEnum } from "@server/auth/actions"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -37,7 +38,11 @@ async function queryRoles(orgId: string, limit: number, offset: number) { name: roles.name, description: roles.description, orgName: orgs.name, - requireDeviceApproval: roles.requireDeviceApproval + requireDeviceApproval: roles.requireDeviceApproval, + sshSudoMode: roles.sshSudoMode, + sshSudoCommands: roles.sshSudoCommands, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshUnixGroups: roles.sshUnixGroups }) .from(roles) .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) @@ -106,9 +111,28 @@ export async function listRoles( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + let rolesWithAllowSsh = rolesList; + if (rolesList.length > 0) { + const roleIds = rolesList.map((r) => r.roleId); + const signSshKeyRows = await db + .select({ roleId: roleActions.roleId }) + .from(roleActions) + .where( + and( + inArray(roleActions.roleId, roleIds), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ); + const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId)); + rolesWithAllowSsh = rolesList.map((r) => ({ + ...r, + allowSsh: roleIdsWithSsh.has(r.roleId) + })); + } + return response(res, { data: { - roles: rolesList, + roles: rolesWithAllowSsh, pagination: { total: totalCount, limit, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 51a33e32..66332bf2 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -1,8 +1,9 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, type Role } from "@server/db"; -import { roles } from "@server/db"; -import { eq } from "drizzle-orm"; +import { roleActions, roles } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { ActionsEnum } from "@server/auth/actions"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -16,11 +17,18 @@ const updateRoleParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const updateRoleBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), description: z.string().optional(), - requireDeviceApproval: z.boolean().optional() + requireDeviceApproval: z.boolean().optional(), + allowSsh: z.boolean().optional(), + sshSudoMode: sshSudoModeSchema.optional(), + sshSudoCommands: z.array(z.string()).optional(), + sshCreateHomeDir: z.boolean().optional(), + sshUnixGroups: z.array(z.string()).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -75,7 +83,9 @@ export async function updateRole( } const { roleId } = parsedParams.data; - const updateData = parsedBody.data; + const body = parsedBody.data; + const { allowSsh, ...restBody } = body; + const updateData: Record = { ...restBody }; const role = await db .select() @@ -111,18 +121,70 @@ export async function updateRole( ); } - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); - if (!isLicensed) { + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { updateData.requireDeviceApproval = undefined; } - const updatedRole = await db - .update(roles) - .set(updateData) - .where(eq(roles.roleId, roleId)) - .returning(); + const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + if (!isLicensedSshPam) { + delete updateData.sshSudoMode; + delete updateData.sshSudoCommands; + delete updateData.sshCreateHomeDir; + delete updateData.sshUnixGroups; + } else { + if (Array.isArray(updateData.sshSudoCommands)) { + updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands); + } + if (Array.isArray(updateData.sshUnixGroups)) { + updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups); + } + } - if (updatedRole.length === 0) { + const updatedRole = await db.transaction(async (trx) => { + const result = await trx + .update(roles) + .set(updateData as typeof roles.$inferInsert) + .where(eq(roles.roleId, roleId)) + .returning(); + + if (result.length === 0) { + return null; + } + + if (allowSsh === true) { + const existing = await trx + .select() + .from(roleActions) + .where( + and( + eq(roleActions.roleId, roleId), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ) + .limit(1); + if (existing.length === 0) { + await trx.insert(roleActions).values({ + roleId, + actionId: ActionsEnum.signSshKey, + orgId: orgId! + }); + } + } else if (allowSsh === false) { + await trx + .delete(roleActions) + .where( + and( + eq(roleActions.roleId, roleId), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ); + } + + return result[0]; + }); + + if (!updatedRole) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -132,7 +194,7 @@ export async function updateRole( } return response(res, { - data: updatedRole[0], + data: updatedRole, success: true, error: false, message: "Role updated successfully", diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts new file mode 100644 index 00000000..969053bf --- /dev/null +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -0,0 +1,28 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.16.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + // set all admin role sudo to "full"; all other roles to "none" + // all roles set hoemdir to true + + // generate ca certs for all orgs? + + try { + db.transaction(() => {})(); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 735993c7..537618ec 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -11,31 +11,19 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; -import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; +import type { + CreateRoleBody, + CreateRoleResponse +} from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { CheckboxWithLabel } from "./ui/checkbox"; +import { RoleForm, type RoleFormValues } from "./RoleForm"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type CreateRoleFormProps = { @@ -52,35 +40,39 @@ export default function CreateRoleForm({ const { org } = useOrgContext(); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); - const { env } = useEnvContext(); - - const formSchema = z.object({ - name: z - .string({ message: t("nameRequired") }) - .min(1) - .max(32), - description: z.string().max(255).optional(), - requireDeviceApproval: z.boolean().optional() - }); - const api = createApiClient(useEnvContext()); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: "", - description: "", - requireDeviceApproval: false - } - }); - const [loading, startTransition] = useTransition(); - async function onSubmit(values: z.infer) { + async function onSubmit(values: RoleFormValues) { + const payload: CreateRoleBody = { + name: values.name, + description: values.description || undefined, + requireDeviceApproval: values.requireDeviceApproval, + allowSsh: values.allowSsh + }; + if (isPaidUser(tierMatrix.sshPam)) { + payload.sshSudoMode = values.sshSudoMode; + payload.sshCreateHomeDir = values.sshCreateHomeDir; + payload.sshSudoCommands = + values.sshSudoMode === "commands" && + values.sshSudoCommands?.trim() + ? values.sshSudoCommands + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + if (values.sshUnixGroups?.trim()) { + payload.sshUnixGroups = values.sshUnixGroups + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + } const res = await api - .put< - AxiosResponse - >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody) + .put>( + `/org/${org?.org.orgId}/role`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -98,143 +90,42 @@ export default function CreateRoleForm({ title: t("accessRoleCreated"), description: t("accessRoleCreatedDescription") }); - - if (open) { - setOpen(false); - } - + if (open) setOpen(false); afterCreate?.(res.data.data); } } return ( - <> - { - setOpen(val); - form.reset(); - }} - > - - - {t("accessRoleCreate")} - - {t("accessRoleCreateDescription")} - - - -
- - startTransition(() => onSubmit(values)) - )} - className="space-y-4" - id="create-role-form" - > - ( - - - {t("accessRoleName")} - - - - - - - )} - /> - ( - - - {t("description")} - - - - - - - )} - /> - - {!env.flags.disableEnterpriseFeatures && ( - <> - - - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - - - - {t( - "requireDeviceApprovalDescription" - )} - - - - - )} - /> - - )} - - -
- - - - - - -
-
- + + + + {t("accessRoleCreate")} + + {t("accessRoleCreateDescription")} + + + + + startTransition(() => onSubmit(values)) + } + /> + + + + + + + + + ); } diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index 3d81f928..80555660 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -11,44 +11,26 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; import type { Role } from "@server/db"; import type { - CreateRoleBody, - CreateRoleResponse, UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { CheckboxWithLabel } from "./ui/checkbox"; +import { RoleForm, type RoleFormValues } from "./RoleForm"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -type CreateRoleFormProps = { +type EditRoleFormProps = { role: Role; open: boolean; setOpen: (open: boolean) => void; - onSuccess?: (res: CreateRoleResponse) => void; + onSuccess?: (res: UpdateRoleResponse) => void; }; export default function EditRoleForm({ @@ -56,39 +38,42 @@ export default function EditRoleForm({ role, setOpen, onSuccess -}: CreateRoleFormProps) { - const { org } = useOrgContext(); +}: EditRoleFormProps) { const t = useTranslations(); const { isPaidUser } = usePaidStatus(); - const { env } = useEnvContext(); - - const formSchema = z.object({ - name: z - .string({ message: t("nameRequired") }) - .min(1) - .max(32), - description: z.string().max(255).optional(), - requireDeviceApproval: z.boolean().optional() - }); - const api = createApiClient(useEnvContext()); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: role.name, - description: role.description ?? "", - requireDeviceApproval: role.requireDeviceApproval ?? false - } - }); - const [loading, startTransition] = useTransition(); - async function onSubmit(values: z.infer) { + async function onSubmit(values: RoleFormValues) { + const payload: UpdateRoleBody = { + name: values.name, + description: values.description || undefined, + requireDeviceApproval: values.requireDeviceApproval, + allowSsh: values.allowSsh + }; + if (isPaidUser(tierMatrix.sshPam)) { + payload.sshSudoMode = values.sshSudoMode; + payload.sshCreateHomeDir = values.sshCreateHomeDir; + payload.sshSudoCommands = + values.sshSudoMode === "commands" && + values.sshSudoCommands?.trim() + ? values.sshSudoCommands + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + if (values.sshUnixGroups !== undefined) { + payload.sshUnixGroups = values.sshUnixGroups + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + } const res = await api - .post< - AxiosResponse - >(`/role/${role.roleId}`, values satisfies UpdateRoleBody) + .post>( + `/role/${role.roleId}`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -106,143 +91,43 @@ export default function EditRoleForm({ title: t("accessRoleUpdated"), description: t("accessRoleUpdatedDescription") }); - - if (open) { - setOpen(false); - } - + if (open) setOpen(false); onSuccess?.(res.data.data); } } return ( - <> - { - setOpen(val); - form.reset(); - }} - > - - - {t("accessRoleEdit")} - - {t("accessRoleEditDescription")} - - - -
- - startTransition(() => onSubmit(values)) - )} - className="space-y-4" - id="create-role-form" - > - ( - - - {t("accessRoleName")} - - - - - - - )} - /> - ( - - - {t("description")} - - - - - - - )} - /> - - {!env.flags.disableEnterpriseFeatures && ( - <> - - - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - - - - {t( - "requireDeviceApprovalDescription" - )} - - - - - )} - /> - - )} - - -
- - - - - - -
-
- + + + + {t("accessRoleEdit")} + + {t("accessRoleEditDescription")} + + + + + startTransition(() => onSubmit(values)) + } + /> + + + + + + + + + ); } diff --git a/src/components/OptionSelect.tsx b/src/components/OptionSelect.tsx new file mode 100644 index 00000000..2f891394 --- /dev/null +++ b/src/components/OptionSelect.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import type { ReactNode } from "react"; + +export type OptionSelectOption = { + value: TValue; + label: string; + icon?: ReactNode; +}; + +type OptionSelectProps = { + options: ReadonlyArray>; + value: TValue; + onChange: (value: TValue) => void; + label?: string; + /** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */ + cols?: number; + className?: string; + disabled?: boolean; +}; + +export function OptionSelect({ + options, + value, + onChange, + label, + cols = 5, + className, + disabled = false +}: OptionSelectProps) { + return ( +
+ {label && ( +

{label}

+ )} +
+ {options.map((option) => { + const isSelected = value === option.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx new file mode 100644 index 00000000..10d74e5f --- /dev/null +++ b/src/components/RoleForm.tsx @@ -0,0 +1,441 @@ +"use client"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; +import { CheckboxWithLabel } from "./ui/checkbox"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Role } from "@server/db"; + +export const SSH_SUDO_MODE_VALUES = ["none", "full", "commands"] as const; +export type SshSudoMode = (typeof SSH_SUDO_MODE_VALUES)[number]; + +function parseRoleJsonArray(value: string | null | undefined): string[] { + if (value == null || value === "") return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function toSshSudoMode(value: string | null | undefined): SshSudoMode { + if (value === "none" || value === "full" || value === "commands") + return value; + return "none"; +} + +export type RoleFormValues = { + name: string; + description?: string; + requireDeviceApproval?: boolean; + allowSsh?: boolean; + sshSudoMode: SshSudoMode; + sshSudoCommands?: string; + sshCreateHomeDir?: boolean; + sshUnixGroups?: string; +}; + +type RoleFormProps = { + variant: "create" | "edit"; + role?: Role; + onSubmit: (values: RoleFormValues) => void | Promise; + formId?: string; +}; + +export function RoleForm({ + variant, + role, + onSubmit, + formId = "create-role-form" +}: RoleFormProps) { + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + const { env } = useEnvContext(); + + const formSchema = z.object({ + name: z + .string({ message: t("nameRequired") }) + .min(1) + .max(32), + description: z.string().max(255).optional(), + requireDeviceApproval: z.boolean().optional(), + allowSsh: z.boolean().optional(), + sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES), + sshSudoCommands: z.string().optional(), + sshCreateHomeDir: z.boolean().optional(), + sshUnixGroups: z.string().optional() + }); + + const defaultValues: RoleFormValues = role + ? { + name: role.name, + description: role.description ?? "", + requireDeviceApproval: role.requireDeviceApproval ?? false, + allowSsh: + (role as Role & { allowSsh?: boolean }).allowSsh ?? false, + sshSudoMode: toSshSudoMode(role.sshSudoMode), + sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join( + ", " + ), + sshCreateHomeDir: role.sshCreateHomeDir ?? false, + sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ") + } + : { + name: "", + description: "", + requireDeviceApproval: false, + allowSsh: false, + sshSudoMode: "none", + sshSudoCommands: "", + sshCreateHomeDir: true, + sshUnixGroups: "" + }; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues + }); + + useEffect(() => { + if (variant === "edit" && role) { + form.reset({ + name: role.name, + description: role.description ?? "", + requireDeviceApproval: role.requireDeviceApproval ?? false, + allowSsh: + (role as Role & { allowSsh?: boolean }).allowSsh ?? false, + sshSudoMode: toSshSudoMode(role.sshSudoMode), + sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join( + ", " + ), + sshCreateHomeDir: role.sshCreateHomeDir ?? false, + sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ") + }); + } + }, [variant, role, form]); + + const sshDisabled = !isPaidUser(tierMatrix.sshPam); + const sshSudoMode = form.watch("sshSudoMode"); + + return ( +
+ onSubmit(values))} + className="space-y-4" + id={formId} + > + {env.flags.disableEnterpriseFeatures ? ( +
+ ( + + {t("accessRoleName")} + + + + + + )} + /> + ( + + {t("description")} + + + + + + )} + /> +
+ ) : ( + + {/* General tab */} +
+ ( + + + {t("accessRoleName")} + + + + + + + )} + /> + ( + + + {t("description")} + + + + + + + )} + /> + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + + + {t( + "requireDeviceApprovalDescription" + )} + + + + )} + /> +
+ + {/* SSH tab - hidden when enterprise features are disabled */} + {!env.flags.disableEnterpriseFeatures && ( +
+ + { + const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [ + { + value: "allow", + label: t("roleAllowSshAllow") + }, + { + value: "disallow", + label: t("roleAllowSshDisallow") + } + ]; + return ( + + + {t("roleAllowSsh")} + + + options={allowSshOptions} + value={ + field.value + ? "allow" + : "disallow" + } + onChange={(v) => + field.onChange(v === "allow") + } + cols={2} + /> + + {t( + "roleAllowSshDescription" + )} + + + + ); + }} + /> + { + const sudoOptions: OptionSelectOption[] = + [ + { + value: "none", + label: t("sshSudoModeNone") + }, + { + value: "full", + label: t("sshSudoModeFull") + }, + { + value: "commands", + label: t( + "sshSudoModeCommands" + ) + } + ]; + return ( + + + {t("sshSudoMode")} + + + options={sudoOptions} + value={field.value} + onChange={field.onChange} + cols={3} + disabled={sshDisabled} + /> + + + ); + }} + /> + {sshSudoMode === "commands" && ( + ( + + + {t("sshSudoCommands")} + + + + + + {t( + "sshSudoCommandsDescription" + )} + + + + )} + /> + )} + + ( + + + {t("sshUnixGroups")} + + + + + + {t("sshUnixGroupsDescription")} + + + + )} + /> + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "sshCreateHomeDir", + checked + ); + } + }} + label={t( + "sshCreateHomeDir" + )} + disabled={sshDisabled} + /> + + + + )} + /> +
+ )} +
+ )} +
+ + ); +} diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index 3561cd6b..5a252f0d 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -8,7 +8,7 @@ import { SettingsSectionTitle } from "./Settings"; import { CheckboxWithLabel } from "./ui/checkbox"; -import { Button } from "./ui/button"; +import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { useState } from "react"; import { FaCubes, FaDocker, FaWindows } from "react-icons/fa"; import { Terminal } from "lucide-react"; @@ -138,6 +138,14 @@ WantedBy=default.target` const commands = commandList[platform][architecture]; + const platformOptions: OptionSelectOption[] = PLATFORMS.map( + (os) => ({ + value: os, + label: getPlatformName(os), + icon: getPlatformIcon(os) + }) + ); + return ( @@ -149,53 +157,33 @@ WantedBy=default.target` -
-

{t("operatingSystem")}

-
- {PLATFORMS.map((os) => ( - - ))} -
-
+ + label={t("operatingSystem")} + options={platformOptions} + value={platform} + onChange={(os) => { + setPlatform(os); + const architectures = getArchitectures(os); + setArchitecture(architectures[0]); + }} + cols={5} + /> -
-

- {["docker", "podman"].includes(platform) + + label={ + ["docker", "podman"].includes(platform) ? t("method") - : t("architecture")} -

-
- {getArchitectures(platform).map((arch) => ( - - ))} -
+ : t("architecture") + } + options={getArchitectures(platform).map((arch) => ({ + value: arch, + label: arch + }))} + value={architecture} + onChange={setArchitecture} + cols={5} + className="mt-4" + />

@@ -250,7 +238,6 @@ WantedBy=default.target` })}

-
); diff --git a/src/components/olm-install-commands.tsx b/src/components/olm-install-commands.tsx index c613d698..38bd8e97 100644 --- a/src/components/olm-install-commands.tsx +++ b/src/components/olm-install-commands.tsx @@ -10,7 +10,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { Button } from "./ui/button"; +import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; export type CommandItem = string | { title: string; command: string }; @@ -88,6 +88,15 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol }; const commands = commandList[platform][architecture]; + + const platformOptions: OptionSelectOption[] = PLATFORMS.map( + (os) => ({ + value: os, + label: getPlatformName(os), + icon: getPlatformIcon(os) + }) + ); + return ( @@ -99,54 +108,35 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol -
-

{t("operatingSystem")}

-
- {PLATFORMS.map((os) => ( - - ))} -
-
+ + label={t("operatingSystem")} + options={platformOptions} + value={platform} + onChange={(os) => { + setPlatform(os); + const architectures = getArchitectures(os); + setArchitecture(architectures[0]); + }} + cols={5} + /> -
-

- {["docker", "podman"].includes(platform) + + label={ + platform === "docker" ? t("method") - : t("architecture")} -

-
- {getArchitectures(platform).map((arch) => ( - - ))} -
-
+ : t("architecture") + } + options={getArchitectures(platform).map((arch) => ({ + value: arch, + label: arch + }))} + value={architecture} + onChange={setArchitecture} + cols={5} + className="mt-4" + /> + +

{t("commands")}

{commands.map((item, index) => { @@ -174,7 +164,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol ); })}
-
From b7864972994067e890ac0ec77ce44c473f221e2a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 19 Feb 2026 17:55:49 -0800 Subject: [PATCH 05/52] Working on k8s --- .github/workflows/saas.yml | 35 ++++++++++++++++++++++++++++ Dockerfile | 8 +++++++ server/private/lib/readConfigFile.ts | 16 ++++++------- server/private/lib/redis.ts | 24 ++++++++++++------- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/.github/workflows/saas.yml b/.github/workflows/saas.yml index 5db7aa2f..93e5d198 100644 --- a/.github/workflows/saas.yml +++ b/.github/workflows/saas.yml @@ -56,6 +56,41 @@ jobs: - name: Checkout code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Download MaxMind GeoLite2 databases + env: + MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} + run: | + echo "Downloading MaxMind GeoLite2 databases..." + + # Download GeoLite2-Country + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-Country.tar.gz + + # Download GeoLite2-ASN + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-ASN.tar.gz + + # Extract the .mmdb files + tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb' + tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb' + + # Verify files exist + if [ ! -f "GeoLite2-Country.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-Country.mmdb" + exit 1 + fi + + if [ ! -f "GeoLite2-ASN.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-ASN.mmdb" + exit 1 + fi + + # Clean up tar files + rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz + + echo "MaxMind databases downloaded successfully" + ls -lh GeoLite2-*.mmdb + - name: Monitor storage space run: | THRESHOLD=75 diff --git a/Dockerfile b/Dockerfile index 4830067e..12c519b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,14 @@ COPY server/db/ios_models.json ./dist/ios_models.json COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public +# Copy MaxMind databases for SaaS builds +ARG BUILD=oss +RUN mkdir -p ./maxmind + +# This is only for saas +COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb +COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb + # OCI Image Labels - Build Args for dynamic values ARG VERSION="dev" ARG REVISION="" diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index e5efa498..a9de84e8 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -72,15 +72,15 @@ export const privateConfigSchema = z.object({ db: z.int().nonnegative().optional().default(0) }) ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z + .boolean() + .optional() + .default(true) + }) .optional() - // tls: z - // .object({ - // reject_unauthorized: z - // .boolean() - // .optional() - // .default(true) - // }) - // .optional() }) .optional(), gerbil: z diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 49cd4c61..69f563b4 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -108,11 +108,15 @@ class RedisManager { port: redisConfig.port!, password: redisConfig.password, db: redisConfig.db - // tls: { - // rejectUnauthorized: - // redisConfig.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } @@ -130,11 +134,15 @@ class RedisManager { port: replica.port!, password: replica.password, db: replica.db || redisConfig.db - // tls: { - // rejectUnauthorized: - // replica.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } From 7d112aab27d4abe215c7c79874de283f5e636ac7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Feb 2026 21:52:47 -0800 Subject: [PATCH 06/52] improve alignment on sidebar --- src/components/SidebarNav.tsx | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 294fd54d..f7fdf275 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -119,7 +119,7 @@ function CollapsibleNavItem({ - +
- {item.items!.map((childItem) => - renderNavItem(childItem, level + 1) - )} +
+
+ {item.items!.map((childItem) => + renderNavItem(childItem, level + 1) + )} +
+
From 4e88f1f38afffaf51c25dbdd9684b2784d411c85 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Feb 2026 22:41:14 -0800 Subject: [PATCH 08/52] more sidebar improvements --- messages/en-US.json | 4 +- src/app/navigation.tsx | 152 +++++++++++++++---------------- src/components/LayoutSidebar.tsx | 66 +++++++------- src/components/OrgSelector.tsx | 2 +- 4 files changed, 114 insertions(+), 110 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3d6b5d77..92334feb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -649,7 +649,7 @@ "resourcesUsersRolesAccess": "User and role-based access control", "resourcesErrorUpdate": "Failed to toggle resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource", - "access": "Access", + "access": "Access Control", "shareLink": "{resource} Share Link", "resourceSelect": "Select resource", "shareLinks": "Share Links", @@ -790,6 +790,7 @@ "accessRoleRemoved": "Role removed", "accessRoleRemovedDescription": "The role has been successfully removed.", "accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.", + "network": "Network", "manage": "Manage", "sitesNotFound": "No sites found.", "pangolinServerAdmin": "Server Admin - Pangolin", @@ -1267,6 +1268,7 @@ "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarOrganization": "Organization", + "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "blueprints": "Blueprints", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index be3ad7d3..162d9966 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { + Building2, ChartLine, Combine, CreditCard, @@ -11,10 +12,11 @@ import { KeyRound, Laptop, Link as LinkIcon, - Logs, // Added from 'dev' branch + Logs, MonitorUp, + Plug, ReceiptText, - ScanEye, // Added from 'dev' branch + ScanEye, Server, Settings, SquareMousePointer, @@ -49,12 +51,12 @@ export const orgNavSections = ( options?: OrgNavSectionsOptions ): SidebarNavSection[] => [ { - heading: "sidebarGeneral", + heading: "network", items: [ { title: "sidebarSites", href: "/{orgId}/settings/sites", - icon: + icon: }, { title: "sidebarResources", @@ -157,92 +159,88 @@ export const orgNavSections = ( } ] }, - { - heading: "sidebarLogsAndAnalytics", - items: (() => { - const logItems: SidebarNavItem[] = [ - { - title: "sidebarLogsRequest", - href: "/{orgId}/settings/logs/request", - icon: - }, - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - } - ] - : []) - ]; - - const analytics = { - title: "sidebarLogsAnalytics", - href: "/{orgId}/settings/logs/analytics", - icon: - }; - - // If only one log item, return it directly without grouping - if (logItems.length === 1) { - return [analytics, ...logItems]; - } - - // If multiple log items, create a group - return [ - analytics, - { - title: "sidebarLogs", - icon: , - items: logItems - } - ]; - })() - }, { heading: "sidebarOrganization", items: [ { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: + title: "sidebarLogsAndAnalytics", + icon: , + items: [ + { + title: "sidebarLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }, + { + title: "sidebarLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: ( + + ) + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) + ] }, { - title: "sidebarBluePrints", - href: "/{orgId}/settings/blueprints", - icon: + title: "sidebarManagement", + icon: , + items: [ + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + { + title: "sidebarBluePrints", + href: "/{orgId}/settings/blueprints", + icon: + } + ] }, + ...(build == "saas" && options?.isPrimaryOrg + ? [ + { + title: "sidebarBillingAndLicenses", + icon: , + items: [ + { + title: "sidebarBilling", + href: "/{orgId}/settings/billing", + icon: ( + + ) + }, + { + title: "sidebarEnterpriseLicenses", + href: "/{orgId}/settings/license", + icon: ( + + ) + } + ] + } + ] + : []), { title: "sidebarSettings", href: "/{orgId}/settings/general", icon: } ] - }, - ...(build == "saas" && options?.isPrimaryOrg - ? [ - { - heading: "sidebarBillingAndLicenses", - items: [ - { - title: "sidebarBilling", - href: "/{orgId}/settings/billing", - icon: - }, - { - title: "sidebarEnterpriseLicenses", - href: "/{orgId}/settings/license", - icon: - } - ] - } - ] - : []) + } ]; export const adminNavSections = (env?: Env): SidebarNavSection[] => [ diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 3095b1fd..ac75047c 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 { ExternalLink, Server } from "lucide-react"; +import { ArrowRight, ExternalLink, Server } from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -146,36 +146,6 @@ export function LayoutSidebar({ />
- {!isAdminPage && user.serverAdmin && ( -
- - - - - {!isSidebarCollapsed && ( - {t("serverAdmin")} - )} - -
- )}
+ {!isAdminPage && user.serverAdmin && ( +
+ + + + + {!isSidebarCollapsed && ( + <> + {t("serverAdmin")} + + + )} + +
+ )} +
diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index cacaf553..25ec3b5c 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -71,7 +71,7 @@ export function OrgSelector({ "cursor-pointer transition-colors", isCollapsed ? "w-full h-16 flex items-center justify-center hover:bg-muted" - : "w-full px-4 py-4 hover:bg-muted" + : "w-full px-5 py-4 hover:bg-muted" )} > {isCollapsed ? ( From 01c15afa747fd9727ad980888868fdcfecd24581 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Feb 2026 23:41:04 -0800 Subject: [PATCH 09/52] other visual adjustments --- src/components/Credenza.tsx | 2 +- src/components/RolesTable.tsx | 78 ++++++++++++++++++----------------- src/components/SidebarNav.tsx | 8 ++-- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 2a59eca1..919ee495 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => { return ( , cell: ({ row }) => { const roleRow = row.original; + const isAdmin = roleRow.isAdmin; return ( - !roleRow.isAdmin && ( -
- - - - - - { - setRoleToRemove(roleRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - -
- ) +
+ + + + + + { + setRoleToRemove(roleRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
); } } diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index dfb63b3e..087f510b 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -128,7 +128,7 @@ function CollapsibleNavItem({ disabled={isDisabled} > {item.icon && ( - + {item.icon} )} @@ -306,7 +306,7 @@ export function SidebarNav({ {item.icon && level === 0 && ( @@ -366,7 +366,7 @@ export function SidebarNav({ )} > {item.icon && level === 0 && ( - + {item.icon} )} @@ -423,7 +423,7 @@ export function SidebarNav({ disabled={isDisabled} > {item.icon && ( - + {item.icon} )} From 6442eb12fb5455e664055f401b9d2f780d4deb08 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 20 Feb 2026 10:43:25 -0800 Subject: [PATCH 10/52] more visual adjustments --- messages/en-US.json | 2 +- .../settings/clients/user/[niceId]/layout.tsx | 2 +- src/app/[orgId]/settings/general/layout.tsx | 2 +- .../settings/sites/[niceId]/layout.tsx | 2 +- src/components/Layout.tsx | 2 +- src/components/LayoutHeader.tsx | 4 +- src/components/LayoutMobileMenu.tsx | 4 +- src/components/LayoutSidebar.tsx | 10 +- src/components/OrgSelector.tsx | 101 +++++++++--------- src/components/SidebarNav.tsx | 10 +- 10 files changed, 69 insertions(+), 70 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 92334feb..b7341839 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2099,7 +2099,7 @@ "manageMachineClients": "Manage Machine Clients", "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", "machineClientsBannerTitle": "Servers & Automated Systems", - "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.", + "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Container", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx index 7d8059aa..2d9934cb 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { /> -
+
{children}
diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index b69969f4..736e2037 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({ description={t("orgSettingsDescription")} /> -
+
{children} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 30f9eff8..2554403e 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { /> -
+
{children}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a7704c8f..90c7f093 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 bef01685..0b716e1e 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/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 2b5fb320..f24c2f13 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -73,14 +73,14 @@ export function LayoutMobileMenu({ {t("navbarDescription")}
-
+
-
+
{!isAdminPage && user.serverAdmin && (
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index ac75047c..7b5bda60 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -145,7 +145,7 @@ export function LayoutSidebar({ )} />
-
+
{!isSidebarCollapsed && ( <> - {t("serverAdmin")} + + {t("serverAdmin")} + )} diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index 25ec3b5c..c0969e7e 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -6,8 +6,7 @@ import { CommandGroup, CommandInput, CommandItem, - CommandList, - CommandSeparator + CommandList } from "@app/components/ui/command"; import { Popover, @@ -25,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; +import { Button } from "@app/components/ui/button"; import { usePathname, useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -93,50 +93,32 @@ export function OrgSelector({ )}
- - + + {/* Peak pointing up to the trigger */} +
+
+ - -
- {t("orgNotFound2")} -
-
- {(!env.flags.disableUserCreateOrg || user.serverAdmin) && ( - <> - - - { - setOpen(false); - router.push("/setup"); - }} - className="mx-2 rounded-md" - > -
- -
-
- - {t("setupNewOrg")} - - - {t("createNewOrgDescription")} - -
-
-
-
- - - )} - - + + +
+ {t("orgNotFound2")} +
+
+ {sortedOrgs.map((org) => ( -
- +
+
-
- +
+ {org.name}
@@ -173,7 +155,7 @@ export function OrgSelector({
))} - - + + + {(!env.flags.disableUserCreateOrg || + user.serverAdmin) && ( +
+ +
+ )} ); diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 087f510b..c6c526bf 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -122,7 +122,7 @@ function CollapsibleNavItem({ "px-3 py-1.5", isActive ? "bg-secondary font-medium" - : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", + : "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} disabled={isDisabled} @@ -290,7 +290,7 @@ export function SidebarNav({ isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isActive ? "bg-secondary font-medium" - : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", + : "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} onClick={(e) => { @@ -361,7 +361,7 @@ export function SidebarNav({ className={cn( "flex items-center rounded-md transition-colors", "px-3 py-1.5", - "text-muted-foreground", + "text-foreground/80", isDisabled && "cursor-not-allowed opacity-60" )} > @@ -416,7 +416,7 @@ export function SidebarNav({ "flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full", isActive || isChildActive ? "bg-secondary font-medium" - : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", + : "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} @@ -471,7 +471,7 @@ export function SidebarNav({ "flex items-center rounded-md transition-colors px-3 py-1.5 text-sm", childIsActive ? "bg-secondary font-medium" - : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground", + : "text-foreground/80 hover:bg-secondary/50 hover:text-foreground", childIsDisabled && "cursor-not-allowed opacity-60" )} From d6ba34aeeacb90b29171e0cef8e2bac893daf6cf Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 20 Feb 2026 17:33:21 -0800 Subject: [PATCH 11/52] 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`} From 3f2bdf081fddeb2e0b895704fe20a0e9975a6f56 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 20 Feb 2026 20:25:14 -0800 Subject: [PATCH 12/52] allow edit admin role ssh and support null authDaemonMode --- server/db/sqlite/schema/schema.ts | 2 +- server/routers/org/createOrg.ts | 3 ++- server/routers/role/updateRole.ts | 14 ++++++-------- src/components/Credenza.tsx | 2 +- src/components/EditRoleForm.tsx | 6 ++++-- src/components/InternalResourceForm.tsx | 6 +++--- src/components/RoleForm.tsx | 25 +++++++++++++++++++++---- src/components/RolesTable.tsx | 1 - 8 files changed, 38 insertions(+), 21 deletions(-) diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 42e568f9..3f483a11 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -685,7 +685,7 @@ export const roles = sqliteTable("roles", { sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands" sshSudoCommands: text("sshSudoCommands").default("[]"), sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( - false + true ), sshUnixGroups: text("sshUnixGroups").default("[]") }); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 729cf211..1a5d8799 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -272,7 +272,8 @@ export async function createOrg( orgId: newOrg[0].orgId, isAdmin: true, name: "Admin", - description: "Admin role with the most permissions" + description: "Admin role with the most permissions", + sshSudoMode: "full" }) .returning({ roleId: roles.roleId }); diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 66332bf2..7400e582 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -102,16 +102,14 @@ export async function updateRole( ); } - if (role[0].isAdmin) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `Cannot update a Admin role` - ) - ); + const orgId = role[0].orgId; + const isAdminRole = role[0].isAdmin; + + if (isAdminRole) { + delete updateData.name; + delete updateData.description; } - const orgId = role[0].orgId; if (!orgId) { return next( createHttpError( diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 919ee495..63b84746 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => { return ( (null); @@ -466,7 +466,7 @@ export function InternalResourceForm({ tcpPortRangeString: resource.tcpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*", disableIcmp: resource.disableIcmp ?? false, - authDaemonMode: resource.authDaemonMode ?? null, + authDaemonMode: resource.authDaemonMode ?? "site", authDaemonPort: resource.authDaemonPort ?? null, roles: [], users: [], diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index 7333ec87..8fe2c55e 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -138,6 +138,7 @@ export function RoleForm({ const sshDisabled = !isPaidUser(tierMatrix.sshPam); const sshSudoMode = form.watch("sshSudoMode"); + const isAdminRole = variant === "edit" && role?.isAdmin === true; useEffect(() => { if (sshDisabled) { @@ -161,7 +162,11 @@ export function RoleForm({ {t("accessRoleName")} - + @@ -174,7 +179,11 @@ export function RoleForm({ {t("description")} - + @@ -203,7 +212,11 @@ export function RoleForm({ {t("accessRoleName")} - + @@ -218,7 +231,11 @@ export function RoleForm({ {t("description")} - + diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index b93e4df1..bf17f63f 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -135,7 +135,6 @@ export default function UsersTable({ roles }: RolesTableProps) { - - - - - {t("accessUsersManage")} - - - {`${userRow.username}-${userRow.idpId}` !== - `${user?.username}-${user?.idpId}` && ( - { - setIsDeleteModalOpen( - true - ); - setSelectedUser( - userRow - ); - }} - > - - {t("accessUserRemove")} - - - )} - - - - )} + + + + + + + isDisabled && e.preventDefault() + } + > + + {t("accessUsersManage")} + + + {!isDisabled && ( + { + setIsDeleteModalOpen(true); + setSelectedUser(userRow); + }} + > + + {t("accessUserRemove")} + + + )} + +
- {!userRow.isOwner && ( + {isDisabled ? ( + + ) : ( + + + )} + {/* Your Plan Section */} @@ -692,22 +769,50 @@ export default function BillingPage() {
- + {isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? ( + + +
+ +
+
+ +

{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}

+
+
+ ) : ( + + )}
); From c36efe7f144909e6e9ee793985354fd402abccb4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Feb 2026 21:02:21 -0800 Subject: [PATCH 37/52] Add translations --- messages/en-US.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index f12e2210..d872d8e3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Feature Availability Notice", "billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.", "billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})", + "billingPastDueTitle": "Payment Past Due", + "billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.", + "billingUnpaidTitle": "Subscription Unpaid", + "billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.", + "billingIncompleteTitle": "Payment Incomplete", + "billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.", + "billingIncompleteExpiredTitle": "Payment Expired", + "billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.", + "billingManageSubscription": "Manage your subscription", + "billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", From b28391feae5c63481283ca5315662ac63e611e10 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:19 -0800 Subject: [PATCH 38/52] New translations en-us.json (French) --- messages/fr-FR.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 1ee3c645..004354f1 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Avis de disponibilité des fonctionnalités", "billingFeatureLossDescription": "En rétrogradant, les fonctionnalités non disponibles dans le nouveau plan seront automatiquement désactivées. Certains paramètres et configurations peuvent être perdus. Veuillez consulter la matrice de prix pour comprendre quelles fonctionnalités ne seront plus disponibles.", "billingUsageExceedsLimit": "L'utilisation actuelle ({current}) dépasse la limite ({limit})", + "billingPastDueTitle": "Paiement en retard", + "billingPastDueDescription": "Votre paiement est échu. Veuillez mettre à jour votre méthode de paiement pour continuer à utiliser les fonctionnalités de votre plan actuel. Si non résolu, votre abonnement sera annulé et vous serez remis au niveau gratuit.", + "billingUnpaidTitle": "Abonnement impayé", + "billingUnpaidDescription": "Votre abonnement est impayé et vous avez été reversé au niveau gratuit. Veuillez mettre à jour votre méthode de paiement pour restaurer votre abonnement.", + "billingIncompleteTitle": "Paiement incomplet", + "billingIncompleteDescription": "Votre paiement est incomplet. Veuillez compléter le processus de paiement pour activer votre abonnement.", + "billingIncompleteExpiredTitle": "Paiement expiré", + "billingIncompleteExpiredDescription": "Votre paiement n'a jamais été complété et a expiré. Vous avez été restauré au niveau gratuit. Veuillez vous abonner à nouveau pour restaurer l'accès aux fonctionnalités payantes.", + "billingManageSubscription": "Gérer votre abonnement", + "billingResolvePaymentIssue": "Veuillez résoudre votre problème de paiement avant de procéder à la mise à niveau ou à la rétrogradation", "signUpTerms": { "IAgreeToThe": "Je suis d'accord avec", "termsOfService": "les conditions d'utilisation", From 593c5db0e803a6a197ddba19d9a2cd8156ba4a75 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:20 -0800 Subject: [PATCH 39/52] New translations en-us.json (Spanish) --- messages/es-ES.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/es-ES.json b/messages/es-ES.json index f3888e8b..6bb73cda 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad", "billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.", "billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})", + "billingPastDueTitle": "Pago vencido", + "billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.", + "billingUnpaidTitle": "Suscripción no pagada", + "billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.", + "billingIncompleteTitle": "Pago incompleto", + "billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.", + "billingIncompleteExpiredTitle": "Pago expirado", + "billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.", + "billingManageSubscription": "Administra tu suscripción", + "billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación", "signUpTerms": { "IAgreeToThe": "Estoy de acuerdo con los", "termsOfService": "términos del servicio", From 8712c1719e07a0c7b149a82db03d38b701cdcd20 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:22 -0800 Subject: [PATCH 40/52] New translations en-us.json (Bulgarian) --- messages/bg-BG.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 3fed7e09..60e4b401 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Уведомление за наличност на функциите", "billingFeatureLossDescription": "Чрез понижението на плана, функциите, недостъпни в новия план, ще бъдат автоматично деактивирани. Някои настройки и конфигурации може да бъдат загубени. Моля, прегледайте ценовата матрица, за да разберете кои функции вече няма да са на разположение.", "billingUsageExceedsLimit": "Текущото използване ({current}) надвишава ограничението ({limit})", + "billingPastDueTitle": "Плащането е просрочено", + "billingPastDueDescription": "Вашето плащане е просрочено. Моля, актуализирайте метода на плащане, за да продължите да използвате настоящия си план. Ако проблемът не бъде разрешен, абонаментът ви ще бъде прекратен и ще бъдете прехвърлени на безплатния план.", + "billingUnpaidTitle": "Абонаментът не е платен", + "billingUnpaidDescription": "Вашият абонамент не е платен и сте прехвърлени на безплатния план. Моля, актуализирайте метода на плащане, за да възстановите вашия абонамент.", + "billingIncompleteTitle": "Плащането е непълно", + "billingIncompleteDescription": "Вашето плащане е непълно. Моля, завършете процеса на плащане, за да активирате вашия абонамент.", + "billingIncompleteExpiredTitle": "Плащането е изтекло", + "billingIncompleteExpiredDescription": "Вашето плащане никога не е завършено и е изтекло. Прехвърлени сте на безплатния план. Моля, абонирайте се отново, за да възстановите достъпа до платените функции.", + "billingManageSubscription": "Управлявайте вашия абонамент", + "billingResolvePaymentIssue": "Моля, разрешете проблема с плащането преди да извършите надграждане или понижение", "signUpTerms": { "IAgreeToThe": "Съгласен съм с", "termsOfService": "условията за ползване", From da3e68a20b97239427e8d0c67b7ce724c1ce3b5a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:23 -0800 Subject: [PATCH 41/52] New translations en-us.json (Czech) --- messages/cs-CZ.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 5d5ffc83..b7666db1 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Upozornění na dostupnost funkce", "billingFeatureLossDescription": "Po pomenutí budou funkce v novém plánu automaticky zakázány. Některá nastavení a konfigurace mohou být ztraceny. Zkontrolujte cenovou matrici, abyste pochopili, které funkce již nebudou k dispozici.", "billingUsageExceedsLimit": "Aktuální využití ({current}) překračuje limit ({limit})", + "billingPastDueTitle": "Poslední splatnost platby", + "billingPastDueDescription": "Vaše platba je již splatná. Chcete-li pokračovat v používání aktuálních tarifů, aktualizujte prosím způsob platby. Pokud nebude vyřešeno, Vaše předplatné bude zrušeno a budete vráceno na úroveň zdarma.", + "billingUnpaidTitle": "Předplatné nezaplaceno", + "billingUnpaidDescription": "Vaše předplatné není zaplaceno a byli jste vráceni do bezplatné úrovně. Aktualizujte prosím svou platební metodu pro obnovení předplatného.", + "billingIncompleteTitle": "Platba nedokončena", + "billingIncompleteDescription": "Vaše platba je neúplná. Pro aktivaci předplatného prosím dokončete platební proces.", + "billingIncompleteExpiredTitle": "Platba vypršela", + "billingIncompleteExpiredDescription": "Vaše platba nebyla nikdy dokončena a vypršela. Byli jste vráceni na úroveň zdarma. Prosím, přihlašte se znovu pro obnovení přístupu k placeným funkcím.", + "billingManageSubscription": "Spravujte své předplatné", + "billingResolvePaymentIssue": "Vyřešte prosím problém s platbou před upgradem nebo upgradem", "signUpTerms": { "IAgreeToThe": "Souhlasím s", "termsOfService": "podmínky služby", From 81f5a4b127d90c8586e9077bc8e6980e9291bf46 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:24 -0800 Subject: [PATCH 42/52] New translations en-us.json (German) --- messages/de-DE.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/de-DE.json b/messages/de-DE.json index 39098d3b..15663fa4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Verfügbarkeitshinweis", "billingFeatureLossDescription": "Durch Herabstufung werden Funktionen, die im neuen Paket nicht verfügbar sind, automatisch deaktiviert. Einige Einstellungen und Konfigurationen können verloren gehen. Bitte überprüfen Sie die Preismatrix um zu verstehen, welche Funktionen nicht mehr verfügbar sein werden.", "billingUsageExceedsLimit": "Aktuelle Nutzung ({current}) überschreitet das Limit ({limit})", + "billingPastDueTitle": "Zahlung vergangene Fälligkeit", + "billingPastDueDescription": "Ihre Zahlung ist abgelaufen. Bitte aktualisieren Sie Ihre Zahlungsmethode, um die aktuellen Funktionen Ihres Pakets weiter zu nutzen. Wenn nicht geklärt, wird Ihr Abonnement abgebrochen und Sie werden auf die kostenlose Stufe zurückgekehrt.", + "billingUnpaidTitle": "Unbezahltes Abonnement", + "billingUnpaidDescription": "Dein Abonnement ist unbezahlt und du wurdest auf die kostenlose Stufe zurückgekehrt. Bitte aktualisiere deine Zahlungsmethode, um dein Abonnement wiederherzustellen.", + "billingIncompleteTitle": "Zahlung unvollständig", + "billingIncompleteDescription": "Ihre Zahlung ist unvollständig. Bitte schließen Sie den Zahlungsvorgang ab, um Ihr Abonnement zu aktivieren.", + "billingIncompleteExpiredTitle": "Zahlung abgelaufen", + "billingIncompleteExpiredDescription": "Deine Zahlung wurde nie abgeschlossen und ist abgelaufen. Du wurdest zur kostenlosen Stufe zurückgekehrt. Bitte melde dich erneut an, um den Zugriff auf kostenpflichtige Funktionen wiederherzustellen.", + "billingManageSubscription": "Verwalten Sie Ihr Abonnement", + "billingResolvePaymentIssue": "Bitte beheben Sie Ihr Zahlungsproblem vor dem Upgrade oder Herabstufen", "signUpTerms": { "IAgreeToThe": "Ich stimme den", "termsOfService": "Nutzungsbedingungen zu", From 5d9c66d22dfd0d9a984f29c77325c2b35e8b1ff1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:26 -0800 Subject: [PATCH 43/52] New translations en-us.json (Italian) --- messages/it-IT.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/it-IT.json b/messages/it-IT.json index c0786a0c..5a60a296 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Avviso Di Disponibilità Caratteristica", "billingFeatureLossDescription": "Con il downgrading, le funzioni non disponibili nel nuovo piano saranno disattivate automaticamente. Alcune impostazioni e configurazioni potrebbero andare perse. Controlla la matrice dei prezzi per capire quali funzioni non saranno più disponibili.", "billingUsageExceedsLimit": "L'utilizzo corrente ({current}) supera il limite ({limit})", + "billingPastDueTitle": "Pagamento Scaduto", + "billingPastDueDescription": "Il pagamento è scaduto. Si prega di aggiornare il metodo di pagamento per continuare a utilizzare le funzioni del piano corrente. Se non risolto, il tuo abbonamento verrà annullato e verrai ripristinato al livello gratuito.", + "billingUnpaidTitle": "Abbonamento Non Pagato", + "billingUnpaidDescription": "Il tuo abbonamento non è pagato e sei stato restituito al livello gratuito. Per favore aggiorna il metodo di pagamento per ripristinare l'abbonamento.", + "billingIncompleteTitle": "Pagamento Incompleto", + "billingIncompleteDescription": "Il pagamento è incompleto. Si prega di completare il processo di pagamento per attivare il tuo abbonamento.", + "billingIncompleteExpiredTitle": "Pagamento Scaduto", + "billingIncompleteExpiredDescription": "Il tuo pagamento non è mai stato completato ed è scaduto. Sei stato ripristinato al livello gratuito. Si prega di iscriversi nuovamente per ripristinare l'accesso alle funzionalità a pagamento.", + "billingManageSubscription": "Gestisci il tuo abbonamento", + "billingResolvePaymentIssue": "Si prega di risolvere il problema di pagamento prima di aggiornare o declassare", "signUpTerms": { "IAgreeToThe": "Accetto i", "termsOfService": "termini di servizio", From 6af06a38aeb56b7c9d87a65c5a540e5c5d0b8995 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:27 -0800 Subject: [PATCH 44/52] New translations en-us.json (Korean) --- messages/ko-KR.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index fddab00c..03865810 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "기능 가용성 알림", "billingFeatureLossDescription": "다운그레이드함으로써 새 계획에서 사용할 수 없는 기능은 자동으로 비활성화됩니다. 일부 설정 및 구성은 손실될 수 있습니다. 어떤 기능들이 더 이상 사용 불가능한지 이해하기 위해 가격표를 검토하세요.", "billingUsageExceedsLimit": "현재 사용량 ({current})이 제한 ({limit})을 초과합니다", + "billingPastDueTitle": "연체된 결제", + "billingPastDueDescription": "결제가 연체되었습니다. 현재 이용 중인 플랜 기능을 계속 사용하기 위해 결제 수단을 업데이트해 주세요. 해결되지 않으면 구독이 취소되고 무료 요금제로 전환됩니다.", + "billingUnpaidTitle": "결제되지 않은 구독", + "billingUnpaidDescription": "구독 결제가 완료되지 않아 무료 요금제로 전환되었습니다. 구독을 복원하려면 결제 수단을 업데이트해 주세요.", + "billingIncompleteTitle": "불완전한 결제", + "billingIncompleteDescription": "결제가 불완전합니다. 구독을 활성화하기 위해 결제 과정을 완료해 주세요.", + "billingIncompleteExpiredTitle": "만료된 결제", + "billingIncompleteExpiredDescription": "결제가 완료되지 않아 만료되었습니다. 무료 요금제로 전환되었습니다. 유료 기능에 대한 액세스를 복원하려면 다시 구독해 주세요.", + "billingManageSubscription": "구독을 관리하십시오", + "billingResolvePaymentIssue": "업그레이드 또는 다운그레이드하기 전에 결제 문제를 해결해 주세요.", "signUpTerms": { "IAgreeToThe": "동의합니다", "termsOfService": "서비스 약관", From 01fdd41a103d4281bb02ef970e6ff72d1b75d64a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:28 -0800 Subject: [PATCH 45/52] New translations en-us.json (Dutch) --- messages/nl-NL.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 51fac33d..caa2ed17 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Kennisgeving beschikbaarheid", "billingFeatureLossDescription": "Door downgraden worden functies die niet beschikbaar zijn in het nieuwe abonnement automatisch uitgeschakeld. Sommige instellingen en configuraties kunnen verloren gaan. Raadpleeg de prijsmatrix om te begrijpen welke functies niet langer beschikbaar zijn.", "billingUsageExceedsLimit": "Huidig gebruik ({current}) overschrijdt limiet ({limit})", + "billingPastDueTitle": "Vervaldatum betaling", + "billingPastDueDescription": "Uw betaling is verlopen. Werk uw betaalmethode bij om uw huidige abonnementsfuncties te blijven gebruiken. Als dit niet is opgelost, zal je abonnement worden geannuleerd en zal je worden teruggezet naar de vrije rang.", + "billingUnpaidTitle": "Abonnement Onbetaald", + "billingUnpaidDescription": "Uw abonnement is niet betaald en u bent teruggekeerd naar het gratis niveau. Update uw betalingsmethode om uw abonnement te herstellen.", + "billingIncompleteTitle": "Betaling onvolledig", + "billingIncompleteDescription": "Uw betaling is onvolledig. Voltooi alstublieft het betalingsproces om uw abonnement te activeren.", + "billingIncompleteExpiredTitle": "Betaling verlopen", + "billingIncompleteExpiredDescription": "Uw betaling is nooit voltooid en verlopen. U bent teruggekeerd naar de gratis niveaus. Abonneer u opnieuw om de toegang tot betaalde functies te herstellen.", + "billingManageSubscription": "Beheer uw abonnement", + "billingResolvePaymentIssue": "Gelieve uw betalingsprobleem op te lossen voor het upgraden of downgraden", "signUpTerms": { "IAgreeToThe": "Ik ga akkoord met de", "termsOfService": "servicevoorwaarden", From e6e92dbc0fc9a0925fef266b220aab920658bd24 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:30 -0800 Subject: [PATCH 46/52] New translations en-us.json (Polish) --- messages/pl-PL.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index e1f6256c..6203f4cc 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Powiadomienie o dostępności funkcji", "billingFeatureLossDescription": "Po obniżeniu wartości funkcje niedostępne w nowym planie zostaną automatycznie wyłączone. Niektóre ustawienia i konfiguracje mogą zostać utracone. Zapoznaj się z matrycą cenową, aby zrozumieć, które funkcje nie będą już dostępne.", "billingUsageExceedsLimit": "Bieżące użycie ({current}) przekracza limit ({limit})", + "billingPastDueTitle": "Płatność w przeszłości", + "billingPastDueDescription": "Twoja płatność jest zaległa. Zaktualizuj metodę płatności, aby kontynuować korzystanie z funkcji aktualnego planu. Jeśli nie zostanie rozwiązana, Twoja subskrypcja zostanie anulowana i zostaniesz przywrócony do darmowego poziomu.", + "billingUnpaidTitle": "Subskrypcja niezapłacona", + "billingUnpaidDescription": "Twoja subskrypcja jest niezapłacona i została przywrócona do darmowego poziomu. Zaktualizuj swoją metodę płatności, aby przywrócić subskrypcję.", + "billingIncompleteTitle": "Płatność niezakończona", + "billingIncompleteDescription": "Twoja płatność jest niekompletna. Ukończ proces płatności, aby aktywować subskrypcję.", + "billingIncompleteExpiredTitle": "Płatność wygasła", + "billingIncompleteExpiredDescription": "Twoja płatność nigdy nie została zakończona i wygasła. Zostałeś przywrócony do darmowego poziomu. Zapisz się ponownie, aby przywrócić dostęp do płatnych funkcji.", + "billingManageSubscription": "Zarządzaj subskrypcją", + "billingResolvePaymentIssue": "Rozwiąż problem z płatnościami przed aktualizacją lub obniżeniem oceny", "signUpTerms": { "IAgreeToThe": "Zgadzam się z", "termsOfService": "warunkami usługi", From b5333a36862ba878d7f454435975f50f607cf3f0 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:31 -0800 Subject: [PATCH 47/52] New translations en-us.json (Portuguese) --- messages/pt-PT.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 89f433cc..b623b2b2 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Aviso de disponibilidade de recursos", "billingFeatureLossDescription": "Ao fazer o downgrading, recursos não disponíveis no novo plano serão desativados automaticamente. Algumas configurações e configurações podem ser perdidas. Por favor, revise a matriz de preços para entender quais características não estarão mais disponíveis.", "billingUsageExceedsLimit": "Uso atual ({current}) excede o limite ({limit})", + "billingPastDueTitle": "Pagamento passado devido", + "billingPastDueDescription": "Seu pagamento está vencido. Por favor, atualize seu método de pagamento para continuar usando os recursos do seu plano atual. Se não for resolvido, sua assinatura será cancelada e você será revertido para o nível gratuito.", + "billingUnpaidTitle": "Assinatura não paga", + "billingUnpaidDescription": "Sua assinatura não foi paga e você voltou para o nível gratuito. Atualize o seu método de pagamento para restaurar sua assinatura.", + "billingIncompleteTitle": "Pagamento Incompleto", + "billingIncompleteDescription": "Seu pagamento está incompleto. Por favor, complete o processo de pagamento para ativar sua assinatura.", + "billingIncompleteExpiredTitle": "Pagamento expirado", + "billingIncompleteExpiredDescription": "Seu pagamento nunca foi concluído e expirou. Você foi revertido para o nível gratuito. Por favor, inscreva-se novamente para restaurar o acesso a recursos pagos.", + "billingManageSubscription": "Gerencie sua assinatura", + "billingResolvePaymentIssue": "Por favor, resolva seu problema de pagamento antes de atualizar ou rebaixar", "signUpTerms": { "IAgreeToThe": "Concordo com", "termsOfService": "os termos de serviço", From 8ef72207664ad715211d582b29efca3ffbac5a19 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:33 -0800 Subject: [PATCH 48/52] New translations en-us.json (Russian) --- messages/ru-RU.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 7aa253db..f4dd0ac3 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Уведомление о доступности функций", "billingFeatureLossDescription": "При переходе на другой тарифный план функции не будут автоматически отключены. Некоторые настройки и конфигурации могут быть потеряны. Пожалуйста, ознакомьтесь с матрицей ценообразования, чтобы понять, какие функции больше не будут доступны.", "billingUsageExceedsLimit": "Текущее использование ({current}) превышает предел ({limit})", + "billingPastDueTitle": "Платеж просрочен", + "billingPastDueDescription": "Ваш платеж просрочен. Пожалуйста, обновите способ оплаты, чтобы продолжить использовать текущие функции. Если ваша подписка не будет решена, она будет отменена, и вы вернетесь к бесплатному уровню.", + "billingUnpaidTitle": "Подписка не оплачена", + "billingUnpaidDescription": "Ваша подписка не оплачена, и вы были возвращены к бесплатному уровню. Пожалуйста, обновите способ оплаты, чтобы восстановить вашу подписку.", + "billingIncompleteTitle": "Платеж не завершен", + "billingIncompleteDescription": "Ваш платеж не завершен. Пожалуйста, завершите процесс оплаты, чтобы активировать вашу подписку.", + "billingIncompleteExpiredTitle": "Платеж просрочен", + "billingIncompleteExpiredDescription": "Ваш платеж не был завершен и истек. Вы были возвращены к бесплатному уровню. Пожалуйста, подпишитесь снова, чтобы восстановить доступ к платным функциям.", + "billingManageSubscription": "Управление подпиской", + "billingResolvePaymentIssue": "Пожалуйста, решите проблему оплаты перед обновлением или понижением сорта", "signUpTerms": { "IAgreeToThe": "Я согласен с", "termsOfService": "условия использования", From 8cd51df1e106935cc6d8b454454adbb901905d42 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:34 -0800 Subject: [PATCH 49/52] New translations en-us.json (Turkish) --- messages/tr-TR.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 4d3b7027..f853629d 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Özellik Kullanılabilirlik Bildirimi", "billingFeatureLossDescription": "Plan düşürüldüğünde, yeni planda mevcut olmayan özellikler otomatik olarak devre dışı bırakılacaktır. Bazı ayarlar ve yapılar kaybolabilir. Hangi özelliklerin artık mevcut olmayacağını anlamak için fiyat tablosunu inceleyiniz.", "billingUsageExceedsLimit": "Mevcut kullanım ({current}) limitleri ({limit}) aşıyor", + "billingPastDueTitle": "Ödeme Geçmiş", + "billingPastDueDescription": "Ödemenizın vadesi geçti. Mevcut plan özelliklerinizi kullanmaya devam etmek için lütfen ödeme yöntemini güncelleyin. Sorun çözülmezse aboneliğiniz iptal edilecek ve ücretsiz seviyeye dönüleceksiniz.", + "billingUnpaidTitle": "Ödenmemiş Abonelik", + "billingUnpaidDescription": "Aboneliğiniz ödenmedi ve ücretsiz seviyeye geri döndünüz. Aboneliğinizi geri yüklemek için lütfen ödeme yöntemini güncelleyin.", + "billingIncompleteTitle": "Eksik Ödeme", + "billingIncompleteDescription": "Ödemeniz eksik. Aboneliğinizi etkinleştirmek için lütfen ödeme sürecini tamamlayın.", + "billingIncompleteExpiredTitle": "Ödeme Süresi Doldu", + "billingIncompleteExpiredDescription": "Ödemeniz hiç tamamlanmadı ve süresi doldu. Ücretsiz seviyeye geri döndünüz. Ücretli özelliklere erişimi yeniden sağlamak için lütfen yeniden abone olun.", + "billingManageSubscription": "Aboneliğinizi Yönetin", + "billingResolvePaymentIssue": "Yükseltmeden veya düşürmeden önce ödeme sorunuzu çözün", "signUpTerms": { "IAgreeToThe": "Kabul ediyorum", "termsOfService": "hizmet şartları", From cb86ad410494b3e9119435205a955349c3981bf0 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:35 -0800 Subject: [PATCH 50/52] New translations en-us.json (Chinese Simplified) --- messages/zh-CN.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 6956af62..29fe8039 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "功能可用通知", "billingFeatureLossDescription": "如果降级,新计划中不可用的功能将被自动禁用。一些设置和配置可能会丢失。 请查看定价矩阵以了解哪些功能将不再可用。", "billingUsageExceedsLimit": "当前使用量 ({current}) 超出限制 ({limit})", + "billingPastDueTitle": "过去到期的付款", + "billingPastDueDescription": "您的付款已过期。请更新您的付款方法以继续使用您当前的计划功能。 如果不解决,您的订阅将被取消,您将被恢复到免费等级。", + "billingUnpaidTitle": "订阅未付款", + "billingUnpaidDescription": "您的订阅未付,您已恢复到免费等级。请更新您的付款方法以恢复您的订阅。", + "billingIncompleteTitle": "付款不完成", + "billingIncompleteDescription": "您的付款不完整。请完成付款过程以激活您的订阅。", + "billingIncompleteExpiredTitle": "付款已过期", + "billingIncompleteExpiredDescription": "您的付款尚未完成且已过期。您已恢复到免费级别。请再次订阅以恢复对已支付功能的访问。", + "billingManageSubscription": "管理您的订阅", + "billingResolvePaymentIssue": "请在升级或降级之前解决您的付款问题", "signUpTerms": { "IAgreeToThe": "我同意", "termsOfService": "服务条款", From d7608b1cc8cc25ccc423f3ff696733d0f82ed4e9 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 22 Feb 2026 21:11:37 -0800 Subject: [PATCH 51/52] New translations en-us.json (Norwegian Bokmal) --- messages/nb-NO.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 24e34e15..503f4265 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1572,6 +1572,16 @@ "billingFeatureLossWarning": "Fremhev tilgjengelig varsel", "billingFeatureLossDescription": "Ved å nedgradere vil funksjoner som ikke er tilgjengelige i den nye planen automatisk bli deaktivert. Noen innstillinger og konfigurasjoner kan gå tapt. Vennligst gjennomgå prismatrisen for å forstå hvilke funksjoner som ikke lenger vil være tilgjengelige.", "billingUsageExceedsLimit": "Gjeldende bruk ({current}) overskrider grensen ({limit})", + "billingPastDueTitle": "Betalingen har forfalt", + "billingPastDueDescription": "Betalingen er forfalt. Vennligst oppdater betalingsmetoden din for å fortsette å bruke den gjeldende funksjonsplanen din. Hvis du ikke har løst deg, vil abonnementet ditt avbrytes, og du vil bli tilbakestilt til gratistiden.", + "billingUnpaidTitle": "Abonnement ubetalt", + "billingUnpaidDescription": "Ditt abonnement er ubetalt og du har blitt tilbakestilt til gratis kasse. Vennligst oppdater din betalingsmetode for å gjenopprette abonnementet.", + "billingIncompleteTitle": "Betaling ufullstendig", + "billingIncompleteDescription": "Betalingen er ufullstendig. Vennligst fullfør betalingsprosessen for å aktivere abonnementet.", + "billingIncompleteExpiredTitle": "Betaling utløpt", + "billingIncompleteExpiredDescription": "Din betaling ble aldri fullført, og har utløpt. Du har blitt tilbakestilt til gratis dekk. Vennligst abonner på nytt for å gjenopprette tilgangen til betalte funksjoner.", + "billingManageSubscription": "Administrere ditt abonnement", + "billingResolvePaymentIssue": "Vennligst løs ditt betalingsproblem før du oppgraderer eller nedgraderer betalingen", "signUpTerms": { "IAgreeToThe": "Jeg godtar", "termsOfService": "brukervilkårene", From a502780c9b6065057d29a467af3af2583a95e077 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 22 Feb 2026 22:04:21 -0800 Subject: [PATCH 52/52] Fix sso username issue --- server/lib/blueprints/clientResources.ts | 12 +++++++++--- server/lib/blueprints/proxyResources.ts | 22 ++++++++++++++++++---- server/lib/blueprints/types.ts | 4 ++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 64de9867..80c691c6 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -11,7 +11,7 @@ import { userSiteResources } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray } from "drizzle-orm"; +import { eq, and, ne, inArray, or } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; @@ -142,7 +142,10 @@ export async function updateClientResources( .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( - inArray(users.username, resourceData.users), + or( + inArray(users.username, resourceData.users), + inArray(users.email, resourceData.users) + ), eq(userOrgs.orgId, orgId) ) ); @@ -276,7 +279,10 @@ export async function updateClientResources( .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( - inArray(users.username, resourceData.users), + or( + inArray(users.username, resourceData.users), + inArray(users.email, resourceData.users) + ), eq(userOrgs.orgId, orgId) ) ); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 55a7712b..2696b68c 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -212,7 +212,10 @@ export async function updateProxyResources( } else { // Update existing resource - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.maintencePage + ); if (!isLicensed) { resourceData.maintenance = undefined; } @@ -590,7 +593,10 @@ export async function updateProxyResources( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || existingRule.value !== - getRuleValue(rule.match.toUpperCase(), rule.value) || + getRuleValue( + rule.match.toUpperCase(), + rule.value + ) || existingRule.priority !== intendedPriority ) { validateRule(rule); @@ -648,7 +654,10 @@ export async function updateProxyResources( ); } - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage); + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.maintencePage + ); if (!isLicensed) { resourceData.maintenance = undefined; } @@ -935,7 +944,12 @@ async function syncUserResources( .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .where(and(eq(users.username, username), eq(userOrgs.orgId, orgId))) + .where( + and( + or(eq(users.username, username), eq(users.email, username)), + eq(userOrgs.orgId, orgId) + ) + ) .limit(1); if (!user) { diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index edf4b0c7..2239e4f9 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -69,7 +69,7 @@ export const AuthSchema = z.object({ .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in sso-roles" }), - "sso-users": z.array(z.email()).optional().default([]), + "sso-users": z.array(z.string()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]), "auto-login-idp": z.int().positive().optional() }); @@ -335,7 +335,7 @@ export const ClientResourceSchema = z .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in roles" }), - users: z.array(z.email()).optional().default([]), + users: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]) }) .refine(