diff --git a/messages/en-US.json b/messages/en-US.json index ecef7605..35e88375 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1428,6 +1428,7 @@ "billingSites": "Sites", "billingUsers": "Users", "billingDomains": "Domains", + "billingOrganizations": "Orgs", "billingRemoteExitNodes": "Remote Nodes", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index cde6cd2a..a7786c76 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -130,18 +130,22 @@ export class UsageService { featureId, orgId, meterId, - instantaneousValue: value, - latestValue: value, + instantaneousValue: value || 0, + latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }) .onConflictDoUpdate({ target: usage.usageId, set: { - instantaneousValue: sql`${usage.instantaneousValue} + ${value}` + instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}` } }) .returning(); + logger.debug( + `Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}` + ); + return returnUsage; } diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 45771492..e58f8207 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -171,16 +171,7 @@ export async function createOrg( } } - if (build == "saas") { - if (!billingOrgIdForNewOrg) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Billing org not found for user. Cannot create new organization." - ) - ); - } - + if (build == "saas" && billingOrgIdForNewOrg) { const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS); if (!usage) { return next( diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 3d28da6f..eb5f8a8d 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -8,7 +8,10 @@ import { userOrgs, resourcePassword, resourcePincode, - resourceWhitelist + resourceWhitelist, + siteResources, + userSiteResources, + roleSiteResources } from "@server/db"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -57,9 +60,21 @@ export async function getUserResources( .from(roleResources) .where(eq(roleResources.roleId, userRoleId)); - const [directResources, roleResourceResults] = await Promise.all([ + const directSiteResourcesQuery = db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)); + + const roleSiteResourcesQuery = db + .select({ siteResourceId: roleSiteResources.siteResourceId }) + .from(roleSiteResources) + .where(eq(roleSiteResources.roleId, userRoleId)); + + const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ directResourcesQuery, - roleResourcesQuery + roleResourcesQuery, + directSiteResourcesQuery, + roleSiteResourcesQuery ]); // Combine all accessible resource IDs @@ -68,18 +83,25 @@ export async function getUserResources( ...roleResourceResults.map((r) => r.resourceId) ]; - if (accessibleResourceIds.length === 0) { - return response(res, { - data: { resources: [] }, - success: true, - error: false, - message: "No resources found", - status: HttpCode.OK - }); - } + // Combine all accessible site resource IDs + const accessibleSiteResourceIds = [ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]; // Get resource details for accessible resources - const resourcesData = await db + let resourcesData: Array<{ + resourceId: number; + name: string; + fullDomain: string | null; + ssl: boolean; + enabled: boolean; + sso: boolean; + protocol: string; + emailWhitelistEnabled: boolean; + }> = []; + if (accessibleResourceIds.length > 0) { + resourcesData = await db .select({ resourceId: resources.resourceId, name: resources.name, @@ -98,6 +120,40 @@ export async function getUserResources( eq(resources.enabled, true) ) ); + } + + // Get site resource details for accessible site resources + let siteResourcesData: Array<{ + siteResourceId: number; + name: string; + destination: string; + mode: string; + protocol: string | null; + enabled: boolean; + alias: string | null; + aliasAddress: string | null; + }> = []; + if (accessibleSiteResourceIds.length > 0) { + siteResourcesData = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + destination: siteResources.destination, + mode: siteResources.mode, + protocol: siteResources.protocol, + enabled: siteResources.enabled, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress + }) + .from(siteResources) + .where( + and( + inArray(siteResources.siteResourceId, accessibleSiteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ) + ); + } // Check for password, pincode, and whitelist protection for each resource const resourcesWithAuth = await Promise.all( @@ -161,8 +217,26 @@ export async function getUserResources( }) ); + // Format site resources + const siteResourcesFormatted = siteResourcesData.map((siteResource) => { + return { + siteResourceId: siteResource.siteResourceId, + name: siteResource.name, + destination: siteResource.destination, + mode: siteResource.mode, + protocol: siteResource.protocol, + enabled: siteResource.enabled, + alias: siteResource.alias, + aliasAddress: siteResource.aliasAddress, + type: 'site' as const + }; + }); + return response(res, { - data: { resources: resourcesWithAuth }, + data: { + resources: resourcesWithAuth, + siteResources: siteResourcesFormatted + }, success: true, error: false, message: "User resources retrieved successfully", @@ -190,5 +264,16 @@ export type GetUserResourcesResponse = { protected: boolean; protocol: string; }>; + siteResources: Array<{ + siteResourceId: number; + name: string; + destination: string; + mode: string; + protocol: string | null; + enabled: boolean; + alias: string | null; + aliasAddress: string | null; + type: 'site'; + }>; }; }; diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index cdb9d3ba..58757253 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -100,6 +100,8 @@ export async function deleteSite( } } + await trx.delete(sites).where(eq(sites.siteId, siteId)); + await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 5f608e55..9d672902 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -768,7 +768,7 @@ export default function BillingPage() {
{t("billingMaximumLimits") || "Maximum Limits"}
- + {t("billingUsers") || "Users"} @@ -888,7 +888,7 @@ export default function BillingPage() { t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(ORGINIZATIONS) !== - null && "organizations"} + null && "orgs"} @@ -901,7 +901,7 @@ export default function BillingPage() { t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(ORGINIZATIONS) !== - null && "organizations"} + null && "orgs"} )} @@ -923,7 +923,7 @@ export default function BillingPage() { t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(REMOTE_EXIT_NODES) !== - null && "remote nodes"} + null && "nodes"} @@ -936,7 +936,7 @@ export default function BillingPage() { t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(REMOTE_EXIT_NODES) !== - null && "remote nodes"} + null && "nodes"} )} diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 4d3a7717..93456b12 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -58,6 +58,18 @@ type Resource = { siteName?: string | null; }; +type SiteResource = { + siteResourceId: number; + name: string; + destination: string; + mode: string; + protocol: string | null; + enabled: boolean; + alias: string | null; + aliasAddress: string | null; + type: 'site'; +}; + type MemberResourcesPortalProps = { orgId: string; }; @@ -334,7 +346,9 @@ export default function MemberResourcesPortal({ const { toast } = useToast(); const [resources, setResources] = useState([]); + const [siteResources, setSiteResources] = useState([]); const [filteredResources, setFilteredResources] = useState([]); + const [filteredSiteResources, setFilteredSiteResources] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -360,7 +374,9 @@ export default function MemberResourcesPortal({ if (response.data.success) { setResources(response.data.data.resources); + setSiteResources(response.data.data.siteResources || []); setFilteredResources(response.data.data.resources); + setFilteredSiteResources(response.data.data.siteResources || []); } else { setError("Failed to load resources"); } @@ -417,17 +433,61 @@ export default function MemberResourcesPortal({ setFilteredResources(filtered); + // Filter and sort site resources + const filteredSites = siteResources.filter( + (resource) => + resource.name + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + resource.destination + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + + // Sort site resources + filteredSites.sort((a, b) => { + switch (sortBy) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "domain-asc": + case "domain-desc": + // Sort by destination for site resources + const destCompare = sortBy === "domain-asc" + ? a.destination.localeCompare(b.destination) + : b.destination.localeCompare(a.destination); + return destCompare; + case "status-enabled": + return b.enabled ? 1 : -1; + case "status-disabled": + return a.enabled ? 1 : -1; + default: + return a.name.localeCompare(b.name); + } + }); + + setFilteredSiteResources(filteredSites); + // Reset to first page when search/sort changes setCurrentPage(1); - }, [resources, searchQuery, sortBy]); + }, [resources, siteResources, searchQuery, sortBy]); // Calculate pagination - const totalPages = Math.ceil(filteredResources.length / itemsPerPage); + const totalItems = filteredResources.length + filteredSiteResources.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); const startIndex = (currentPage - 1) * itemsPerPage; const paginatedResources = filteredResources.slice( startIndex, startIndex + itemsPerPage ); + const remainingSlots = itemsPerPage - paginatedResources.length; + const paginatedSiteResources = remainingSlots > 0 + ? filteredSiteResources.slice( + Math.max(0, startIndex - filteredResources.length), + Math.max(0, startIndex - filteredResources.length) + remainingSlots + ) + : []; const handleOpenResource = (resource: Resource) => { // Open the resource in a new tab @@ -575,7 +635,7 @@ export default function MemberResourcesPortal({ {/* Resources Content */} - {filteredResources.length === 0 ? ( + {filteredResources.length === 0 && filteredSiteResources.length === 0 ? ( /* Enhanced Empty State */ @@ -623,9 +683,20 @@ export default function MemberResourcesPortal({ ) : ( <> - {/* Resources Grid */} -
- {paginatedResources.map((resource) => ( + {/* Public Resources Section */} + {paginatedResources.length > 0 && ( + <> +
+

+ + Public Resources +

+

+ Web applications and services accessible via browser +

+
+
+ {paginatedResources.map((resource) => (
@@ -702,13 +773,167 @@ export default function MemberResourcesPortal({ ))}
+ + )} + + {/* Private Resources (Site Resources) Section */} + {paginatedSiteResources.length > 0 && ( + <> +
+

+ + Private Resources +

+

+ Internal network resources accessible via client +

+
+
+ {paginatedSiteResources.map((siteResource) => ( + +
+
+
+ + + + + {siteResource.name} + + + +

+ {siteResource.name} +

+
+
+
+
+ +
+ +
+
Resource Details
+
+ Mode: + + {siteResource.mode} + +
+ {siteResource.protocol && ( +
+ Protocol: + + {siteResource.protocol} + +
+ )} + {siteResource.alias && ( +
+ Alias: + + {siteResource.alias} + +
+ )} + {siteResource.aliasAddress && ( +
+ Alias Address: + + {siteResource.aliasAddress} + +
+ )} +
+ Status: + + {siteResource.enabled ? 'Enabled' : 'Disabled'} + +
+
+
+
+
+ +
+ {siteResource.alias ? ( + <> + {/* Alias as primary */} +
+
+ {siteResource.alias} +
+ +
+ {/* Destination as secondary */} +
+ {siteResource.destination} +
+ + ) : ( + /* Destination as primary when no alias */ +
+
+ {siteResource.destination} +
+ +
+ )} +
+
+ +
+
+ + Requires Client Connection +
+
+
+ ))} +
+ + )} {/* Pagination Controls */}