diff --git a/messages/en-US.json b/messages/en-US.json index 7642419c6..5c86aabec 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2113,9 +2113,11 @@ "addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", - "domainPickerFreeProvidedDomain": "Free Provided Domain", + "domainPickerFreeProvidedDomain": "Provided Domain", + "domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", + "domainPickerManual": "Manual", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c76dcd95b..0756ea665 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -19,7 +19,8 @@ export enum TierFeature { SshPam = "sshPam", FullRbac = "fullRbac", SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed - SIEM = "siem" // handle downgrade by disabling SIEM integrations + SIEM = "siem", // handle downgrade by disabling SIEM integrations + DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces } export const tierMatrix: Record = { @@ -56,5 +57,6 @@ export const tierMatrix: Record = { [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], - [TierFeature.SIEM]: ["enterprise"] + [TierFeature.SIEM]: ["enterprise"], + [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/private/routers/domain/checkDomainNamespaceAvailability.ts b/server/private/routers/domain/checkDomainNamespaceAvailability.ts index db9a4b46a..0bb7f8704 100644 --- a/server/private/routers/domain/checkDomainNamespaceAvailability.ts +++ b/server/private/routers/domain/checkDomainNamespaceAvailability.ts @@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi"; import { db, domainNamespaces, resources } from "@server/db"; import { inArray } from "drizzle-orm"; import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; +import { build } from "@server/build"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z.strictObject({}); const querySchema = z.strictObject({ - subdomain: z.string() + subdomain: z.string(), + // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise }); registry.registerPath({ @@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability( } const { subdomain } = parsedQuery.data; + // if ( + // build == "saas" && + // !isSubscribed(orgId!, tierMatrix.domainNamespaces) + // ) { + // // return not available + // return response(res, { + // data: { + // available: false, + // options: [] + // }, + // success: true, + // error: false, + // message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.", + // status: HttpCode.OK + // }); + // } + const namespaces = await db.select().from(domainNamespaces); let possibleDomains = namespaces.map((ns) => { const desired = `${subdomain}.${ns.domainNamespaceId}`; diff --git a/server/private/routers/domain/listDomainNamespaces.ts b/server/private/routers/domain/listDomainNamespaces.ts index 180613a85..5bbd25b1a 100644 --- a/server/private/routers/domain/listDomainNamespaces.ts +++ b/server/private/routers/domain/listDomainNamespaces.ts @@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z.strictObject({}); @@ -37,7 +40,8 @@ const querySchema = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise }); async function query(limit: number, offset: number) { @@ -99,6 +103,26 @@ export async function listDomainNamespaces( ); } + // if ( + // build == "saas" && + // !isSubscribed(orgId!, tierMatrix.domainNamespaces) + // ) { + // return response(res, { + // data: { + // domainNamespaces: [], + // pagination: { + // total: 0, + // limit, + // offset + // } + // }, + // success: true, + // error: false, + // message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.", + // status: HttpCode.OK + // }); + // } + const domainNamespacesList = await query(limit, offset); const [{ count }] = await db diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 6cff4d23a..d8820de79 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, loginPage } from "@server/db"; +import { db, domainNamespaces, loginPage } from "@server/db"; import { domains, orgDomains, @@ -24,6 +24,8 @@ import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -112,7 +114,10 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { + if ( + req.user && + (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0) + ) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -193,6 +198,29 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { + // grandfather in existing users + const lastAllowedDate = new Date("2026-04-12"); + const userCreatedDate = new Date(req.user?.dateCreated || new Date()); + if (userCreatedDate > lastAllowedDate) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } + } + } + // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 01f3e79ff..07e566194 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, loginPage } from "@server/db"; +import { db, domainNamespaces, loginPage } from "@server/db"; import { domains, Org, @@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z if (data.headers) { // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) const validHeaderValue = /^[\t\x20-\x7E]*$/; - return data.headers.every((h) => validHeaderValue.test(h.value)); + return data.headers.every((h) => + validHeaderValue.test(h.value) + ); } return true; }, @@ -318,6 +321,34 @@ async function updateHttpResource( if (updateData.domainId) { const domainId = updateData.domainId; + if ( + build == "saas" && + !isSubscribed(resource.orgId, tierMatrix.domainNamespaces) + ) { + // grandfather in existing users + const lastAllowedDate = new Date("2026-04-12"); + const userCreatedDate = new Date( + req.user?.dateCreated || new Date() + ); + if (userCreatedDate > lastAllowedDate) { + // check if this domain id is a namespace domain and if so, reject + const domain = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (domain.length > 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature." + ) + ); + } + } + } + // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, @@ -366,7 +397,7 @@ async function updateHttpResource( ); } } - + if (build != "oss") { const existingLoginPages = await db .select() diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7ac1849b9..b11586e69 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,7 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { + orgs, + roles, + userInviteRoles, + userInvites, + userOrgs, + users +} from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -37,8 +44,7 @@ const inviteUserBodySchema = z regenerate: z.boolean().optional() }) .refine( - (d) => - (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, { message: "roleIds or roleId is required", path: ["roleIds"] } ) .transform((data) => ({ @@ -265,7 +271,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( @@ -314,12 +320,12 @@ export async function inviteUser( expiresAt, tokenHash }); - await trx.insert(userInviteRoles).values( - uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) - ); + await trx + .insert(userInviteRoles) + .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; if (doEmail) { await sendEmail( diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 3d6e6186b..a9128b9d3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({ getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => String(row.targetId), state: { pagination: { pageIndex: 0, diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index f057c07c4..f5c20d8cc 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -999,6 +999,7 @@ export default function Page() { getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + getRowId: (row) => String(row.targetId), state: { pagination: { pageIndex: 0, diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 16e7f2e1f..0a05e46d3 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -319,6 +319,7 @@ export default function DeviceLoginForm({
{selectedBaseDomain.domain} - {selectedBaseDomain.verified && ( - - )} + {selectedBaseDomain.verified && + selectedBaseDomain.domainType !== + "wildcard" && ( + + )}
) : ( t("domainPickerSelectBaseDomain") @@ -574,14 +581,23 @@ export default function DomainPicker({ } - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified + {orgDomain.type === + "wildcard" ? t( - "domainPickerVerified" + "domainPickerManual" ) - : t( - "domainPickerUnverified" + : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + )} @@ -680,6 +696,23 @@ export default function DomainPicker({ + {build === "saas" && + !hasSaasSubscription( + tierMatrix[TierFeature.DomainNamespaces] + ) && + !hideFreeDomain && ( + + +
+ + + {t("domainPickerFreeDomainsPaidFeature")} + +
+
+
+ )} + {/*showProvidedDomainSearch && build === "saas" && ( diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 417fa9892..5de8f25fd 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -39,7 +39,11 @@ export default function InviteStatusCard({ const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [type, setType] = useState< - "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded" + | "rejected" + | "wrong_user" + | "user_does_not_exist" + | "not_logged_in" + | "user_limit_exceeded" >("rejected"); useEffect(() => { @@ -90,12 +94,12 @@ export default function InviteStatusCard({ if (!user && type === "user_does_not_exist") { const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else if (!user && type === "not_logged_in") { const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } else { @@ -109,7 +113,7 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/login?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -117,7 +121,7 @@ export default function InviteStatusCard({ async function goToSignup() { await api.post("/auth/logout", {}); const redirectUrl = email - ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}` + ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` : `/auth/signup?redirect=/invite?token=${tokenParam}`; router.push(redirectUrl); } @@ -157,7 +161,9 @@ export default function InviteStatusCard({ Cannot Accept Invite

- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite. + This organization has reached its user limit. Please + contact the organization administrator to upgrade their + plan before accepting this invite.

); diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index c64c3f430..9dbf6de80 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -164,7 +164,7 @@ const countryClass = cn( const highlightedCountryClass = cn( sharedCountryClass, - "stroke-2", + "stroke-[3]", "fill-[#f4f4f5]", "stroke-[#f36117]", "dark:fill-[#3f3f46]" @@ -194,11 +194,20 @@ function drawInteractiveCountries( const path = setupProjetionPath(); const data = parseWorldTopoJsonToGeoJsonFeatures(); const svg = d3.select(element); + const countriesLayer = svg.append("g"); + const hoverLayer = svg.append("g").style("pointer-events", "none"); + const hoverPath = hoverLayer + .append("path") + .datum(null) + .attr("class", highlightedCountryClass) + .style("display", "none"); - svg.selectAll("path") + countriesLayer + .selectAll("path") .data(data) .enter() .append("path") + .attr("data-country-path", "true") .attr("class", countryClass) .attr("d", path as never) @@ -209,9 +218,10 @@ function drawInteractiveCountries( y, hoveredCountryAlpha3Code: country.properties.a3 }); - // brings country to front - this.parentNode?.appendChild(this); - d3.select(this).attr("class", highlightedCountryClass); + hoverPath + .datum(country) + .attr("d", path(country) as string) + .style("display", null); }) .on("mousemove", function (event) { @@ -221,7 +231,7 @@ function drawInteractiveCountries( .on("mouseout", function () { setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null }); - d3.select(this).attr("class", countryClass); + hoverPath.style("display", "none"); }); return svg; @@ -257,7 +267,7 @@ function colorInCountriesWithValues( const svg = d3.select(element); return svg - .selectAll("path") + .selectAll('path[data-country-path="true"]') .style("fill", (countryPath) => { const country = getCountryByCountryPath(countryPath); if (!country?.count) {