From 9a89579e08e6408f6bd62c9f57f42dbcc39fe596 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:33:35 +0000 Subject: [PATCH 01/79] Initial plan From 5428bf4ed04fedfd0ed682643c6d027f5ec76f20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:37:34 +0000 Subject: [PATCH 02/79] fix: preserve rule IDs when saving policy rules through the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `setResourcePolicyRules` endpoint was deleting all existing rules and re-inserting them on every save, causing all ruleIDs to change. Backend: Accept an optional `ruleId` per rule in the request body and perform an upsert — update existing rules (matched by ruleId), insert new ones (no ruleId), and delete only rules absent from the incoming list. Frontend: Include `ruleId` in the rules payload for existing (non-new) rules so the backend can match and preserve them. --- .../routers/policy/setResourcePolicyRules.ts | 76 ++++++++++++++++--- .../PolicyAccessRulesSection.tsx | 3 +- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index f15c1e51a..dfcfb7cdb 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resourcePolicyRules, resourcePolicies } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq, notInArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -14,6 +14,7 @@ import { import { OpenAPITags, registry } from "@server/openApi"; const ruleSchema = z.strictObject({ + ruleId: z.int().positive().optional(), action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({ type: "string", enum: ["ACCEPT", "DROP", "PASS"], @@ -121,17 +122,74 @@ export async function setResourcePolicyRules( .set({ applyRules }) .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); - await trx - .delete(resourcePolicyRules) - .where( - eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId) - ); + const incomingRuleIds = rules + .map((r) => r.ruleId) + .filter((id): id is number => id !== undefined); - if (rules.length > 0) { + // Delete rules that are no longer in the incoming list + if (incomingRuleIds.length > 0) { + await trx + .delete(resourcePolicyRules) + .where( + and( + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ), + notInArray( + resourcePolicyRules.ruleId, + incomingRuleIds + ) + ) + ); + } else { + await trx + .delete(resourcePolicyRules) + .where( + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ) + ); + } + + // Update existing rules (those with a ruleId) + const existingRules = rules.filter( + (r): r is typeof r & { ruleId: number } => + r.ruleId !== undefined + ); + for (const rule of existingRules) { + await trx + .update(resourcePolicyRules) + .set({ + action: rule.action, + match: rule.match, + value: rule.value, + priority: rule.priority, + enabled: rule.enabled + }) + .where( + and( + eq(resourcePolicyRules.ruleId, rule.ruleId), + eq( + resourcePolicyRules.resourcePolicyId, + resourcePolicyId + ) + ) + ); + } + + // Insert new rules (those without a ruleId) + const newRules = rules.filter((r) => r.ruleId === undefined); + if (newRules.length > 0) { await trx.insert(resourcePolicyRules).values( - rules.map((rule) => ({ + newRules.map((rule) => ({ resourcePolicyId, - ...rule + action: rule.action, + match: rule.match, + value: rule.value, + priority: rule.priority, + enabled: rule.enabled })) ); } diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index 7a88cab0b..bd735f9b5 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -340,7 +340,8 @@ function PolicyAccessRulesSectionEdit({ ? rules.filter((rule) => !rule.fromPolicy) : rules; const rulesPayload = rulesToValidate.map( - ({ action, match, value, priority, enabled }) => ({ + ({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({ + ...(isNew ? {} : { ruleId }), action, match, value, From 35dffe71cbda47bfc50983b2ec45ce1df417f4ca Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 26 Jun 2026 14:40:31 -0400 Subject: [PATCH 03/79] Make error statement debug --- server/lib/blueprints/applyBlueprint.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 493831131..1edf87f80 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -172,7 +172,9 @@ export async function applyBlueprint({ } catch (err) { blueprintSucceeded = false; blueprintMessage = `Blueprint applied with errors: ${err}`; - logger.error(blueprintMessage); + logger.debug( + `Org ${orgId} blueprint apply issues: ${blueprintMessage}` + ); error = err; } From ea3f1c341b106a3a78b960487813fa86ba133e46 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 26 Jun 2026 14:40:39 -0400 Subject: [PATCH 04/79] Move hashing outside of transaction --- .../policy/setResourcePolicyHeaderAuth.ts | 17 ++++++++++------- .../routers/resource/setResourceHeaderAuth.ts | 19 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts index 368f9b05e..6dba0b8a7 100644 --- a/server/routers/policy/setResourcePolicyHeaderAuth.ts +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -76,6 +76,15 @@ export async function setResourcePolicyHeaderAuth( const { resourcePolicyId } = parsedParams.data; const { headerAuth } = parsedBody.data; + const headerAuthHash = + headerAuth !== null + ? await hashPassword( + Buffer.from( + `${headerAuth.user}:${headerAuth.password}` + ).toString("base64") + ) + : null; + await db.transaction(async (trx) => { await trx .delete(resourcePolicyHeaderAuth) @@ -86,13 +95,7 @@ export async function setResourcePolicyHeaderAuth( ) ); - if (headerAuth !== null) { - const headerAuthHash = await hashPassword( - Buffer.from( - `${headerAuth.user}:${headerAuth.password}` - ).toString("base64") - ); - + if (headerAuth !== null && headerAuthHash !== null) { await trx.insert(resourcePolicyHeaderAuth).values({ resourcePolicyId, headerAuthHash, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index e19d47c3b..6c5ee1788 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -107,6 +107,13 @@ export async function setResourceHeaderAuth( resource.resourcePolicyId === null && resource.defaultResourcePolicyId !== null; + const headerAuthHash = + user && password && extendedCompatibility !== null + ? await hashPassword( + Buffer.from(`${user}:${password}`).toString("base64") + ) + : null; + await db.transaction(async (trx) => { if (isInlinePolicy) { const policyId = resource.defaultResourcePolicyId!; @@ -116,11 +123,7 @@ export async function setResourceHeaderAuth( eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId) ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); - + if (headerAuthHash !== null && extendedCompatibility !== null) { await trx.insert(resourcePolicyHeaderAuth).values({ resourcePolicyId: policyId, headerAuthHash, @@ -140,11 +143,7 @@ export async function setResourceHeaderAuth( ) ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); - + if (headerAuthHash !== null && extendedCompatibility !== null) { await Promise.all([ trx .insert(resourceHeaderAuth) From 7506c0420d1233b910ba82a4249415c046339973 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 26 Jun 2026 17:11:11 -0400 Subject: [PATCH 05/79] properly pass org policy error message in olm register --- server/routers/olm/handleOlmRegisterMessage.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index bef993831..d386fe74e 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -197,15 +197,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { policyCheck }); - if (policyCheck?.error) { - logger.error( - `[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`, - { orgId: client.orgId, clientId: client.clientId } - ); - sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); - return; - } - if (policyCheck.policies?.passwordAge?.compliant === false) { logger.warn( `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`, @@ -238,7 +229,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { olm.olmId ); return; - } else if (!policyCheck.allowed) { + } else if (!policyCheck.allowed || policyCheck.error) { logger.warn( `[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`, { orgId: client.orgId, clientId: client.clientId } From 05dc558c4a91d74cd9db98989ed54cc1ec08265d Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 29 May 2026 11:33:48 -0700 Subject: [PATCH 06/79] Add basic resources input on the remote node --- server/db/pg/schema/privateSchema.ts | 16 ++- server/db/sqlite/schema/privateSchema.ts | 12 ++ server/lib/billing/usageService.ts | 21 +-- server/private/routers/external.ts | 19 +++ .../private/routers/remoteExitNode/index.ts | 2 + .../listRemoteExitNodeResources.ts | 90 ++++++++++++ .../setRemoteExitNodeResources.ts | 129 ++++++++++++++++++ .../[remoteExitNodeId]/layout.tsx | 4 + .../[remoteExitNodeId]/networking/page.tsx | 127 +++++++++++++++++ .../remote-exit-nodes/create/page.tsx | 2 - 10 files changed, 409 insertions(+), 13 deletions(-) create mode 100644 server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts create mode 100644 server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts create mode 100644 src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index ae73b97ac..5358b0bd6 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -19,12 +19,12 @@ import { roles, users, exitNodes, - sessions, - clients, resources, siteResources, targetHealthCheck, - sites + sites, + clients, + sessions } from "./schema"; export const certificates = pgTable("certificates", { @@ -197,6 +197,16 @@ export const remoteExitNodes = pgTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = pgTable("remoteExitNodeResources", { + remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(), + remoteExitNodeId: varchar("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: varchar("destination").notNull() // a cidr range +}); + export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { sessionId: varchar("id").primaryKey(), remoteExitNodeId: varchar("remoteExitNodeId") diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index ae7360780..878090cb6 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -195,6 +195,18 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", { }) }); +export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", { + remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({ + autoIncrement: true + }), + remoteExitNodeId: text("remoteExitNodeId") + .notNull() + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }), + destination: text("destination").notNull() // a cidr range +}); + export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { sessionId: text("id").primaryKey(), remoteExitNodeId: text("remoteExitNodeId") diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index dd32a09ad..19b14e876 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -276,11 +276,12 @@ export class UsageService { return null; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - - const usageId = `${orgIdToUse}-${featureId}`; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + + const usageId = `${orgIdToUse}-${featureId}`; + const [result] = await trx .select() .from(usage) @@ -340,8 +341,12 @@ export class UsageService { `Failed to get usage for ${orgIdToUse}/${featureId}:`, error ); - throw error; + if (process.env.NODE_ENV !== "development") { + throw error; + } } + + return null; } public async getBillingOrg( @@ -384,13 +389,13 @@ export class UsageService { return false; } - const orgIdToUse = await this.getBillingOrg(orgId, trx); - // This method should check the current usage against the limits set for the organization // and kick out all of the sites on the org let hasExceededLimits = false; - + let orgIdToUse = orgId; try { + orgIdToUse = await this.getBillingOrg(orgId, trx); + let orgLimits: Limit[] = []; if (featureId) { // Get all limits set for this organization diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 3cecd2ebb..23b9795ff 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -329,6 +329,25 @@ authenticated.delete( remoteExitNode.deleteRemoteExitNode ); +authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodeResources +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/resources", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodeResources +); + authenticated.put( "/org/:orgId/login-page", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 953ccba88..2258856f1 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -23,3 +23,5 @@ export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; export * from "./exitNodeReconnectScheduler"; +export * from "./listRemoteExitNodeResources"; +export * from "./setRemoteExitNodeResources"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts new file mode 100644 index 000000000..5f91fd0b3 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts @@ -0,0 +1,90 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +export type ListRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export async function listRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts new file mode 100644 index 000000000..b262bf9a1 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -0,0 +1,129 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +const bodySchema = z.strictObject({ + destinations: z.array( + z.string().regex(cidrRegex, "Must be a valid CIDR range") + ) +}); + +export type SetRemoteExitNodeResourcesBody = z.infer; + +export type SetRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export async function setRemoteExitNodeResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { destinations } = parsedBody.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + // Replace all resources atomically + await db + .delete(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + if (destinations.length > 0) { + await db.insert(remoteExitNodeResources).values( + destinations.map((destination) => ({ + remoteExitNodeId, + destination + })) + ); + } + + const resources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + return response(res, { + data: { resources }, + success: true, + error: false, + message: "Remote exit node resources updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index ec1dea837..cac55907f 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -34,6 +34,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); const navItems = [ + { + title: "Networking", + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking" + }, { title: t("credentials"), href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx new file mode 100644 index 000000000..bf220714b --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useParams } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources"; +import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; + +const cidrRegex = + /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; + +export default function NetworkingPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams<{ + orgId: string; + remoteExitNodeId: string; + }>(); + const { remoteExitNode } = useRemoteExitNodeContext(); + + const [subnets, setSubnets] = useState([]); + const [activeTagIndex, setActiveTagIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function loadResources() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources` + ); + const resources = res.data.data.resources; + setSubnets( + resources.map((r) => ({ + id: r.destination, + text: r.destination + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + formatAxiosError(error) || "Failed to load subnets" + }); + } finally { + setLoading(false); + } + } + + loadResources(); + }, [remoteExitNode.remoteExitNodeId]); + + const handleSave = async () => { + setSaving(true); + try { + await api.post>( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, + { + destinations: subnets.map((s) => s.text) + } + ); + toast({ + title: "Subnets saved", + description: "Remote subnets have been updated successfully." + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: formatAxiosError(error) || "Failed to save subnets" + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + Remote Subnets + + Define the CIDR ranges that this remote exit node will + route traffic to. Type a valid CIDR (e.g.{" "} + 10.0.0.0/8) and press Enter to add. + + + + cidrRegex.test(tag.trim())} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + disabled={loading} + allowDuplicates={false} + inlineTags={true} + /> + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx index 0e68c3791..13c62a3e7 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -35,8 +35,6 @@ import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect"; From 633d9031af59a746bb7bbaed05931fcbafcd73e6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 29 May 2026 12:20:45 -0700 Subject: [PATCH 07/79] Add labels input --- server/db/pg/schema/privateSchema.ts | 30 +++- server/db/sqlite/schema/privateSchema.ts | 30 +++- server/private/routers/external.ts | 19 ++ .../private/routers/remoteExitNode/index.ts | 2 + .../listRemoteExitNodePreferenceLabels.ts | 110 ++++++++++++ .../setRemoteExitNodePreferenceLabels.ts | 168 ++++++++++++++++++ .../[remoteExitNodeId]/networking/page.tsx | 145 +++++++++++++-- .../[remoteExitNodeId]/page.tsx | 2 +- .../multi-select/multi-select-content.tsx | 15 +- .../multi-select/multi-select-tag-input.tsx | 12 +- 10 files changed, 510 insertions(+), 23 deletions(-) create mode 100644 server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts create mode 100644 server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 5358b0bd6..54a4f5dda 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -2,6 +2,7 @@ import { pgTable, serial, varchar, + unique, boolean, integer, bigint, @@ -24,7 +25,8 @@ import { targetHealthCheck, sites, clients, - sessions + sessions, + labels } from "./schema"; export const certificates = pgTable("certificates", { @@ -207,6 +209,32 @@ export const remoteExitNodeResources = pgTable("remoteExitNodeResources", { destination: varchar("destination").notNull() // a cidr range }); +export const remoteExitNodePreferenceLabels = pgTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: serial( + "remoteExitNodePreferenceLabelId" + ).primaryKey(), + remoteExitNode: integer("remoteExitNode") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + unique("remote_exit_node_preference_label_uniq").on( + t.remoteExitNode, + t.labelId + ) + ] +); + export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { sessionId: varchar("id").primaryKey(), remoteExitNodeId: varchar("remoteExitNodeId") diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 878090cb6..b75836e29 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -12,6 +12,7 @@ import { clients, domains, exitNodes, + labels, orgs, resources, roles, @@ -21,9 +22,6 @@ import { targetHealthCheck, users } from "./schema"; -import { serial, varchar } from "drizzle-orm/mysql-core"; -import { pgTable } from "drizzle-orm/pg-core"; -import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -207,6 +205,32 @@ export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", { destination: text("destination").notNull() // a cidr range }); +export const remoteExitNodePreferenceLabels = sqliteTable( + // this controls what sites are enforced to connect to this node + "remoteExitNodePreferenceLabels", + { + remoteExitNodePreferenceLabelId: integer( + "remoteExitNodePreferenceLabelId" + ).primaryKey({ autoIncrement: true }), + remoteExitNodeId: text("remoteExitNodeId") + .references(() => remoteExitNodes.remoteExitNodeId, { + onDelete: "cascade" + }) + .notNull(), + labelId: integer("labelId") + .references(() => labels.labelId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [ + uniqueIndex("remote_exit_node_preference_label_uniq").on( + t.remoteExitNodeId, + t.labelId + ) + ] +); + export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { sessionId: text("id").primaryKey(), remoteExitNodeId: text("remoteExitNodeId") diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 23b9795ff..68cfaa749 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -348,6 +348,25 @@ authenticated.post( remoteExitNode.setRemoteExitNodeResources ); +authenticated.get( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.getRemoteExitNode), + remoteExitNode.listRemoteExitNodePreferenceLabels +); + +authenticated.post( + "/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels", + verifyValidLicense, + verifyOrgAccess, + verifyRemoteExitNodeAccess, + verifyUserHasAction(ActionsEnum.updateRemoteExitNode), + logActionAudit(ActionsEnum.updateRemoteExitNode), + remoteExitNode.setRemoteExitNodePreferenceLabels +); + authenticated.put( "/org/:orgId/login-page", verifyValidLicense, diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 2258856f1..244ef17de 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -25,3 +25,5 @@ export * from "./offlineChecker"; export * from "./exitNodeReconnectScheduler"; export * from "./listRemoteExitNodeResources"; export * from "./setRemoteExitNodeResources"; +export * from "./listRemoteExitNodePreferenceLabels"; +export * from "./setRemoteExitNodePreferenceLabels"; diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..47ff11c48 --- /dev/null +++ b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,110 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { + db, + labels, + remoteExitNodePreferenceLabels, + remoteExitNodes +} from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +export type ListRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export async function listRemoteExitNodePreferenceLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { remoteExitNodeId } = parsedParams.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + const rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: + "Remote exit node preference labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts new file mode 100644 index 000000000..3185e5a99 --- /dev/null +++ b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts @@ -0,0 +1,168 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { + db, + labels, + remoteExitNodePreferenceLabels, + remoteExitNodes +} from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.strictObject({ + orgId: z.string().min(1), + remoteExitNodeId: z.string().min(1) +}); + +const bodySchema = z.strictObject({ + labelIds: z.array(z.number().int().positive()) +}); + +export type SetRemoteExitNodePreferenceLabelsBody = z.infer; + +export type SetRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export async function setRemoteExitNodePreferenceLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, remoteExitNodeId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { labelIds } = parsedBody.data; + + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); + + if (!remoteExitNode) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Remote exit node with ID ${remoteExitNodeId} not found` + ) + ); + } + + // Validate all provided labelIds belong to this org + if (labelIds.length > 0) { + const existingLabels = await db + .select({ labelId: labels.labelId }) + .from(labels) + .where( + and( + eq(labels.orgId, orgId), + inArray(labels.labelId, labelIds) + ) + ); + + if (existingLabels.length !== labelIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more label IDs are invalid or do not belong to this organization" + ) + ); + } + } + + // Replace all preference labels atomically + await db + .delete(remoteExitNodePreferenceLabels) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + if (labelIds.length > 0) { + await db.insert(remoteExitNodePreferenceLabels).values( + labelIds.map((labelId) => ({ + remoteExitNodeId, + labelId + })) + ); + } + + const rows = await db + .select({ + remoteExitNodePreferenceLabelId: + remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId, + labelId: remoteExitNodePreferenceLabels.labelId, + name: labels.name, + color: labels.color + }) + .from(remoteExitNodePreferenceLabels) + .innerJoin( + labels, + eq(labels.labelId, remoteExitNodePreferenceLabels.labelId) + ) + .where( + eq( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeId + ) + ); + + return response(res, { + data: { labels: rows }, + success: true, + error: false, + message: "Remote exit node preference labels updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index bf220714b..a0da030ff 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { SettingsContainer, SettingsSection, @@ -18,8 +18,15 @@ import { useParams } from "next/navigation"; import { AxiosResponse } from "axios"; import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input"; +import type { TagValue } from "@app/components/multi-select/multi-select-content"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources"; import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; +import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels"; +import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels"; const cidrRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; @@ -33,22 +40,50 @@ export default function NetworkingPage() { }>(); const { remoteExitNode } = useRemoteExitNodeContext(); + // Subnets state const [subnets, setSubnets] = useState([]); const [activeTagIndex, setActiveTagIndex] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const [loadingSubnets, setLoadingSubnets] = useState(true); + const [savingSubnets, setSavingSubnets] = useState(false); + + // Labels state + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelSearchQuery, setLabelSearchQuery] = useState(""); + const [loadingLabels, setLoadingLabels] = useState(true); + const [savingLabels, setSavingLabels] = useState(false); + + const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150); + + const { data: availableLabels = [] } = useQuery( + orgQueries.labels({ orgId, query: debouncedLabelQuery, perPage: 10 }) + ); + + const labelsShown = useMemo(() => { + const base: TagValue[] = availableLabels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })); + if (debouncedLabelQuery.trim().length === 0) { + for (const sel of selectedLabels) { + if (!base.find((b) => b.id === sel.id)) { + base.unshift(sel); + } + } + } + return base; + }, [availableLabels, selectedLabels, debouncedLabelQuery]); useEffect(() => { - async function loadResources() { + async function loadSubnets() { try { const res = await api.get< AxiosResponse >( `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources` ); - const resources = res.data.data.resources; setSubnets( - resources.map((r) => ({ + res.data.data.resources.map((r) => ({ id: r.destination, text: r.destination })) @@ -61,21 +96,46 @@ export default function NetworkingPage() { formatAxiosError(error) || "Failed to load subnets" }); } finally { - setLoading(false); + setLoadingSubnets(false); } } - loadResources(); + async function loadLabels() { + try { + const res = await api.get< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels` + ); + setSelectedLabels( + res.data.data.labels.map((l) => ({ + id: l.labelId.toString(), + text: l.name, + color: l.color + })) + ); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: + formatAxiosError(error) || "Failed to load labels" + }); + } finally { + setLoadingLabels(false); + } + } + + loadSubnets(); + loadLabels(); }, [remoteExitNode.remoteExitNodeId]); - const handleSave = async () => { - setSaving(true); + const handleSaveSubnets = async () => { + setSavingSubnets(true); try { await api.post>( `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, - { - destinations: subnets.map((s) => s.text) - } + { destinations: subnets.map((s) => s.text) } ); toast({ title: "Subnets saved", @@ -88,7 +148,31 @@ export default function NetworkingPage() { description: formatAxiosError(error) || "Failed to save subnets" }); } finally { - setSaving(false); + setSavingSubnets(false); + } + }; + + const handleSaveLabels = async () => { + setSavingLabels(true); + try { + await api.post< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`, + { labelIds: selectedLabels.map((l) => parseInt(l.id)) } + ); + toast({ + title: "Labels saved", + description: "Preference labels have been updated successfully." + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: formatAxiosError(error) || "Failed to save labels" + }); + } finally { + setSavingLabels(false); } }; @@ -111,17 +195,46 @@ export default function NetworkingPage() { validateTag={(tag) => cidrRegex.test(tag.trim())} activeTagIndex={activeTagIndex} setActiveTagIndex={setActiveTagIndex} - disabled={loading} + disabled={loadingSubnets} allowDuplicates={false} inlineTags={true} /> - + + + + + Preference Labels + + + Sites with these labels will be enforced to connect + through this remote exit node. + + + + + + + + + ); } diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index a368ec687..4df0f0797 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -10,6 +10,6 @@ export default async function RemoteExitNodePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` + `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/networking` ); } diff --git a/src/components/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx index 15b23827f..659e16d5c 100644 --- a/src/components/multi-select/multi-select-content.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { Checkbox } from "../ui/checkbox"; -export type TagValue = { text: string; id: string; isAdmin?: boolean }; +export type TagValue = { + text: string; + id: string; + isAdmin?: boolean; + color?: string; +}; export type MultiSelectTagsProps = { emptyPlaceholder?: string; @@ -77,6 +82,14 @@ export function MultiSelectContent({ aria-hidden tabIndex={-1} /> + {option.color && ( + + )} {`${option.text}`} ); diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index bde1a9b05..dc75311c2 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -66,7 +66,17 @@ export function MultiSelectTagInput({ )} onClick={(e) => e.stopPropagation()} > - {option.text} + {option.color && ( + + )} + + {option.text} + {isLocked ? ( From eac7c67dccd1d091cbfedf70adffda9a3ce69b12 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 26 Jun 2026 18:09:56 -0400 Subject: [PATCH 08/79] Send down remote subnets --- server/routers/newt/buildConfiguration.ts | 20 ++++++++++++++-- .../routers/newt/handleNewtRegisterMessage.ts | 24 ++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 154ab38cb..5083a6c56 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -5,6 +5,7 @@ import { db, ExitNode, networks, + remoteExitNodeResources, resources, Site, siteNetworks, @@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient( export async function buildTargetConfigurationForNewtClient( siteId: number, - version?: string | null + version?: string | null, + remoteExitNodeId?: string ) { // Get all enabled targets with their resource mode information const allTargets = await db @@ -379,10 +381,24 @@ export async function buildTargetConfigurationForNewtClient( }; }); + let remoteExitNodeSubnets: string[] = []; + if (remoteExitNodeId) { + const remoteNodeResources = await db + .select() + .from(remoteExitNodeResources) + .where( + eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) + ); + + // filter through these and provide the subnets + remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination); + } + return { validHealthCheckTargets, tcpTargets, udpTargets, - browserGatewayTargets + browserGatewayTargets, + remoteExitNodeSubnets }; } diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index bd4aaacb3..0dc8380c8 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db, ExitNode, newts, Transaction } from "@server/db"; +import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; @@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } + let remoteExitNodeId: string | undefined; + if (exitNode.type == "remoteExitNode") { + // get the remote exit node ID associated with this exit node + const [remoteExitNode] = await db + .select() + .from(remoteExitNodes) + .where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId)) + .limit(1); + + remoteExitNodeId = remoteExitNode?.remoteExitNodeId; + } + const { tcpTargets, udpTargets, validHealthCheckTargets, - browserGatewayTargets - } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); + browserGatewayTargets, + remoteExitNodeSubnets + } = await buildTargetConfigurationForNewtClient( + siteId, + newtVersion, + remoteExitNodeId // this is for the remote node resources + ); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { }, healthCheckTargets: validHealthCheckTargets, browserGatewayTargets: browserGatewayTargets, + remoteExitNodeSubnets: remoteExitNodeSubnets, chainId: chainId } }, From c9cc9581b1c530548bb494b9138e877f520f0c9e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 26 Jun 2026 23:24:14 -0400 Subject: [PATCH 09/79] Add batch update --- .../setRemoteExitNodeResources.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts index b262bf9a1..8416f0169 100644 --- a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -13,13 +13,20 @@ import { NextFunction, Request, Response } from "express"; import { z } from "zod"; -import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db"; +import { + db, + newts, + remoteExitNodeResources, + remoteExitNodes, + sites +} from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { sendToClientsBatch } from "#private/routers/ws"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -113,6 +120,25 @@ export async function setRemoteExitNodeResources( eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId) ); + // Notify all newts connected to this remote exit node's exit node + if (remoteExitNode.exitNodeId) { + const connectedNewts = await db + .select({ newtId: newts.newtId }) + .from(newts) + .innerJoin(sites, eq(newts.siteId, sites.siteId)) + .where(eq(sites.exitNodeId, remoteExitNode.exitNodeId)); + + await sendToClientsBatch( + connectedNewts.map(({ newtId }) => ({ + clientId: newtId, + message: { + type: "newt/wg/subnets/update", + data: { subnets: destinations } + } + })) + ); + } + return response(res, { data: { resources }, success: true, From 04d4e298e8d8fbc69a60f3e0fd53467ec7669712 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 29 Jun 2026 10:42:25 -0400 Subject: [PATCH 10/79] fix spacing on endpoint field on site --- src/components/SiteInfoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index a5e639c28..8089e71b2 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -54,7 +54,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("publicIpEndpoint")} {formatPublicEndpoint(site.endpoint)}  - + {site.countryCode && countryCodeToFlagEmoji(site.countryCode)} From 31725eb3ccd7b0a18024ffbd2a915369c87e728a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 29 Jun 2026 10:45:38 -0400 Subject: [PATCH 11/79] display resource type in info card --- src/components/ResourceInfoBox.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index f25342263..f459d2c38 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - {resource.ssl ? "HTTPS" : "HTTP"} + {resource.mode == "http" + ? resource.ssl + ? "HTTPS" + : "HTTP" + : resource.mode?.toUpperCase()} From cf07cceb5d04aa07a64deba630a9b9018073f135 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 11:31:34 -0400 Subject: [PATCH 12/79] Fix bad col in pg --- server/db/pg/schema/privateSchema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 54a4f5dda..cbe8a4039 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -216,7 +216,7 @@ export const remoteExitNodePreferenceLabels = pgTable( remoteExitNodePreferenceLabelId: serial( "remoteExitNodePreferenceLabelId" ).primaryKey(), - remoteExitNode: integer("remoteExitNode") + remoteExitNodeId: varchar("remoteExitNodeId") .references(() => remoteExitNodes.remoteExitNodeId, { onDelete: "cascade" }) @@ -229,7 +229,7 @@ export const remoteExitNodePreferenceLabels = pgTable( }, (t) => [ unique("remote_exit_node_preference_label_uniq").on( - t.remoteExitNode, + t.remoteExitNodeId, t.labelId ) ] From 9c18936be7a31e8adacba1a205b3f8670ae31630 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 11:40:25 -0400 Subject: [PATCH 13/79] Filter the nodes based on the preference labels --- server/private/lib/exitNodes/exitNodes.ts | 85 ++++++++++++++++++- .../newt/handleNewtPingRequestMessage.ts | 3 +- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index f6417dae2..1f9517725 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -18,12 +18,15 @@ import { resources, targets, sites, + siteLabels, + remoteExitNodes, + remoteExitNodePreferenceLabels, targetHealthCheck, Transaction } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; -import { eq, and, or, ne, isNull } from "drizzle-orm"; +import { eq, and, or, ne, isNull, inArray } from "drizzle-orm"; import axios from "axios"; import config from "../config"; @@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess( export async function listExitNodes( orgId: string, filterOnline = false, - noCloud = false + noCloud = false, + siteId?: number ) { const allExitNodes = await db .select({ @@ -237,7 +241,7 @@ export async function listExitNodes( // }) // ); - const remoteExitNodes = allExitNodes.filter( + let remoteExitNodesList = allExitNodes.filter( (node) => node.type === "remoteExitNode" && (!filterOnline || node.online) ); @@ -246,9 +250,82 @@ export async function listExitNodes( node.type === "gerbil" && (!filterOnline || node.online) && !noCloud ); + // Apply label-based filtering to remote exit nodes if siteId is provided + if (siteId !== undefined && remoteExitNodesList.length > 0) { + // Get the site's labels + const siteLabelRows = await db + .select({ labelId: siteLabels.labelId }) + .from(siteLabels) + .where(eq(siteLabels.siteId, siteId)); + const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId)); + + // Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId + const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId); + const remoteNodeRows = await db + .select({ + exitNodeId: remoteExitNodes.exitNodeId, + remoteExitNodeId: remoteExitNodes.remoteExitNodeId + }) + .from(remoteExitNodes) + .where(inArray(remoteExitNodes.exitNodeId, exitNodeIds)); + + const exitNodeIdToRemoteId = new Map( + remoteNodeRows + .filter((r) => r.exitNodeId !== null) + .map((r) => [r.exitNodeId!, r.remoteExitNodeId]) + ); + + // Get preference labels for all remote exit nodes + const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId); + const prefLabelRows = + remoteExitNodeIds.length > 0 + ? await db + .select({ + remoteExitNodeId: + remoteExitNodePreferenceLabels.remoteExitNodeId, + labelId: remoteExitNodePreferenceLabels.labelId + }) + .from(remoteExitNodePreferenceLabels) + .where( + inArray( + remoteExitNodePreferenceLabels.remoteExitNodeId, + remoteExitNodeIds + ) + ) + : []; + + // Build a map of remoteExitNodeId -> Set of labelIds + const prefLabelsMap = new Map>(); + for (const row of prefLabelRows) { + if (!prefLabelsMap.has(row.remoteExitNodeId)) { + prefLabelsMap.set(row.remoteExitNodeId, new Set()); + } + prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId); + } + + // Filter: include node if it has no preference labels, or if site shares at least one label + const filtered = remoteExitNodesList.filter((node) => { + const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId); + if (!remoteId) return true; // no remoteExitNode record, don't filter + const prefLabels = prefLabelsMap.get(remoteId); + if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include + // include only if site has at least one matching label + for (const labelId of siteLabelIds) { + if (prefLabels.has(labelId)) return true; + } + return false; + }); + + // Only apply the filtered list if at least one remote node remains; + // otherwise fall through to the gerbil fallback below + if (filtered.length > 0 || remoteExitNodesList.length === 0) { + remoteExitNodesList = filtered; + } + } + // THIS PROVIDES THE FALL const exitNodesList = - remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; + remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes; return exitNodesList; } diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index 8f6df4bec..f239dc4de 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const exitNodesList = await listExitNodes( site.orgId, true, - noCloud || false + noCloud || false, + newt.siteId ); // filter for only the online ones let lastExitNodeId = null; From d5d99a480465650e07bfdde64a5d65ca61567004 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 14:59:05 -0400 Subject: [PATCH 14/79] Add org rebuild rate limit --- server/lib/orgRebuildCounter.ts | 24 ++++ server/lib/rebuildClientAssociations.ts | 20 +++- server/private/lib/orgRebuildCounter.ts | 105 ++++++++++++++++++ server/private/routers/user/addUserRole.ts | 14 ++- .../private/routers/user/setUserOrgRoles.ts | 14 ++- server/routers/client/createClient.ts | 14 ++- server/routers/client/createUserClient.ts | 14 ++- server/routers/client/deleteClient.ts | 14 ++- .../rebuildClientAssociationsCacheRoute.ts | 11 +- server/routers/olm/deleteUserOlm.ts | 29 ++++- .../siteResource/addClientToSiteResource.ts | 14 ++- .../siteResource/addRoleToSiteResource.ts | 14 ++- .../siteResource/addUserToSiteResource.ts | 14 ++- .../batchAddClientToSiteResources.ts | 14 ++- .../siteResource/createSiteResource.ts | 14 ++- .../removeClientFromSiteResource.ts | 13 ++- .../removeRoleFromSiteResource.ts | 14 ++- .../removeUserFromSiteResource.ts | 14 ++- .../siteResource/setSiteResourceClients.ts | 14 ++- .../siteResource/setSiteResourceRoles.ts | 11 +- .../siteResource/setSiteResourceUsers.ts | 14 ++- .../siteResource/updateSiteResource.ts | 10 ++ server/routers/user/addUserRoleLegacy.ts | 14 ++- 23 files changed, 413 insertions(+), 20 deletions(-) create mode 100644 server/lib/orgRebuildCounter.ts create mode 100644 server/private/lib/orgRebuildCounter.ts diff --git a/server/lib/orgRebuildCounter.ts b/server/lib/orgRebuildCounter.ts new file mode 100644 index 000000000..40edd0aa0 --- /dev/null +++ b/server/lib/orgRebuildCounter.ts @@ -0,0 +1,24 @@ +export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; + +const orgActiveRebuilds = new Map(); + +export async function incrementOrgRebuildCount(orgId: string): Promise { + orgActiveRebuilds.set(orgId, (orgActiveRebuilds.get(orgId) ?? 0) + 1); +} + +export async function decrementOrgRebuildCount(orgId: string): Promise { + const current = orgActiveRebuilds.get(orgId) ?? 0; + if (current <= 1) { + orgActiveRebuilds.delete(orgId); + } else { + orgActiveRebuilds.set(orgId, current - 1); + } +} + +export async function getOrgActiveRebuildCount(orgId: string): Promise { + return orgActiveRebuilds.get(orgId) ?? 0; +} + +export async function checkOrgRebuildRateLimit(orgId: string): Promise { + return (orgActiveRebuilds.get(orgId) ?? 0) >= ORG_REBUILD_CONCURRENCY_LIMIT; +} diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 1f675e81e..150001662 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -45,11 +45,23 @@ import { } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; +import { + checkOrgRebuildRateLimit, + decrementOrgRebuildCount, + incrementOrgRebuildCount, + ORG_REBUILD_CONCURRENCY_LIMIT +} from "#dynamic/lib/orgRebuildCounter"; + +export { ORG_REBUILD_CONCURRENCY_LIMIT }; // TTL for rebuild-association locks. These functions can fan out into many // peer/proxy updates, so give them a generous window. const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; +export async function isOrgRebuildRateLimited(orgId: string): Promise { + return checkOrgRebuildRateLimit(orgId); +} + const REBUILD_IDLE_POLL_INTERVAL_MS = 300; const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000; @@ -271,6 +283,7 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource ) { + await incrementOrgRebuildCount(siteResource.orgId); try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, @@ -292,6 +305,8 @@ export async function rebuildClientAssociationsFromSiteResource( return { mergedAllClients: [] }; } throw err; + } finally { + await decrementOrgRebuildCount(siteResource.orgId); } } @@ -1638,8 +1653,9 @@ export async function handleMessagingForUpdatedSiteResource( export async function rebuildClientAssociationsFromClient( client: Client ): Promise { - const trx = primaryDb; + await incrementOrgRebuildCount(client.orgId); try { + const trx = primaryDb; return await lockManager.withLock( `rebuild-client-associations:client:${client.clientId}`, () => rebuildClientAssociationsFromClientImpl(client, trx), @@ -1660,6 +1676,8 @@ export async function rebuildClientAssociationsFromClient( return; } throw err; + } finally { + await decrementOrgRebuildCount(client.orgId); } } diff --git a/server/private/lib/orgRebuildCounter.ts b/server/private/lib/orgRebuildCounter.ts new file mode 100644 index 000000000..58d954f66 --- /dev/null +++ b/server/private/lib/orgRebuildCounter.ts @@ -0,0 +1,105 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { redis } from "#private/lib/redis"; +import logger from "@server/logger"; + +export const ORG_REBUILD_CONCURRENCY_LIMIT = 5; + +// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a +// server process dies while holding a rebuild, this ensures the counter key +// eventually expires rather than staying inflated forever. +const ORG_REBUILD_COUNT_TTL_MS = 180000; +const KEY_PREFIX = "rebuild-org-count:"; + +// In-memory fallback used when Redis is unavailable. +const localFallback = new Map(); + +function isRedisReady(): boolean { + return !!(redis && redis.status === "ready"); +} + +export async function incrementOrgRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1); + return; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + await redis!.incr(key); + // Always refresh the TTL so the key doesn't expire while rebuilds are + // still in progress. The TTL is purely a crash-recovery safety net. + await redis!.pexpire(key, ORG_REBUILD_COUNT_TTL_MS); + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis increment failed for org ${orgId}, falling back to local:`, + err + ); + localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1); + } +} + +export async function decrementOrgRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + const current = localFallback.get(orgId) ?? 0; + if (current <= 1) { + localFallback.delete(orgId); + } else { + localFallback.set(orgId, current - 1); + } + return; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + const count = await redis!.decr(key); + if (count <= 0) { + await redis!.del(key); + } + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis decrement failed for org ${orgId}, falling back to local:`, + err + ); + const current = localFallback.get(orgId) ?? 0; + if (current <= 1) { + localFallback.delete(orgId); + } else { + localFallback.set(orgId, current - 1); + } + } +} + +export async function getOrgActiveRebuildCount(orgId: string): Promise { + if (!isRedisReady()) { + return localFallback.get(orgId) ?? 0; + } + try { + const key = `${KEY_PREFIX}${orgId}`; + const val = await redis!.get(key); + return val ? parseInt(val, 10) : 0; + } catch (err) { + logger.warn( + `orgRebuildCounter: Redis get failed for org ${orgId}, falling back to local:`, + err + ); + return localFallback.get(orgId) ?? 0; + } +} + +export async function checkOrgRebuildRateLimit( + orgId: string +): Promise { + return ( + (await getOrgActiveRebuildCount(orgId)) >= ORG_REBUILD_CONCURRENCY_LIMIT + ); +} diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index ce5a6dd50..2065984a3 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -23,7 +23,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addUserRoleParamsSchema = z.strictObject({ userId: z.string(), @@ -128,6 +131,15 @@ export async function addUserRole( ); } + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let newUserRole: { userId: string; orgId: string; diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index ef6bc1b4f..b583233d1 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -21,7 +21,10 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setUserOrgRolesParamsSchema = z.strictObject({ orgId: z.string(), @@ -87,6 +90,15 @@ export async function setUserOrgRoles( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const orgRoles = await db .select({ roleId: roles.roleId, isAdmin: roles.isAdmin }) .from(roles) diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index bef47245d..57604f0f0 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -24,7 +24,10 @@ import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateId } from "@server/auth/sessions/app"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; import { build } from "@server/build"; @@ -154,6 +157,15 @@ export async function createClient( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 3c7d85018..70027e4a5 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -21,7 +21,10 @@ import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; const paramsSchema = z @@ -146,6 +149,15 @@ export async function createUserClient( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 62765c2c1..e6acead2e 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -9,7 +9,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; import { OlmErrorCodes } from "../olm/error"; @@ -76,6 +79,15 @@ export async function deleteClient( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( diff --git a/server/routers/client/rebuildClientAssociationsCacheRoute.ts b/server/routers/client/rebuildClientAssociationsCacheRoute.ts index 86cb5c485..ca149fa97 100644 --- a/server/routers/client/rebuildClientAssociationsCacheRoute.ts +++ b/server/routers/client/rebuildClientAssociationsCacheRoute.ts @@ -9,7 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { rebuildClientAssociationsFromClient, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -60,6 +60,15 @@ export async function rebuildClientAssociationsCacheRoute( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + rebuildClientAssociationsFromClient(client).catch((e) => { logger.error( `Failed to rebuild client associations for client ${clientId}: ${e}` diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index addef32d9..d2d0821e8 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -9,7 +9,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; import { OlmErrorCodes } from "./error"; @@ -64,6 +67,30 @@ export async function deleteUserOlm( const { olmId } = parsedParams.data; + // get the client first + const [client] = await db + .select() + .from(clients) + .where(eq(clients.olmId, olmId)); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `No client found for olmId ${olmId}` + ) + ); + } + + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let deletedClient: Client | undefined; // Delete associated clients and the OLM in a transaction await db.transaction(async (trx) => { diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 4a6dd141e..5e81cf8f7 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addClientToSiteResourceBodySchema = z .object({ @@ -128,6 +131,15 @@ export async function addClientToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if client already exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 05186c351..d3dd164cc 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addRoleToSiteResourceBodySchema = z .object({ @@ -104,6 +107,15 @@ export async function addRoleToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // verify the role exists and belongs to the same org const [role] = await db .select() diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index c35357993..0a93868ae 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const addUserToSiteResourceBodySchema = z .object({ @@ -104,6 +107,15 @@ export async function addUserToSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if user already exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index 1ebb3359d..3eb282310 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -15,7 +15,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const batchAddClientToSiteResourcesParamsSchema = z .object({ @@ -186,6 +189,15 @@ export async function batchAddClientToSiteResources( ); } + if (await isOrgRebuildRateLimited(client.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + if (client.userId !== null) { return next( createHttpError( diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index d0d018f84..4b55921a2 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -21,7 +21,10 @@ import { } from "@server/lib/ip"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -339,6 +342,15 @@ export async function createSiteResource( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index c53e214cd..e955dd5c6 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeClientFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,14 @@ export async function removeClientFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } // Check if client exists and has a userId const [client] = await db .select() diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 6dd978e24..509088597 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeRoleFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,15 @@ export async function removeRoleFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if the role is an admin role const [roleToCheck] = await db .select() diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 67e6ac960..3d8e1fd97 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const removeUserFromSiteResourceBodySchema = z .object({ @@ -106,6 +109,15 @@ export async function removeUserFromSiteResource( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if user exists in site resource const existingEntry = await db .select() diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index a4bc5b69e..59dff6ef9 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -8,7 +8,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setSiteResourceClientsBodySchema = z .object({ @@ -107,6 +110,15 @@ export async function setSiteResourceClients( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if any clients have a userId (associated with a user) if (clientIds.length > 0) { const clientsWithUsers = await db diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index cad6da53b..613f0aa63 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -9,7 +9,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { rebuildClientAssociationsFromSiteResource, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const setSiteResourceRolesBodySchema = z .object({ @@ -167,6 +167,15 @@ export async function setSiteResourceRoles( } }); + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index cde5b4e66..4a423bf06 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; import { error } from "node:console"; const setSiteResourceUsersBodySchema = z @@ -109,6 +112,15 @@ export async function setSiteResourceUsers( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + await db.transaction(async (trx) => { await trx .delete(userSiteResources) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 434163f6f..22159f1c6 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -19,6 +19,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { handleMessagingForUpdatedSiteResource, + isOrgRebuildRateLimited, rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } from "@server/lib/rebuildClientAssociations"; @@ -345,6 +346,15 @@ export async function updateSiteResource( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Verify the site exists and belongs to the org const sitesToAssign = await db .select() diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index b3ff55a06..b7df41bf3 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -10,7 +10,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; /** Legacy path param order: /role/:roleId/add/:userId */ const addUserRoleLegacyParamsSchema = z.strictObject({ @@ -127,6 +130,15 @@ export async function addUserRoleLegacy( ); } + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + let orgClientsToRebuild: Client[] = []; await db.transaction(async (trx) => { From 4718c489d378e670bb278b504682405253c21120 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 15:02:41 -0400 Subject: [PATCH 15/79] Add concurrency guard calculateUserClientsForOrgs --- .../routers/orgIdp/unassociateOrgIdp.ts | 10 +++++++ server/routers/user/acceptInvite.ts | 10 +++++++ server/routers/user/createOrgUser.ts | 10 +++++++ server/routers/user/removeUserOrg.ts | 29 +++++++++---------- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index 0b5c1ed51..d9c0bfc8f 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -23,6 +23,7 @@ import { and, eq, sql } from "drizzle-orm"; import { removeUserFromOrg } from "@server/lib/userOrg"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { OpenAPITags, registry } from "@server/openApi"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z .object({ @@ -90,6 +91,15 @@ export async function unassociateOrgIdp( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const orgUsersFromIdp = await db .select({ userId: userOrgs.userId, diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index ef7ddcdbd..37ac69f59 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -21,6 +21,7 @@ import { FeatureId } from "@server/lib/billing"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { build } from "@server/build"; import { assignUserToOrg } from "@server/lib/userOrg"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -147,6 +148,15 @@ export async function acceptInvite( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const inviteRoleRows = await db .select({ roleId: userInviteRoles.roleId }) .from(userInviteRoles) diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index c6f25e085..b20881198 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -19,6 +19,7 @@ import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -229,6 +230,15 @@ export async function createOrgUser( ); } + if (await isOrgRebuildRateLimited(org.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const [idpRes] = await db .select() .from(idp) diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 902aeed84..abaf6b3e6 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -1,29 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - db, - orgs, - resources, - siteResources, - sites, - UserOrg, - userSiteResources, - primaryDb -} from "@server/db"; -import { userOrgs, userResources, users, userSites } from "@server/db"; -import { and, count, eq, exists, inArray } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; -import { build } from "@server/build"; -import { UserType } from "@server/types/UserTypes"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { removeUserFromOrg } from "@server/lib/userOrg"; +import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; const removeUserSchema = z.strictObject({ userId: z.string(), @@ -93,6 +81,15 @@ export async function removeUserOrg( ); } + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + const [org] = await db .select() .from(orgs) From ff89a64453b3bc2f259f41dac9624f4f3c9ff943 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 15:22:35 -0400 Subject: [PATCH 16/79] Rename to limit id --- server/lib/billing/features.ts | 51 ++++++++++--------- server/lib/billing/getLineItems.ts | 10 ++-- server/lib/billing/limitSet.ts | 46 ++++++++--------- server/lib/billing/limitsService.ts | 4 +- server/lib/billing/usageService.ts | 16 +++--- server/lib/deleteOrg.ts | 16 +++--- server/lib/userOrg.ts | 6 +-- server/private/routers/billing/changeTier.ts | 4 +- server/private/routers/billing/getOrgUsage.ts | 12 ++--- .../remoteExitNode/createRemoteExitNode.ts | 13 +++-- .../remoteExitNode/deleteRemoteExitNode.ts | 4 +- server/routers/auth/deleteMyAccount.ts | 4 +- server/routers/domain/createOrgDomain.ts | 8 +-- server/routers/domain/deleteOrgDomain.ts | 4 +- server/routers/gerbil/receiveBandwidth.ts | 16 +++--- server/routers/idp/validateOidcCallback.ts | 4 +- server/routers/newt/registerNewt.ts | 8 +-- server/routers/org/createOrg.ts | 10 ++-- server/routers/site/createSite.ts | 8 +-- server/routers/site/deleteSite.ts | 4 +- server/routers/user/acceptInvite.ts | 6 +-- server/routers/user/createOrgUser.ts | 6 +-- server/routers/user/inviteUser.ts | 11 ++-- .../settings/(private)/billing/page.tsx | 42 +++++++-------- 24 files changed, 159 insertions(+), 154 deletions(-) diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index 6063b470f..9fea3da2c 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,4 +1,4 @@ -export enum FeatureId { +export enum LimitId { USERS = "users", SITES = "sites", EGRESS_DATA_MB = "egressDataMb", @@ -8,21 +8,23 @@ export enum FeatureId { TIER1 = "tier1" } -export async function getFeatureDisplayName(featureId: FeatureId): Promise { +export async function getFeatureDisplayName( + featureId: LimitId +): Promise { switch (featureId) { - case FeatureId.USERS: + case LimitId.USERS: return "Users"; - case FeatureId.SITES: + case LimitId.SITES: return "Sites"; - case FeatureId.EGRESS_DATA_MB: + case LimitId.EGRESS_DATA_MB: return "Egress Data (MB)"; - case FeatureId.DOMAINS: + case LimitId.DOMAINS: return "Domains"; - case FeatureId.REMOTE_EXIT_NODES: + case LimitId.REMOTE_EXIT_NODES: return "Remote Exit Nodes"; - case FeatureId.ORGINIZATIONS: + case LimitId.ORGINIZATIONS: return "Organizations"; - case FeatureId.TIER1: + case LimitId.TIER1: return "Home Lab"; default: return featureId; @@ -30,15 +32,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise> = { // right now we are not charging for any data +export const FeatureMeterIds: Partial> = { + // right now we are not charging for any data // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; -export const FeatureMeterIdsSandbox: Partial> = { +export const FeatureMeterIdsSandbox: Partial> = { // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; -export function getFeatureMeterId(featureId: FeatureId): string | undefined { +export function getFeatureMeterId(featureId: LimitId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" @@ -49,22 +52,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined { } } -export function getFeatureIdByMetricId( - metricId: string -): FeatureId | undefined { - return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find( +export function getFeatureIdByMetricId(metricId: string): LimitId | undefined { + return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find( ([_, v]) => v === metricId )?.[0]; } -export type FeaturePriceSet = Partial>; +export type FeaturePriceSet = Partial>; export const tier1FeaturePriceSet: FeaturePriceSet = { - [FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" + [LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" }; export const tier1FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" + [LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export function getTier1FeaturePriceSet(): FeaturePriceSet { @@ -79,11 +80,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet { } export const tier2FeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" + [LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" }; export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" + [LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; export function getTier2FeaturePriceSet(): FeaturePriceSet { @@ -98,11 +99,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet { } export const tier3FeaturePriceSet: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" + [LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" }; export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { - [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" + [LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; export function getTier3FeaturePriceSet(): FeaturePriceSet { @@ -116,7 +117,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet { } } -export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { +export function getFeatureIdByPriceId(priceId: string): LimitId | undefined { // Check all feature price sets const allPriceSets = [ getTier1FeaturePriceSet(), @@ -125,7 +126,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { ]; for (const priceSet of allPriceSets) { - const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( + const entry = (Object.entries(priceSet) as [LimitId, string][]).find( ([_, price]) => price === priceId ); if (entry) { diff --git a/server/lib/billing/getLineItems.ts b/server/lib/billing/getLineItems.ts index d386e5e96..5df5fb8a8 100644 --- a/server/lib/billing/getLineItems.ts +++ b/server/lib/billing/getLineItems.ts @@ -1,19 +1,19 @@ import Stripe from "stripe"; -import { FeatureId, FeaturePriceSet } from "./features"; +import { LimitId, FeaturePriceSet } from "./features"; import { usageService } from "./usageService"; export async function getLineItems( featurePriceSet: FeaturePriceSet, - orgId: string, + orgId: string ): Promise { - const users = await usageService.getUsage(orgId, FeatureId.USERS); + const users = await usageService.getUsage(orgId, LimitId.USERS); return Object.entries(featurePriceSet).map(([featureId, priceId]) => { let quantity: number | undefined; - if (featureId === FeatureId.USERS) { + if (featureId === LimitId.USERS) { quantity = users?.instantaneousValue || 1; - } else if (featureId === FeatureId.TIER1) { + } else if (featureId === LimitId.TIER1) { quantity = 1; } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index e45ae637d..1273112ff 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -1,70 +1,70 @@ -import { FeatureId } from "./features"; +import { LimitId } from "./features"; export type LimitSet = Partial<{ - [key in FeatureId]: { + [key in LimitId]: { value: number | null; // null indicates no limit description?: string; }; }>; export const freeLimitSet: LimitSet = { - [FeatureId.SITES]: { value: 5, description: "Basic limit" }, - [FeatureId.USERS]: { value: 5, description: "Basic limit" }, - [FeatureId.DOMAINS]: { value: 5, description: "Basic limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, - [FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" }, + [LimitId.SITES]: { value: 5, description: "Basic limit" }, + [LimitId.USERS]: { value: 5, description: "Basic limit" }, + [LimitId.DOMAINS]: { value: 5, description: "Basic limit" }, + [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, + [LimitId.ORGINIZATIONS]: { value: 1, description: "Basic limit" } }; export const tier1LimitSet: LimitSet = { - [FeatureId.USERS]: { value: 7, description: "Home limit" }, - [FeatureId.SITES]: { value: 10, description: "Home limit" }, - [FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, - [FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" }, + [LimitId.USERS]: { value: 7, description: "Home limit" }, + [LimitId.SITES]: { value: 10, description: "Home limit" }, + [LimitId.DOMAINS]: { value: 10, description: "Home limit" }, + [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, + [LimitId.ORGINIZATIONS]: { value: 1, description: "Home limit" } }; export const tier2LimitSet: LimitSet = { - [FeatureId.USERS]: { + [LimitId.USERS]: { value: 50, description: "Team limit" }, - [FeatureId.SITES]: { + [LimitId.SITES]: { value: 50, description: "Team limit" }, - [FeatureId.DOMAINS]: { + [LimitId.DOMAINS]: { value: 50, description: "Team limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { + [LimitId.REMOTE_EXIT_NODES]: { value: 3, description: "Team limit" }, - [FeatureId.ORGINIZATIONS]: { + [LimitId.ORGINIZATIONS]: { value: 1, description: "Team limit" } }; export const tier3LimitSet: LimitSet = { - [FeatureId.USERS]: { + [LimitId.USERS]: { value: 250, description: "Business limit" }, - [FeatureId.SITES]: { + [LimitId.SITES]: { value: 250, description: "Business limit" }, - [FeatureId.DOMAINS]: { + [LimitId.DOMAINS]: { value: 100, description: "Business limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { + [LimitId.REMOTE_EXIT_NODES]: { value: 20, description: "Business limit" }, - [FeatureId.ORGINIZATIONS]: { + [LimitId.ORGINIZATIONS]: { value: 5, description: "Business limit" - }, + } }; diff --git a/server/lib/billing/limitsService.ts b/server/lib/billing/limitsService.ts index f364d6e00..e08b0fe14 100644 --- a/server/lib/billing/limitsService.ts +++ b/server/lib/billing/limitsService.ts @@ -1,7 +1,7 @@ import { db, limits } from "@server/db"; import { and, eq } from "drizzle-orm"; import { LimitSet } from "./limitSet"; -import { FeatureId } from "./features"; +import { LimitId } from "./features"; import logger from "@server/logger"; class LimitService { @@ -38,7 +38,7 @@ class LimitService { async getOrgLimit( orgId: string, - featureId: FeatureId + featureId: LimitId ): Promise { const limitId = `${orgId}-${featureId}`; const [limit] = await db diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index dd32a09ad..a239cc719 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -9,7 +9,7 @@ import { Transaction, orgs } from "@server/db"; -import { FeatureId, getFeatureMeterId } from "./features"; +import { LimitId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; import { regionalCache as cache } from "#dynamic/lib/cache"; @@ -37,7 +37,7 @@ export class UsageService { public async add( orgId: string, - featureId: FeatureId, + featureId: LimitId, value: number, transaction: any = null ): Promise { @@ -114,7 +114,7 @@ export class UsageService { private async internalAddUsage( orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount - featureId: FeatureId, + featureId: LimitId, value: number, trx: Transaction ): Promise { @@ -163,7 +163,7 @@ export class UsageService { async updateCount( orgId: string, - featureId: FeatureId, + featureId: LimitId, value?: number, customerId?: string ): Promise { @@ -227,7 +227,7 @@ export class UsageService { private async getCustomerId( orgId: string, - featureId: FeatureId + featureId: LimitId ): Promise { const orgIdToUse = await this.getBillingOrg(orgId); @@ -269,7 +269,7 @@ export class UsageService { public async getUsage( orgId: string, - featureId: FeatureId, + featureId: LimitId, trx: Transaction | typeof db = db ): Promise { if (noop()) { @@ -376,7 +376,7 @@ export class UsageService { public async checkLimitSet( orgId: string, - featureId?: FeatureId, + featureId?: LimitId, usage?: Usage, trx: Transaction | typeof db = db ): Promise { @@ -424,7 +424,7 @@ export class UsageService { } else { currentUsage = await this.getUsage( orgIdToUse, - limit.featureId as FeatureId, + limit.featureId as LimitId, trx ); } diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts index 065f216a1..0d954016f 100644 --- a/server/lib/deleteOrg.ts +++ b/server/lib/deleteOrg.ts @@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers"; import { OlmErrorCodes } from "@server/routers/olm/error"; import { sendTerminateClient } from "@server/routers/client/terminate"; import { usageService } from "./billing/usageService"; -import { FeatureId } from "./billing"; +import { LimitId } from "./billing"; export type DeleteOrgByIdResult = { deletedNewtIds: string[]; @@ -140,7 +140,9 @@ export async function deleteOrgById( .select({ count: count() }) .from(orgDomains) .where(eq(orgDomains.domainId, domainId)); - logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); + logger.info( + `Found ${orgCount.count} orgs using domain ${domainId}` + ); if (orgCount.count === 1) { domainIdsToDelete.push(domainId); } @@ -152,7 +154,7 @@ export async function deleteOrgById( .where(inArray(domains.domainId, domainIdsToDelete)); } - await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + await usageService.add(orgId, LimitId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here await trx.delete(orgs).where(eq(orgs.orgId, orgId)); @@ -199,22 +201,22 @@ export async function deleteOrgById( if (org.billingOrgId) { usageService.updateCount( org.billingOrgId, - FeatureId.DOMAINS, + LimitId.DOMAINS, domainCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.SITES, + LimitId.SITES, siteCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.USERS, + LimitId.USERS, userCount ?? 0 ); usageService.updateCount( org.billingOrgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, remoteExitNodeCount ?? 0 ); } diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 809266b73..4bff40c13 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -14,7 +14,7 @@ import { } from "@server/db"; import { eq, and, inArray, ne, exists } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; export async function assignUserToOrg( org: Org, @@ -61,7 +61,7 @@ export async function assignUserToOrg( ); if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { - await usageService.add(org.orgId, FeatureId.USERS, 1, trx); + await usageService.add(org.orgId, LimitId.USERS, 1, trx); } } } @@ -157,7 +157,7 @@ export async function removeUserFromOrg( ); if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { - await usageService.add(org.orgId, FeatureId.USERS, -1, trx); + await usageService.add(org.orgId, LimitId.USERS, -1, trx); } } } diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index d82cbfeea..e532e88fd 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -25,7 +25,7 @@ import { getTier1FeaturePriceSet, getTier3FeaturePriceSet, getTier2FeaturePriceSet, - FeatureId, + LimitId, type FeaturePriceSet } from "@server/lib/billing"; import { getLineItems } from "@server/lib/billing/getLineItems"; @@ -214,7 +214,7 @@ export async function changeTier( } // Map to the corresponding feature in the new tier - const newPriceId = targetPriceSet[FeatureId.USERS]; + const newPriceId = targetPriceSet[LimitId.USERS]; if (newPriceId) { return { diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 718559654..7ef993a2b 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -24,7 +24,7 @@ import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { Limit, limits, Usage, usage } from "@server/db"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { GetOrgUsageResponse } from "@server/routers/billing/types"; const getOrgSchema = z.strictObject({ @@ -93,16 +93,16 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const sites = await usageService.getUsage(orgId, FeatureId.SITES); - const users = await usageService.getUsage(orgId, FeatureId.USERS); - const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const sites = await usageService.getUsage(orgId, LimitId.SITES); + const users = await usageService.getUsage(orgId, LimitId.USERS); + const domains = await usageService.getUsage(orgId, LimitId.DOMAINS); const remoteExitNodes = await usageService.getUsage( orgId, - FeatureId.REMOTE_EXIT_NODES + LimitId.REMOTE_EXIT_NODES ); const organizations = await usageService.getUsage( orgId, - FeatureId.ORGINIZATIONS + LimitId.ORGINIZATIONS ); // const egressData = await usageService.getUsage( // orgId, diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index d7e889222..bb16f228d 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -35,7 +35,7 @@ import logger from "@server/logger"; import { and, eq, inArray, ne } from "drizzle-orm"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; export const paramsSchema = z.object({ @@ -79,7 +79,10 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.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") ); @@ -87,13 +90,13 @@ export async function createRemoteExitNode( const usage = await usageService.getUsage( orgId, - FeatureId.REMOTE_EXIT_NODES + LimitId.REMOTE_EXIT_NODES ); if (usage) { const rejectRemoteExitNodes = await usageService.checkLimitSet( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -264,7 +267,7 @@ export async function createRemoteExitNode( if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { await usageService.add( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, 1, trx ); diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index e86476a5a..6fd871896 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -22,7 +22,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -117,7 +117,7 @@ export async function deleteRemoteExitNode( if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { await usageService.add( orgId, - FeatureId.REMOTE_EXIT_NODES, + LimitId.REMOTE_EXIT_NODES, -1, trx ); diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index d45486946..0a1f74f3e 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -20,7 +20,7 @@ import { getOrgTierData } from "#dynamic/lib/billing"; import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), @@ -220,7 +220,7 @@ export async function deleteMyAccount( await trx.delete(users).where(eq(users.userId, userId)); // loop through the other orgs and decrement the count for (const userOrg of otherOrgsTheUserWasIn) { - await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx); + await usageService.add(userOrg.orgId, LimitId.USERS, -1, trx); } }); diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index ceb61b25f..8370b5465 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -17,7 +17,7 @@ import { subdomainSchema } from "@server/lib/schemas"; import { generateId } from "@server/auth/sessions/app"; import { eq, and } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators"; import { build } from "@server/build"; import config from "@server/lib/config"; @@ -120,7 +120,7 @@ export async function createOrgDomain( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const usage = await usageService.getUsage(orgId, LimitId.DOMAINS); if (!usage) { return next( createHttpError( @@ -132,7 +132,7 @@ export async function createOrgDomain( const rejectDomains = await usageService.checkLimitSet( orgId, - FeatureId.DOMAINS, + LimitId.DOMAINS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -346,7 +346,7 @@ export async function createOrgDomain( await trx.insert(dnsRecords).values(recordsToInsert); } - await usageService.add(orgId, FeatureId.DOMAINS, 1, trx); + await usageService.add(orgId, LimitId.DOMAINS, 1, trx); }); if (!returned) { diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index 4c347668e..3f443eac5 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -8,7 +8,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ domainId: z.string(), @@ -77,7 +77,7 @@ export async function deleteAccountDomain( await trx.delete(domains).where(eq(domains.domainId, domainId)); - await usageService.add(orgId, FeatureId.DOMAINS, -1, trx); + await usageService.add(orgId, LimitId.DOMAINS, -1, trx); }); return response(res, { diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index eacf3dad4..aacf7f843 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -6,7 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing/features"; +import { LimitId } from "@server/lib/billing/features"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; @@ -171,8 +171,9 @@ export async function flushSiteBandwidthToDb(): Promise { } // PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk. - const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => - sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` + const valuesList = chunk.map( + ([publicKey, { bytesIn, bytesOut }]) => + sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ); const valuesClause = sql.join(valuesList, sql`, `); return dbQueryRows<{ orgId: string; pubKey: string }>(sql` @@ -228,7 +229,7 @@ export async function flushSiteBandwidthToDb(): Promise { const totalBandwidth = orgUsageMap.get(orgId)!; const bandwidthUsage = await usageService.add( orgId, - FeatureId.EGRESS_DATA_MB, + LimitId.EGRESS_DATA_MB, totalBandwidth ); if (bandwidthUsage) { @@ -236,7 +237,7 @@ export async function flushSiteBandwidthToDb(): Promise { usageService .checkLimitSet( orgId, - FeatureId.EGRESS_DATA_MB, + LimitId.EGRESS_DATA_MB, bandwidthUsage ) .catch((error: any) => { @@ -247,10 +248,7 @@ export async function flushSiteBandwidthToDb(): Promise { }); } } catch (error) { - logger.error( - `Error processing usage for org ${orgId}:`, - error - ); + logger.error(`Error processing usage for org ${orgId}:`, error); // Continue with other orgs. } } diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index f6cb656b3..57b16964d 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -31,7 +31,7 @@ import { } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import { UserType } from "@server/types/UserTypes"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; @@ -645,7 +645,7 @@ export async function validateOidcCallback( for (const orgCount of orgUserCounts) { await usageService.updateCount( orgCount.orgId, - FeatureId.USERS, + LimitId.USERS, orgCount.userCount ); } diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 3adcfb467..1fbfab191 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -25,7 +25,7 @@ import { getUniqueSiteName } from "@server/db/names"; import moment from "moment"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { INSPECT_MAX_BYTES } from "buffer"; import { getNextAvailableClientSubnet } from "@server/lib/ip"; @@ -169,7 +169,7 @@ export async function registerNewt( // SaaS billing check if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.SITES); + const usage = await usageService.getUsage(orgId, LimitId.SITES); if (!usage) { return next( createHttpError( @@ -180,7 +180,7 @@ export async function registerNewt( } const rejectSites = await usageService.checkLimitSet( orgId, - FeatureId.SITES, + LimitId.SITES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -274,7 +274,7 @@ export async function registerNewt( ) ); - await usageService.add(orgId, FeatureId.SITES, 1, trx); + await usageService.add(orgId, LimitId.SITES, 1, trx); }); } finally { await releaseSubnetLock(); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 35466ebc0..c625e42af 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -27,7 +27,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing"; +import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { doCidrsOverlap } from "@server/lib/ip"; @@ -202,7 +202,7 @@ export async function createOrg( if (build == "saas" && billingOrgIdForNewOrg) { const usage = await usageService.getUsage( billingOrgIdForNewOrg, - FeatureId.ORGINIZATIONS + LimitId.ORGINIZATIONS ); if (!usage) { return next( @@ -214,7 +214,7 @@ export async function createOrg( } const rejectOrgs = await usageService.checkLimitSet( billingOrgIdForNewOrg, - FeatureId.ORGINIZATIONS, + LimitId.ORGINIZATIONS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -421,7 +421,7 @@ export async function createOrg( if (customerId) { await usageService.updateCount( orgId, - FeatureId.USERS, + LimitId.USERS, 1, customerId ); // Only 1 because we are creating the org @@ -431,7 +431,7 @@ export async function createOrg( if (numOrgs) { usageService.updateCount( billingOrgIdForNewOrg || orgId, - FeatureId.ORGINIZATIONS, + LimitId.ORGINIZATIONS, numOrgs ); } diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index cc67f7b27..bddf5b251 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -19,7 +19,7 @@ import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { generateId } from "@server/auth/sessions/app"; const createSiteParamsSchema = z.strictObject({ @@ -160,7 +160,7 @@ export async function createSite( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.SITES); + const usage = await usageService.getUsage(orgId, LimitId.SITES); if (!usage) { return next( createHttpError( @@ -172,7 +172,7 @@ export async function createSite( const rejectSites = await usageService.checkLimitSet( orgId, - FeatureId.SITES, + LimitId.SITES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -519,7 +519,7 @@ export async function createSite( }); } - await usageService.add(orgId, FeatureId.SITES, 1, trx); + await usageService.add(orgId, LimitId.SITES, 1, trx); }); } finally { await releaseSubnetLock?.(); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 300c570d8..602032ffb 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -13,7 +13,7 @@ import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { deleteAssociatedResourcesForSite, @@ -177,7 +177,7 @@ export async function deleteSite( } await trx.delete(sites).where(eq(sites.siteId, siteId)); - await usageService.add(site.orgId, FeatureId.SITES, -1, trx); + await usageService.add(site.orgId, LimitId.SITES, -1, trx); }); if (deleteResources) { diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 37ac69f59..c912fea0e 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -17,7 +17,7 @@ import { fromError } from "zod-validation-error"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { verifySession } from "@server/auth/sessions/verifySession"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { build } from "@server/build"; import { assignUserToOrg } from "@server/lib/userOrg"; @@ -104,7 +104,7 @@ export async function acceptInvite( if (build == "saas") { const usage = await usageService.getUsage( existingInvite.orgId, - FeatureId.USERS + LimitId.USERS ); if (!usage) { return next( @@ -117,7 +117,7 @@ export async function acceptInvite( const rejectUsers = await usageService.checkLimitSet( existingInvite.orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index b20881198..d80d85999 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -12,7 +12,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; @@ -123,7 +123,7 @@ export async function createOrgUser( } = parsedBody.data; if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.USERS); + const usage = await usageService.getUsage(orgId, LimitId.USERS); if (!usage) { return next( createHttpError( @@ -135,7 +135,7 @@ export async function createOrgUser( const rejectUsers = await usageService.checkLimitSet( orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index c4feb820f..7445ee910 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -25,7 +25,7 @@ import SendInviteLink from "@server/emails/templates/SendInviteLink"; import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { LimitId } from "@server/lib/billing"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; @@ -73,7 +73,6 @@ const InviteUserResponseDataSchema = z.object({ expiresAt: z.number() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/create-invite", @@ -94,7 +93,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(InviteUserResponseDataSchema) + schema: createApiResponseSchema( + InviteUserResponseDataSchema + ) } } } @@ -181,7 +182,7 @@ export async function inviteUser( } if (build == "saas") { - const usage = await usageService.getUsage(orgId, FeatureId.USERS); + const usage = await usageService.getUsage(orgId, LimitId.USERS); if (!usage) { return next( createHttpError( @@ -192,7 +193,7 @@ export async function inviteUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - FeatureId.USERS, + LimitId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index e4abdb561..34b0ee3cc 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -58,7 +58,7 @@ import { tier2LimitSet, tier3LimitSet } from "@server/lib/billing/limitSet"; -import { FeatureId } from "@server/lib/billing/features"; +import { LimitId } from "@server/lib/billing/features"; import TrialBillingBanner from "@app/components/TrialBillingBanner"; // Plan tier definitions matching the mockup @@ -161,32 +161,32 @@ const tierLimits: Record< } > = { basic: { - users: freeLimitSet[FeatureId.USERS]?.value ?? 0, - sites: freeLimitSet[FeatureId.SITES]?.value ?? 0, - domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: freeLimitSet[LimitId.USERS]?.value ?? 0, + sites: freeLimitSet[LimitId.SITES]?.value ?? 0, + domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: freeLimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 }, tier1: { - users: tier1LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier1LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier1LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier1LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 }, tier2: { - users: tier2LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier2LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier2LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier2LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 }, tier3: { - users: tier3LimitSet[FeatureId.USERS]?.value ?? 0, - sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0, - domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0, - remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + users: tier3LimitSet[LimitId.USERS]?.value ?? 0, + sites: tier3LimitSet[LimitId.SITES]?.value ?? 0, + domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0, + remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier3LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 }, enterprise: { users: 0, // Custom for enterprise From d60c15b0ae460f9661953b4e72378b361a502b4a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 15:24:16 -0400 Subject: [PATCH 17/79] Fix typo --- server/lib/billing/features.ts | 4 ++-- server/lib/billing/limitSet.ts | 8 ++++---- server/lib/deleteOrg.ts | 2 +- server/private/routers/billing/getOrgUsage.ts | 2 +- server/routers/org/createOrg.ts | 6 +++--- src/app/[orgId]/settings/(private)/billing/page.tsx | 8 ++++---- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index 9fea3da2c..c2ca44209 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -4,7 +4,7 @@ export enum LimitId { EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", - ORGINIZATIONS = "organizations", + ORGANIZATIONS = "organizations", TIER1 = "tier1" } @@ -22,7 +22,7 @@ export async function getFeatureDisplayName( return "Domains"; case LimitId.REMOTE_EXIT_NODES: return "Remote Exit Nodes"; - case LimitId.ORGINIZATIONS: + case LimitId.ORGANIZATIONS: return "Organizations"; case LimitId.TIER1: return "Home Lab"; diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 1273112ff..965353a33 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -12,7 +12,7 @@ export const freeLimitSet: LimitSet = { [LimitId.USERS]: { value: 5, description: "Basic limit" }, [LimitId.DOMAINS]: { value: 5, description: "Basic limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, - [LimitId.ORGINIZATIONS]: { value: 1, description: "Basic limit" } + [LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" } }; export const tier1LimitSet: LimitSet = { @@ -20,7 +20,7 @@ export const tier1LimitSet: LimitSet = { [LimitId.SITES]: { value: 10, description: "Home limit" }, [LimitId.DOMAINS]: { value: 10, description: "Home limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, - [LimitId.ORGINIZATIONS]: { value: 1, description: "Home limit" } + [LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" } }; export const tier2LimitSet: LimitSet = { @@ -40,7 +40,7 @@ export const tier2LimitSet: LimitSet = { value: 3, description: "Team limit" }, - [LimitId.ORGINIZATIONS]: { + [LimitId.ORGANIZATIONS]: { value: 1, description: "Team limit" } @@ -63,7 +63,7 @@ export const tier3LimitSet: LimitSet = { value: 20, description: "Business limit" }, - [LimitId.ORGINIZATIONS]: { + [LimitId.ORGANIZATIONS]: { value: 5, description: "Business limit" } diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts index 0d954016f..20c8a3e23 100644 --- a/server/lib/deleteOrg.ts +++ b/server/lib/deleteOrg.ts @@ -154,7 +154,7 @@ export async function deleteOrgById( .where(inArray(domains.domainId, domainIdsToDelete)); } - await usageService.add(orgId, LimitId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here await trx.delete(orgs).where(eq(orgs.orgId, orgId)); diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 7ef993a2b..6b61b84de 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -102,7 +102,7 @@ export async function getOrgUsage( ); const organizations = await usageService.getUsage( orgId, - LimitId.ORGINIZATIONS + LimitId.ORGANIZATIONS ); // const egressData = await usageService.getUsage( // orgId, diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index c625e42af..f7ea3018f 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -202,7 +202,7 @@ export async function createOrg( if (build == "saas" && billingOrgIdForNewOrg) { const usage = await usageService.getUsage( billingOrgIdForNewOrg, - LimitId.ORGINIZATIONS + LimitId.ORGANIZATIONS ); if (!usage) { return next( @@ -214,7 +214,7 @@ export async function createOrg( } const rejectOrgs = await usageService.checkLimitSet( billingOrgIdForNewOrg, - LimitId.ORGINIZATIONS, + LimitId.ORGANIZATIONS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 @@ -431,7 +431,7 @@ export async function createOrg( if (numOrgs) { usageService.updateCount( billingOrgIdForNewOrg || orgId, - LimitId.ORGINIZATIONS, + LimitId.ORGANIZATIONS, numOrgs ); } diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 34b0ee3cc..d526a17f4 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -165,28 +165,28 @@ const tierLimits: Record< sites: freeLimitSet[LimitId.SITES]?.value ?? 0, domains: freeLimitSet[LimitId.DOMAINS]?.value ?? 0, remoteNodes: freeLimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: freeLimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 + organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier1: { users: tier1LimitSet[LimitId.USERS]?.value ?? 0, sites: tier1LimitSet[LimitId.SITES]?.value ?? 0, domains: tier1LimitSet[LimitId.DOMAINS]?.value ?? 0, remoteNodes: tier1LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier1LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 + organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier2: { users: tier2LimitSet[LimitId.USERS]?.value ?? 0, sites: tier2LimitSet[LimitId.SITES]?.value ?? 0, domains: tier2LimitSet[LimitId.DOMAINS]?.value ?? 0, remoteNodes: tier2LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier2LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 + organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, tier3: { users: tier3LimitSet[LimitId.USERS]?.value ?? 0, sites: tier3LimitSet[LimitId.SITES]?.value ?? 0, domains: tier3LimitSet[LimitId.DOMAINS]?.value ?? 0, remoteNodes: tier3LimitSet[LimitId.REMOTE_EXIT_NODES]?.value ?? 0, - organizations: tier3LimitSet[LimitId.ORGINIZATIONS]?.value ?? 0 + organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0 }, enterprise: { users: 0, // Custom for enterprise From 528bbeca2638330562d23c9aae7a4cf8fd1b2cd6 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 15:39:30 -0400 Subject: [PATCH 18/79] Implement usage tracking on resources, clients --- server/lib/billing/features.ts | 3 ++ server/lib/billing/limitSet.ts | 20 +++++++-- server/routers/client/createClient.ts | 38 ++++++++++++++++- server/routers/client/deleteClient.ts | 9 ++++ server/routers/resource/createResource.ts | 38 +++++++++++++++++ server/routers/resource/deleteResource.ts | 10 +++++ .../siteResource/createSiteResource.ts | 41 +++++++++++++++++++ .../siteResource/deleteSiteResource.ts | 10 +++++ 8 files changed, 164 insertions(+), 5 deletions(-) diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index c2ca44209..eff042ebf 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -5,6 +5,9 @@ export enum LimitId { DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", ORGANIZATIONS = "organizations", + PUBLIC_RESOURCES = "publicResources", + PRIVATE_RESOURCES = "privateResources", + MACHINE_CLIENTS = "machineClients", TIER1 = "tier1" } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 965353a33..87b1a9c17 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -12,7 +12,10 @@ export const freeLimitSet: LimitSet = { [LimitId.USERS]: { value: 5, description: "Basic limit" }, [LimitId.DOMAINS]: { value: 5, description: "Basic limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, - [LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" } + [LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" }, + [LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" } }; export const tier1LimitSet: LimitSet = { @@ -20,7 +23,10 @@ export const tier1LimitSet: LimitSet = { [LimitId.SITES]: { value: 10, description: "Home limit" }, [LimitId.DOMAINS]: { value: 10, description: "Home limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, - [LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" } + [LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" }, + [LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" } }; export const tier2LimitSet: LimitSet = { @@ -43,7 +49,10 @@ export const tier2LimitSet: LimitSet = { [LimitId.ORGANIZATIONS]: { value: 1, description: "Team limit" - } + }, + [LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" } }; export const tier3LimitSet: LimitSet = { @@ -66,5 +75,8 @@ export const tier3LimitSet: LimitSet = { [LimitId.ORGANIZATIONS]: { value: 5, description: "Business limit" - } + }, + [LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" }, + [LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" }, + [LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" } }; diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 57604f0f0..f64b77b7f 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -30,6 +30,8 @@ import { } from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; import { build } from "@server/build"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const createClientParamsSchema = z.strictObject({ orgId: z.string() @@ -128,6 +130,38 @@ export async function createClient( ); } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.MACHINE_CLIENTS + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectClient = await usageService.checkLimitSet( + orgId, + + LimitId.MACHINE_CLIENTS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectClient) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Public resource limit exceeded. Please upgrade your plan." + ) + ); + } + } + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { @@ -289,6 +323,8 @@ export async function createClient( clientId: newClient.clientId, dateCreated: moment().toISOString() }); + + await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx); }); if (newClient) { @@ -303,7 +339,7 @@ export async function createClient( data: newClient, success: true, error: false, - message: "Site created successfully", + message: "Client created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index e6acead2e..f09d6432e 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -15,6 +15,8 @@ import { } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; import { OlmErrorCodes } from "../olm/error"; +import { LimitId } from "@server/lib/billing/features"; +import { usageService } from "@server/lib/billing/usageService"; const deleteClientSchema = z.strictObject({ clientId: z.coerce.number().int().positive() @@ -118,6 +120,13 @@ export async function deleteClient( if (!client.userId && client.olmId) { await trx.delete(olms).where(eq(olms.olmId, client.olmId)); } + + await usageService.add( + deletedClient.orgId, + LimitId.MACHINE_CLIENTS, + -1, + trx + ); }); if (deletedClient) { diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index b9547bfbf..81c5950fb 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -36,6 +36,8 @@ import { getUniqueResourceName, getUniqueResourcePolicyName } from "@server/db/names"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -235,6 +237,38 @@ export async function createResource( req.body.mode = resolvedMode.mode; } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PUBLIC_RESOURCES + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PUBLIC_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Public resource limit exceeded. Please upgrade your plan." + ) + ); + } + } + if (typeof req.body.proxyPort === "number") { if ( !config.getRawConfig().flags?.allow_raw_resources && @@ -503,6 +537,8 @@ async function createHttpResource( } resource = newResource[0]; + + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); }); if (!resource) { @@ -631,6 +667,8 @@ async function createRawResource( } resource = newResource[0]; + + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); }); if (!resource) { diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 766b25b04..d80817360 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -11,6 +11,8 @@ import { performDeleteResource, runResourceDeleteSideEffects } from "@server/lib/deleteResource"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -64,6 +66,14 @@ export async function deleteResource( await db.transaction(async (trx) => { deleteResult = await performDeleteResource(resourceId, trx); + if (deleteResult?.deletedResource?.orgId) { + await usageService.add( + deleteResult?.deletedResource?.orgId, + LimitId.PUBLIC_RESOURCES, + -1, + trx + ); + } }); if (!deleteResult) { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4b55921a2..d7adb5c03 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -37,6 +37,8 @@ import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -294,6 +296,38 @@ export async function createSiteResource( siteIds.push(siteId); } + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PRIVATE_RESOURCES + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PRIVATE_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Private resource limit exceeded. Please upgrade your plan." + ) + ); + } + } + if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( orgId, @@ -605,6 +639,13 @@ export async function createSiteResource( ); } } + + await usageService.add( + orgId, + LimitId.PRIVATE_RESOURCES, + 1, + trx + ); }); } finally { await releaseAliasLock?.(); diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index cddeb490b..63479c399 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -12,6 +12,8 @@ import { performDeleteSiteResource, runSiteResourceDeleteSideEffects } from "@server/lib/deleteSiteResource"; +import { LimitId } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -86,6 +88,14 @@ export async function deleteSiteResource( siteResourceId, trx ); + if (removedSiteResource?.orgId) { + await usageService.add( + removedSiteResource?.orgId, + LimitId.PRIVATE_RESOURCES, + -1, + trx + ); + } }); if (!removedSiteResource) { From 2f2b7f43c16ad1dc305e91c53c78beeb9b17674f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 16:13:12 -0400 Subject: [PATCH 19/79] Add usage tracking to blueprints --- server/lib/blueprints/applyBlueprint.ts | 9 +++++- server/lib/blueprints/privateResources.ts | 36 +++++++++++++++++++++++ server/lib/blueprints/publicResources.ts | 34 +++++++++++++++++++++ server/routers/client/createClient.ts | 2 +- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 1edf87f80..c62dd4735 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -8,7 +8,7 @@ import { userSiteResources, clientSiteResources } from "@server/db"; -import { Config, ConfigSchema } from "./types"; +import { Config, ConfigSchema, isTargetsOnlyResource } from "./types"; import { PublicResourcesResults, updatePublicResources @@ -34,6 +34,12 @@ import { rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } from "../rebuildClientAssociations"; +import { build } from "@server/build"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; type ApplyBlueprintArgs = { orgId: string; @@ -64,6 +70,7 @@ export async function applyBlueprint({ let publicResourcesResults: PublicResourcesResults = []; let privateResourcesResults: ClientResourcesResults = []; + await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 8d00701f1..74a1f618f 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -25,6 +25,12 @@ import { getNextAvailableAliasAddress } from "../ip"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; +import { build } from "@server/build"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; async function getDomainForSiteResource( siteResourceId: number | undefined, @@ -413,6 +419,34 @@ export async function updatePrivateResources( oldSites: existingSiteIds }); } else { + // create a brand new resource + + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PRIVATE_RESOURCES + ); + if (!usage) { + throw new Error( + `Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}` + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PRIVATE_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + throw new Error( + "Private resource limit exceeded. Please upgrade your plan." + ); + } + } + let aliasAddress: string | null = null; let releaseAliasLock: (() => Promise) | null = null; if ( @@ -609,6 +643,8 @@ export async function updatePrivateResources( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); + await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx); + results.push({ newSiteResource: newResource, newSites: allSites, diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 1bbe0a4f1..92d6239fa 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -51,6 +51,11 @@ import { build } from "@server/build"; import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import next from "next"; +import { LimitId } from "../billing"; +import { usageService } from "../billing/usageService"; export type PublicResourcesResults = { proxyResource: Resource; @@ -1005,6 +1010,33 @@ export async function updatePublicResources( logger.debug(`Updated resource ${existingResource.resourceId}`); } else { // create a brand new resource + + if (build == "saas") { + const usage = await usageService.getUsage( + orgId, + LimitId.PUBLIC_RESOURCES + ); + if (!usage) { + throw new Error( + `Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}` + ); + } + const rejectResource = await usageService.checkLimitSet( + orgId, + + LimitId.PUBLIC_RESOURCES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectResource) { + throw new Error( + "Public resource limit exceeded. Please upgrade your plan." + ); + } + } + let domain; if ( ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") @@ -1294,6 +1326,8 @@ export async function updatePublicResources( await createTarget(newResource.resourceId, targetData); } + await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx); + logger.debug(`Created resource ${newResource.resourceId}`); } diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index f64b77b7f..11c7c9ec2 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -156,7 +156,7 @@ export async function createClient( return next( createHttpError( HttpCode.FORBIDDEN, - "Public resource limit exceeded. Please upgrade your plan." + "Machine client limit exceeded. Please upgrade your plan." ) ); } From 42d98fa83bc1fa158750b3dd40598391c52f0853 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 16:34:10 -0400 Subject: [PATCH 20/79] Comment back in the sync command --- server/routers/newt/handleNewtPingMessage.ts | 26 +++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index c56c5f6d4..56b8a2a24 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -49,22 +49,20 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); - // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, newt.siteId)) - // .limit(1); + if (!site) { + logger.warn( + `Newt ping message: site with ID ${newt.siteId} not found` + ); + return; + } - // if (!site) { - // logger.warn( - // `Newt ping message: site with ID ${newt.siteId} not found` - // ); - // return; - // } - - // await sendNewtSyncMessage(newt, site); + await sendNewtSyncMessage(newt, site); } return { From ccabddc22511d009100c25499f09ef7638f6c0af Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 18:05:29 -0400 Subject: [PATCH 21/79] Add logging for access for new public resources --- server/private/routers/auditLogs/index.ts | 1 + .../auditLogs/logAccessAuditAttempt.ts | 95 +++++++++++++++++++ .../routers/auditLogs/queryAccessAuditLog.ts | 8 +- server/private/routers/external.ts | 6 ++ server/routers/auditLogs/types.ts | 1 + src/app/[orgId]/settings/logs/access/page.tsx | 23 ++++- src/app/rdp/RdpClient.tsx | 13 +++ src/app/ssh/SshClient.tsx | 19 ++++ src/app/vnc/VncClient.tsx | 27 ++++++ 9 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 server/private/routers/auditLogs/logAccessAuditAttempt.ts diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index aacd37635..5fffa2b86 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; export * from "./queryConnectionAuditLog"; export * from "./exportConnectionAuditLog"; +export * from "./logAccessAuditAttempt"; diff --git a/server/private/routers/auditLogs/logAccessAuditAttempt.ts b/server/private/routers/auditLogs/logAccessAuditAttempt.ts new file mode 100644 index 000000000..5b0fb9c92 --- /dev/null +++ b/server/private/routers/auditLogs/logAccessAuditAttempt.ts @@ -0,0 +1,95 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; + +export const logAccessAuditAttemptSchema = z.object({ + resourceId: z.number().int().positive(), + action: z.boolean(), + type: z.enum(["login", "ssh", "vnc", "rdp"]) +}); + +export const logAccessAuditAttemptParams = z.object({ + orgId: z.string() +}); + +export async function logAccessAuditAttempt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error) + ) + ); + } + const parsedParams = logAccessAuditAttemptParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const { resourceId, action, type } = parsedBody.data; + + const username = req.user?.username; + const userId = req.user?.userId; + + await logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: action, + ...(username && userId + ? { + user: { + username, + userId + } + } + : {}), + type: type, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Access audit attempt logged successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 0feca4154..9e819db64 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -22,7 +22,7 @@ import { import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -120,7 +120,10 @@ function getWhere(data: Q) { lt(accessAuditLog.timestamp, data.timeEnd), eq(accessAuditLog.orgId, data.orgId), data.resourceId - ? eq(accessAuditLog.resourceId, data.resourceId) + ? or( + eq(accessAuditLog.resourceId, data.resourceId), + eq(accessAuditLog.siteResourceId, data.resourceId) + ) : undefined, data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, data.actorType @@ -233,7 +236,6 @@ async function enrichWithResourceDetails( const details = siteResourceMap.get(log.siteResourceId); return { ...log, - resourceId: log.siteResourceId, resourceName: details?.name ?? null, resourceNiceId: details?.niceId ?? null }; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 3cecd2ebb..179ec89c7 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -878,3 +878,9 @@ authenticated.post( verifyClientAccess, client.rebuildClientAssociationsCacheRoute ); + +authenticated.post( + "/org/:orgId/logs/access/attempt", + verifyOrgAccess, + logs.logAccessAuditAttempt +); diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index b8168ef1e..15ca1e87e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = { actorType: string | null; actorId: string | null; resourceId: number | null; + siteResourceId: number | null; resourceName: string | null; resourceNiceId: string | null; ip: string | null; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index fc0660ebb..6ac2a30e8 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -342,7 +342,7 @@ export default function GeneralPage() { return ( { const typeLabel = - row.original.type === "ssh" - ? "SSH" + row.original.type === "ssh" || + row.original.type === "rdp" || + row.original.type === "vnc" + ? row.original.type.toUpperCase() : row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1); return {typeLabel || "-"}; @@ -513,7 +517,15 @@ export default function GeneralPage() { function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"]; - const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"]; + const types = [ + "password", + "pincode", + "login", + "whitelistedEmail", + "ssh", + "rdp", + "vnc" + ]; const actors = [ "alice@example.com", "bob@example.com", @@ -538,6 +550,7 @@ function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] { actor, actorId: actor ? `user-${i}` : null, resourceId: Math.floor(Math.random() * 5) + 1, + siteResourceId: null, resourceNiceId: `resource-${(i % 3) + 1}`, resourceName: `Resource ${(i % 3) + 1}`, ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`, diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 1c7d77eb9..668a42cec 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -42,6 +42,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; declare module "react" { namespace JSX { @@ -96,6 +98,7 @@ export default function RdpClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_rdp_credentials"; const resourceName = target?.name?.trim() || null; @@ -311,6 +314,11 @@ export default function RdpClient({ values, target.authToken ); + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "rdp" + }); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); @@ -320,6 +328,11 @@ export default function RdpClient({ fileTransferRef.current = null; setShowLogin(true); } catch (err) { + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "rdp" + }); setConnecting(false); setShowLogin(true); if (isIronError(err)) { diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 7a2bcd425..8fc0423e7 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -36,6 +36,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type AuthTab = "password" | "privateKey"; @@ -73,6 +75,7 @@ export default function SshClient({ }) { const STORAGE_KEY = "pangolin_ssh_credentials"; const t = useTranslations(); + const api = createApiClient(useEnvContext()); const resourceName = target?.name?.trim() || null; const passwordTabSchema = z.object({ @@ -263,6 +266,17 @@ export default function SshClient({ let authConfirmed = false; let authErrorShown = false; let socketOpened = false; + let auditLogged = false; + + const logAudit = (action: boolean) => { + if (auditLogged || !target) return; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action, + type: "ssh" + }); + }; ws.onopen = () => { socketOpened = true; @@ -294,6 +308,7 @@ export default function SshClient({ if (msg.type === "data" && msg.data) { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -301,6 +316,7 @@ export default function SshClient({ } else if (msg.type === "error") { if (!authConfirmed) { authErrorShown = true; + logAudit(false); setConnecting(false); setConnectError( msg.error ?? t("sshErrorAuthFailed") @@ -323,6 +339,7 @@ export default function SshClient({ evt.data.text().then((text) => { if (!authConfirmed) { authConfirmed = true; + logAudit(true); setConnecting(false); setConnected(true); } @@ -332,6 +349,7 @@ export default function SshClient({ }; ws.onerror = () => { + logAudit(false); setConnecting(false); setConnected(false); setConnectError(t("sshErrorWebSocket")); @@ -355,6 +373,7 @@ export default function SshClient({ ); } if (!authConfirmed && !authErrorShown) { + logAudit(false); setConnectError(t("sshErrorConnectionClosed")); } }; diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index ac50bb587..b44ab9168 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -33,6 +33,8 @@ import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type VncCredentialsForm = { password: string; @@ -52,6 +54,7 @@ export default function VncClient({ primaryColor?: string | null; }) { const t = useTranslations(); + const api = createApiClient(useEnvContext()); const STORAGE_KEY = "pangolin_vnc_credentials"; const resourceName = target?.name?.trim() || null; @@ -179,6 +182,7 @@ export default function VncClient({ } let authConfirmed = false; + let auditLogged = false; rfb.scaleViewport = true; rfb.resizeSession = true; @@ -190,6 +194,12 @@ export default function VncClient({ target.authToken ); authConfirmed = true; + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: true, + type: "vnc" + }); setConnecting(false); setConnected(true); }); @@ -201,6 +211,17 @@ export default function VncClient({ setConnecting(false); setConnected(false); if (!authConfirmed && !e.detail.clean) { + if (!auditLogged) { + auditLogged = true; + void api.post( + `/org/${target.orgId}/logs/access/attempt`, + { + resourceId: target.resourceId, + action: false, + type: "vnc" + } + ); + } setConnectError(t("sshErrorConnectionClosed")); } } @@ -209,6 +230,12 @@ export default function VncClient({ rfb.addEventListener( "securityfailure", (e: { detail: { status: number; reason?: string } }) => { + auditLogged = true; + void api.post(`/org/${target.orgId}/logs/access/attempt`, { + resourceId: target.resourceId, + action: false, + type: "vnc" + }); disconnect(); setConnectError( e.detail.reason ?? From 7c2ea153c5b194213783f5e4db910a2e3dc93c47 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 18:33:03 -0400 Subject: [PATCH 22/79] Use regional cache for rate limiting --- server/private/lib/rateLimit.ts | 34 ++++++++++++++-------------- server/private/lib/redis.ts | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/server/private/lib/rateLimit.ts b/server/private/lib/rateLimit.ts index a8cf3c01c..32aa6419c 100644 --- a/server/private/lib/rateLimit.ts +++ b/server/private/lib/rateLimit.ts @@ -12,7 +12,7 @@ */ import logger from "@server/logger"; -import redisManager from "#private/lib/redis"; +import { regionalRedisManager as redisManager } from "#private/lib/redis"; import { build } from "@server/build"; // Rate limiting configuration @@ -152,10 +152,9 @@ export class RateLimitService { ); // Set TTL using the client directly - this prevents the key from persisting forever - if (redisManager.getClient()) { - await redisManager - .getClient() - .expire(globalKey, RATE_LIMIT_WINDOW + 10); + const writeClient = redisManager.getClient(); + if (writeClient) { + await writeClient.expire(globalKey, RATE_LIMIT_WINDOW + 10); } // Update tracking @@ -204,10 +203,12 @@ export class RateLimitService { ); // Set TTL using the client directly - this prevents the key from persisting forever - if (redisManager.getClient()) { - await redisManager - .getClient() - .expire(messageTypeKey, RATE_LIMIT_WINDOW + 10); + const writeClient = redisManager.getClient(); + if (writeClient) { + await writeClient.expire( + messageTypeKey, + RATE_LIMIT_WINDOW + 10 + ); } // Update tracking @@ -487,16 +488,13 @@ export class RateLimitService { await redisManager.del(globalKey); // Get all message type keys for this client and delete them - const client = redisManager.getClient(); - if (client) { - const messageTypeKeys = await client.keys( - `ratelimit:${clientId}:*` + const messageTypeKeys = await redisManager.keys( + `ratelimit:${clientId}:*` + ); + if (messageTypeKeys.length > 0) { + await Promise.all( + messageTypeKeys.map((key) => redisManager.del(key)) ); - if (messageTypeKeys.length > 0) { - await Promise.all( - messageTypeKeys.map((key) => redisManager.del(key)) - ); - } } } } diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 7c3d67836..bda2e6204 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -1000,6 +1000,45 @@ class RegionalRedisManager { } } + public getClient(): Redis | null { + return this.writeClient; + } + + public async hget(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.readClient) return null; + try { + return await this.readClient.hget(key, field); + } catch (error) { + logger.error("Regional Redis HGET error:", error); + return null; + } + } + + public async hset( + key: string, + field: string, + value: string + ): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return false; + try { + await this.writeClient.hset(key, field, value); + return true; + } catch (error) { + logger.error("Regional Redis HSET error:", error); + return false; + } + } + + public async hgetall(key: string): Promise> { + if (!this.isRedisEnabled() || !this.readClient) return {}; + try { + return await this.readClient.hgetall(key); + } catch (error) { + logger.error("Regional Redis HGETALL error:", error); + return {}; + } + } + public async disconnect(): Promise { try { if (this.writeClient) { From e5652cdb8aa0a2315d75c3db31d36268a7cf0fe9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 20:45:38 -0400 Subject: [PATCH 23/79] Dont enable admin routes --- server/private/routers/external.ts | 42 +++---- server/routers/external.ts | 186 +++++++++++++++-------------- src/app/admin/layout.tsx | 6 + 3 files changed, 125 insertions(+), 109 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 179ec89c7..ac18861ca 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -495,29 +495,31 @@ authRouter.post( auth.transferSession ); -authenticated.post( - "/license/activate", - verifyUserIsServerAdmin, - license.activateLicense -); +if (build !== "saas") { + authenticated.post( + "/license/activate", + verifyUserIsServerAdmin, + license.activateLicense + ); -authenticated.get( - "/license/keys", - verifyUserIsServerAdmin, - license.listLicenseKeys -); + authenticated.get( + "/license/keys", + verifyUserIsServerAdmin, + license.listLicenseKeys + ); -authenticated.delete( - "/license/:licenseKey", - verifyUserIsServerAdmin, - license.deleteLicenseKey -); + authenticated.delete( + "/license/:licenseKey", + verifyUserIsServerAdmin, + license.deleteLicenseKey + ); -authenticated.post( - "/license/recheck", - verifyUserIsServerAdmin, - license.recheckStatus -); + authenticated.post( + "/license/recheck", + verifyUserIsServerAdmin, + license.recheckStatus + ); +} authenticated.get( "/org/:orgId/logs/action", diff --git a/server/routers/external.ts b/server/routers/external.ts index 960c00249..a64162e6a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -910,19 +910,6 @@ unauthenticated.post( ); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); -authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); -authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); -authenticated.post( - "/user/:userId/generate-password-reset-code", - verifyUserIsServerAdmin, - user.adminGeneratePasswordResetCode -); -authenticated.delete( - "/user/:userId", - verifyUserIsServerAdmin, - user.adminRemoveUser -); - authenticated.put( "/org/:orgId/user", verifyOrgAccess, @@ -945,12 +932,6 @@ authenticated.post( authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); -authenticated.post( - "/user/:userId/2fa", - verifyUserIsServerAdmin, - user.updateUser2FA -); - authenticated.get( "/org/:orgId/users", verifyOrgAccess, @@ -1033,85 +1014,112 @@ authenticated.post( olm.recoverOlmWithFingerprint ); -authenticated.put( - "/idp/oidc", - verifyUserIsServerAdmin, - // verifyUserHasAction(ActionsEnum.createIdp), - idp.createOidcIdp -); +if (build !== "saas") { + authenticated.put( + "/idp/oidc", + verifyUserIsServerAdmin, + // verifyUserHasAction(ActionsEnum.createIdp), + idp.createOidcIdp + ); -authenticated.post( - "/idp/:idpId/oidc", - verifyUserIsServerAdmin, - idp.updateOidcIdp -); + authenticated.post( + "/idp/:idpId/oidc", + verifyUserIsServerAdmin, + idp.updateOidcIdp + ); -authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); + authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); -authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); + authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); -authenticated.put( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.createIdpOrgPolicy -); + authenticated.put( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.createIdpOrgPolicy + ); -authenticated.post( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.updateIdpOrgPolicy -); + authenticated.post( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.updateIdpOrgPolicy + ); -authenticated.delete( - "/idp/:idpId/org/:orgId", - verifyUserIsServerAdmin, - idp.deleteIdpOrgPolicy -); + authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyUserIsServerAdmin, + idp.deleteIdpOrgPolicy + ); -authenticated.get( - "/idp/:idpId/org", - verifyUserIsServerAdmin, - idp.listIdpOrgPolicies -); + authenticated.get( + "/idp/:idpId/org", + verifyUserIsServerAdmin, + idp.listIdpOrgPolicies + ); + + authenticated.get( + `/api-key/:apiKeyId`, + verifyUserIsServerAdmin, + apiKeys.getApiKey + ); + + authenticated.put( + `/api-key`, + verifyUserIsServerAdmin, + apiKeys.createRootApiKey + ); + + authenticated.delete( + `/api-key/:apiKeyId`, + verifyUserIsServerAdmin, + apiKeys.deleteApiKey + ); + + authenticated.get( + `/api-keys`, + verifyUserIsServerAdmin, + apiKeys.listRootApiKeys + ); + + authenticated.get( + `/api-key/:apiKeyId/actions`, + verifyUserIsServerAdmin, + apiKeys.listApiKeyActions + ); + + authenticated.post( + `/api-key/:apiKeyId/actions`, + verifyUserIsServerAdmin, + apiKeys.setApiKeyActions + ); + + authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); + + authenticated.get( + "/user/:userId", + verifyUserIsServerAdmin, + user.adminGetUser + ); + + authenticated.post( + "/user/:userId/generate-password-reset-code", + verifyUserIsServerAdmin, + user.adminGeneratePasswordResetCode + ); + + authenticated.delete( + "/user/:userId", + verifyUserIsServerAdmin, + user.adminRemoveUser + ); + + authenticated.post( + "/user/:userId/2fa", + verifyUserIsServerAdmin, + user.updateUser2FA + ); +} authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids -authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); - -authenticated.get( - `/api-key/:apiKeyId`, - verifyUserIsServerAdmin, - apiKeys.getApiKey -); - -authenticated.put( - `/api-key`, - verifyUserIsServerAdmin, - apiKeys.createRootApiKey -); - -authenticated.delete( - `/api-key/:apiKeyId`, - verifyUserIsServerAdmin, - apiKeys.deleteApiKey -); - -authenticated.get( - `/api-keys`, - verifyUserIsServerAdmin, - apiKeys.listRootApiKeys -); - -authenticated.get( - `/api-key/:apiKeyId/actions`, - verifyUserIsServerAdmin, - apiKeys.listApiKeyActions -); - -authenticated.post( - `/api-key/:apiKeyId/actions`, - verifyUserIsServerAdmin, - apiKeys.setApiKeyActions -); authenticated.get( `/org/:orgId/api-keys`, diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 5f35ee4cd..15d900ee8 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout"; import { adminNavSections } from "../navigation"; import { pullEnv } from "@app/lib/pullEnv"; import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider"; +import { build } from "@server/build"; export const dynamic = "force-dynamic"; @@ -29,6 +30,11 @@ export default async function AdminLayout(props: LayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + // Disable the admin page on saas + if (build == "saas") { + redirect(`/`); + } + const env = pullEnv(); if (!user || !user.serverAdmin) { From b41c1f5b270002e9c787ef617d76fc9740b831e4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 29 Jun 2026 21:10:49 -0400 Subject: [PATCH 24/79] Add restart button --- messages/en-US.json | 10 ++ server/routers/external.ts | 8 ++ server/routers/site/index.ts | 1 + server/routers/site/restartSite.ts | 116 ++++++++++++++++++ .../settings/sites/[niceId]/general/page.tsx | 64 ++++++++++ 5 files changed, 199 insertions(+) create mode 100644 server/routers/site/restartSite.ts diff --git a/messages/en-US.json b/messages/en-US.json index c7964f8be..388e34015 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -123,6 +123,16 @@ "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", + "siteRestartTitle": "Restart Site", + "siteRestartDescription": "Restart the WireGuard tunnel for this site. This will briefly interrupt connectivity.", + "siteRestartBody": "Use this if the site tunnel is not functioning correctly and you want to force a reconnect without restarting the host.", + "siteRestartButton": "Restart Site", + "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for {name}? The site will briefly lose connectivity.", + "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", + "siteRestarted": "Site restarted", + "siteRestartedDescription": "The WireGuard tunnel has been restarted.", + "siteErrorRestart": "Failed to restart site", + "siteErrorRestartDescription": "An error occurred while restarting the site.", "siteSettingDescription": "Configure the settings on the site", "siteResourcesTab": "Resources", "siteResourcesNoneOnSite": "This site has no public or private resources yet.", diff --git a/server/routers/external.ts b/server/routers/external.ts index a64162e6a..326e555a8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -254,6 +254,14 @@ authenticated.delete( site.deleteSite ); +authenticated.post( + "/site/:siteId/restart", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.restartSite +); + // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 00fdeda91..292c57971 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -7,3 +7,4 @@ export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; +export * from "./restartSite"; diff --git a/server/routers/site/restartSite.ts b/server/routers/site/restartSite.ts new file mode 100644 index 000000000..705b65db8 --- /dev/null +++ b/server/routers/site/restartSite.ts @@ -0,0 +1,116 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } from "@server/db"; +import { sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { sendToClient } from "../ws"; + +const updateSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/restart", + description: "Restart a site.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function restartSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + // get the newt + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Newt for site with ID ${siteId} not found` + ) + ); + } + + await sendToClient( + newt.newtId, + { + type: "newt/wg/restart", + data: {} + }, + { incrementConfigVersion: false, compress: false } + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Site restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index fdbfc387d..37a92c409 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button as ButtonUI } from "@/components/ui/button"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -72,6 +73,23 @@ export default function GeneralPage() { const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( null ); + const [isRestartDialogOpen, setIsRestartDialogOpen] = useState(false); + + async function restartSite() { + try { + await api.post(`/site/${site?.siteId}/restart`); + toast({ + title: t("siteRestarted"), + description: t("siteRestartedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorRestart"), + description: formatAxiosError(e, t("siteErrorRestartDescription")) + }); + } + } const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false; @@ -349,6 +367,52 @@ export default function GeneralPage() { + {site && site.type === "newt" && ( + <> + + {t.rich("siteRestartDialogMessage", { + name: site.name, + b: (chunks) => {chunks} + })} +

+ } + buttonText={t("siteRestartButton")} + onConfirm={restartSite} + string={site.name} + warningText={t("siteRestartWarning")} + title={t("siteRestartTitle")} + /> + + + + {t("siteRestartTitle")} + + + {t("siteRestartDescription")} + + + + +

+ {t("siteRestartBody")} +

+
+
+ + + +
+ + )} ); } From 29563a13a47317008a65bd95459f55c6c06817be Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Jun 2026 09:41:32 -0400 Subject: [PATCH 25/79] Add indexes on the niceId and orgId --- server/db/pg/schema/schema.ts | 180 +++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 81 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1b48aa520..57c68c232 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -128,7 +128,8 @@ export const sites = pgTable( t.exitNodeId, t.type, t.siteId - ) + ), + index("idx_sites_orgid_niceid").on(t.orgId, t.niceId) ] ); @@ -203,7 +204,9 @@ export const resources = pgTable( (t) => [ index("idx_resources_fulldomain") .on(t.fullDomain) - .where(sql`${t.fullDomain} IS NOT NULL`) + .where(sql`${t.fullDomain} IS NOT NULL`), + index("idx_resources_niceid").on(t.niceId), + index("idx_resources_orgid_niceid").on(t.orgId, t.niceId) ] ); @@ -384,63 +387,71 @@ export const exitNodes = pgTable("exitNodes", { region: varchar("region") }); -export const siteResources = pgTable("siteResources", { - // this is for the clients - siteResourceId: serial("siteResourceId").primaryKey(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - networkId: integer("networkId").references(() => networks.networkId, { - onDelete: "set null" - }), - defaultNetworkId: integer("defaultNetworkId").references( - () => networks.networkId, - { - onDelete: "restrict" - } - ), - niceId: varchar("niceId").notNull(), - name: varchar("name").notNull(), - ssl: boolean("ssl").notNull().default(false), - mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" - scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode - proxyPort: integer("proxyPort"), // only for port mode - destinationPort: integer("destinationPort"), // only for port mode - destination: varchar("destination"), // ip, cidr, hostname; validate against the mode - enabled: boolean("enabled").notNull().default(true), - alias: varchar("alias"), - aliasAddress: varchar("aliasAddress"), - tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), - udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), - disableIcmp: boolean("disableIcmp").notNull().default(false), - authDaemonPort: integer("authDaemonPort").default(22123), - pamMode: varchar("pamMode", { length: 32 }) - .$type<"passthrough" | "push">() - .default("passthrough"), - authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote" | "native">() - .default("site"), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain") -}); +export const siteResources = pgTable( + "siteResources", + { + // this is for the clients + siteResourceId: serial("siteResourceId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + networkId: integer("networkId").references(() => networks.networkId, { + onDelete: "set null" + }), + defaultNetworkId: integer("defaultNetworkId").references( + () => networks.networkId, + { + onDelete: "restrict" + } + ), + niceId: varchar("niceId").notNull(), + name: varchar("name").notNull(), + ssl: boolean("ssl").notNull().default(false), + mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" + scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode + proxyPort: integer("proxyPort"), // only for port mode + destinationPort: integer("destinationPort"), // only for port mode + destination: varchar("destination"), // ip, cidr, hostname; validate against the mode + enabled: boolean("enabled").notNull().default(true), + alias: varchar("alias"), + aliasAddress: varchar("aliasAddress"), + tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), + udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), + disableIcmp: boolean("disableIcmp").notNull().default(false), + authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote" | "native">() + .default("site"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain") + }, + (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] +); -export const networks = pgTable("networks", { - networkId: serial("networkId").primaryKey(), - niceId: text("niceId"), - name: text("name"), - scope: varchar("scope") - .$type<"global" | "resource">() - .notNull() - .default("global"), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull() -}); +export const networks = pgTable( + "networks", + { + networkId: serial("networkId").primaryKey(), + niceId: text("niceId"), + name: text("name"), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [index("idx_networks_orgid").on(t.orgId)] +); export const siteNetworks = pgTable( "siteNetworks", @@ -986,28 +997,32 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { value: varchar("value").notNull() }); -export const resourcePolicies = pgTable("resourcePolicies", { - resourcePolicyId: serial("resourcePolicyId").primaryKey(), - sso: boolean("sso").notNull().default(true), - applyRules: boolean("applyRules").notNull().default(false), - scope: varchar("scope") - .$type<"global" | "resource">() - .notNull() - .default("global"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled") - .notNull() - .default(false), - idpId: integer("idpId").references(() => idp.idpId, { - onDelete: "set null" - }), - niceId: text("niceId").notNull(), - name: varchar("name").notNull(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull() -}); +export const resourcePolicies = pgTable( + "resourcePolicies", + { + resourcePolicyId: serial("resourcePolicyId").primaryKey(), + sso: boolean("sso").notNull().default(true), + applyRules: boolean("applyRules").notNull().default(false), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled") + .notNull() + .default(false), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() + }, + (t) => [index("idx_resourcepolicies_orgid_niceid").on(t.orgId, t.niceId)] +); export const supporterKey = pgTable("supporterKey", { keyId: serial("keyId").primaryKey(), @@ -1131,7 +1146,10 @@ export const clients = pgTable( "pending" | "approved" | "denied" >() }, - (t) => [index("idx_clients_userid").on(t.userId)] + (t) => [ + index("idx_clients_userid").on(t.userId), + index("idx_clients_orgid_niceid").on(t.orgId, t.niceId) + ] ); export const clientSitesAssociationsCache = pgTable( From 9bb2d6cdc814e2e6cd3f0d43224cba703ae6c325 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Jun 2026 12:16:37 -0400 Subject: [PATCH 26/79] Prompt for the username on vnc --- messages/en-US.json | 3 ++- src/app/vnc/VncClient.tsx | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 388e34015..41937e61e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3587,7 +3587,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Private key is required", "vncTitle": "VNC", - "vncSignInDescription": "Enter your VNC password to connect", + "vncSignInDescription": "Enter your VNC credentials to connect", + "vncUsernameOptional": "Username (optional)", "vncPasswordOptional": "Password (optional)", "vncNoResourceTarget": "No resource target is available", "vncFailedToLoadNovnc": "Failed to load noVNC", diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index b44ab9168..b549cc547 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -37,10 +37,12 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; type VncCredentialsForm = { + username: string; password: string; }; const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = { + username: "", password: "" }; @@ -59,6 +61,7 @@ export default function VncClient({ const resourceName = target?.name?.trim() || null; const formSchema = z.object({ + username: z.string(), password: z.string() }); @@ -168,8 +171,11 @@ export default function VncClient({ screenRef.current.innerHTML = ""; const options: Record = {}; - if (values.password) { - options.credentials = { password: values.password }; + if (values.username || values.password) { + options.credentials = { + username: values.username, + password: values.password + }; } let rfb: any; @@ -292,6 +298,25 @@ export default function VncClient({ onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" > + ( + + + {t("vncUsernameOptional")} + + + + + + + )} + /> Date: Tue, 30 Jun 2026 13:53:44 -0400 Subject: [PATCH 27/79] Update schema for tracking valid domains --- server/db/pg/schema/schema.ts | 15 +++++++++++---- server/db/sqlite/schema/privateSchema.ts | 3 --- server/db/sqlite/schema/schema.ts | 4 +++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 57c68c232..765c07724 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -25,7 +25,8 @@ export const domains = pgTable("domains", { certResolver: varchar("certResolver"), customCertResolver: varchar("customCertResolver"), preferWildcardCert: boolean("preferWildcardCert"), - errorMessage: text("errorMessage") + errorMessage: text("errorMessage"), + lastCheckedAt: integer("lastCheckedAt") }); export const dnsRecords = pgTable("dnsRecords", { @@ -407,7 +408,9 @@ export const siteResources = pgTable( niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), - mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" + mode: varchar("mode") + .$type<"host" | "cidr" | "http" | "ssh">() + .notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode @@ -415,8 +418,12 @@ export const siteResources = pgTable( enabled: boolean("enabled").notNull().default(true), alias: varchar("alias"), aliasAddress: varchar("aliasAddress"), - tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), - udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), + tcpPortRangeString: varchar("tcpPortRangeString") + .notNull() + .default("*"), + udpPortRangeString: varchar("udpPortRangeString") + .notNull() + .default("*"), disableIcmp: boolean("disableIcmp").notNull().default(false), authDaemonPort: integer("authDaemonPort").default(22123), pamMode: varchar("pamMode", { length: 32 }) diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index ae7360780..ad5f386eb 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -21,9 +21,6 @@ import { targetHealthCheck, users } from "./schema"; -import { serial, varchar } from "drizzle-orm/mysql-core"; -import { pgTable } from "drizzle-orm/pg-core"; -import { bigint } from "zod"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0c4a143f5..fe827149a 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", { failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), + customCertResolver: text("customCertResolver"), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), - errorMessage: text("errorMessage") + errorMessage: text("errorMessage"), + lastCheckedAt: integer("lastCheckedAt") }); export const dnsRecords = sqliteTable("dnsRecords", { From 0033f40f4d0dc041c2dffb2c3ea5046be8bd5af3 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Jun 2026 15:06:35 -0400 Subject: [PATCH 28/79] Fix typo preventing subscription cancelation --- server/private/routers/org/sendTrialNotification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/org/sendTrialNotification.ts b/server/private/routers/org/sendTrialNotification.ts index 233010064..d3210530a 100644 --- a/server/private/routers/org/sendTrialNotification.ts +++ b/server/private/routers/org/sendTrialNotification.ts @@ -215,7 +215,7 @@ export async function sendTrialNotification( if (resetLimits) { // this will only fire if they have not upgraded yet because when upgrading we delete the trial - await handleSubscriptionLifesycle(orgId, "cancled"); + await handleSubscriptionLifesycle(orgId, "canceled"); logger.debug( `Trial ended for org ${orgId}, limits reset to free tier` ); From 686789ee4c3021544d436d2cc38a1e63c26d6f15 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Jun 2026 15:37:42 -0400 Subject: [PATCH 29/79] Properly translate --- messages/en-US.json | 17 ++++ .../[remoteExitNodeId]/networking/page.tsx | 77 +++++++++++++------ 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 41937e61e..b8cf7d592 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2388,6 +2388,23 @@ "sidebarRemoteExitNodes": "Remote Nodes", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secret", + "remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. 10.0.0.0/8) and press Enter to add.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsSave": "Save Subnets", + "remoteExitNodeNetworkingSubnetsSaveSuccessTitle": "Subnets saved", + "remoteExitNodeNetworkingSubnetsSaveSuccessDescription": "Remote subnets have been updated successfully.", + "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets", + "remoteExitNodeNetworkingSubnetsSaveError": "Failed to save subnets", + "remoteExitNodeNetworkingLabelsTitle": "Preference Labels", + "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.", + "remoteExitNodeNetworkingLabelsButtonText": "Select labels...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", + "remoteExitNodeNetworkingLabelsSave": "Save Labels", + "remoteExitNodeNetworkingLabelsSaveSuccessTitle": "Labels saved", + "remoteExitNodeNetworkingLabelsSaveSuccessDescription": "Preference labels have been updated successfully.", + "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", + "remoteExitNodeNetworkingLabelsSaveError": "Failed to save labels", "remoteExitNodeCreate": { "title": "Create Remote Node", "description": "Create a new self-hosted remote relay and proxy server node", diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index a0da030ff..abac12488 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -27,11 +27,14 @@ import type { ListRemoteExitNodeResourcesResponse } from "@server/private/router import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels"; import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels"; +import { useTranslations } from "next-intl"; +import { ExternalLink } from "lucide-react"; const cidrRegex = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/; export default function NetworkingPage() { + const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams<{ @@ -91,9 +94,10 @@ export default function NetworkingPage() { } catch (error) { toast({ variant: "destructive", - title: "Error", + title: t("error"), description: - formatAxiosError(error) || "Failed to load subnets" + formatAxiosError(error) || + t("remoteExitNodeNetworkingSubnetsLoadError") }); } finally { setLoadingSubnets(false); @@ -117,9 +121,10 @@ export default function NetworkingPage() { } catch (error) { toast({ variant: "destructive", - title: "Error", + title: t("error"), description: - formatAxiosError(error) || "Failed to load labels" + formatAxiosError(error) || + t("remoteExitNodeNetworkingLabelsLoadError") }); } finally { setLoadingLabels(false); @@ -138,14 +143,18 @@ export default function NetworkingPage() { { destinations: subnets.map((s) => s.text) } ); toast({ - title: "Subnets saved", - description: "Remote subnets have been updated successfully." + title: t("remoteExitNodeNetworkingSubnetsSaveSuccessTitle"), + description: t( + "remoteExitNodeNetworkingSubnetsSaveSuccessDescription" + ) }); } catch (error) { toast({ variant: "destructive", - title: "Error", - description: formatAxiosError(error) || "Failed to save subnets" + title: t("error"), + description: + formatAxiosError(error) || + t("remoteExitNodeNetworkingSubnetsSaveError") }); } finally { setSavingSubnets(false); @@ -162,14 +171,18 @@ export default function NetworkingPage() { { labelIds: selectedLabels.map((l) => parseInt(l.id)) } ); toast({ - title: "Labels saved", - description: "Preference labels have been updated successfully." + title: t("remoteExitNodeNetworkingLabelsSaveSuccessTitle"), + description: t( + "remoteExitNodeNetworkingLabelsSaveSuccessDescription" + ) }); } catch (error) { toast({ variant: "destructive", - title: "Error", - description: formatAxiosError(error) || "Failed to save labels" + title: t("error"), + description: + formatAxiosError(error) || + t("remoteExitNodeNetworkingLabelsSaveError") }); } finally { setSavingLabels(false); @@ -180,18 +193,31 @@ export default function NetworkingPage() { - Remote Subnets + + {t("remoteExitNodeNetworkingSubnetsTitle")} + - Define the CIDR ranges that this remote exit node will - route traffic to. Type a valid CIDR (e.g.{" "} - 10.0.0.0/8) and press Enter to add. + {t.rich("remoteExitNodeNetworkingSubnetsDescription", { + code: (chunks) => {chunks} + })}{" "} + + {t("learnMore")} + + cidrRegex.test(tag.trim())} activeTagIndex={activeTagIndex} setActiveTagIndex={setActiveTagIndex} @@ -202,7 +228,7 @@ export default function NetworkingPage() { @@ -210,11 +236,10 @@ export default function NetworkingPage() { - Preference Labels + {t("remoteExitNodeNetworkingLabelsTitle")} - Sites with these labels will be enforced to connect - through this remote exit node. + {t("remoteExitNodeNetworkingLabelsDescription")} @@ -225,13 +250,17 @@ export default function NetworkingPage() { onSearch={setLabelSearchQuery} searchQuery={labelSearchQuery} disabled={loadingLabels} - buttonText="Select labels..." - searchPlaceholder="Search labels..." + buttonText={t( + "remoteExitNodeNetworkingLabelsButtonText" + )} + searchPlaceholder={t( + "remoteExitNodeNetworkingLabelsSearchPlaceholder" + )} /> From cfbbdedaf510f5ce22f770a7650574cecaa9930d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 30 Jun 2026 17:44:35 -0400 Subject: [PATCH 30/79] rework ui --- messages/en-US.json | 14 +- .../[remoteExitNodeId]/networking/page.tsx | 206 +++++++++--------- .../settings/sites/[niceId]/general/page.tsx | 64 ------ src/components/SitesTable.tsx | 55 +++++ 4 files changed, 167 insertions(+), 172 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b8cf7d592..993c2fb45 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2388,23 +2388,21 @@ "sidebarRemoteExitNodes": "Remote Nodes", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secret", + "remoteExitNodeNetworkingTitle": "Network Settings", + "remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it.", + "remoteExitNodeNetworkingSave": "Save Settings", + "remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved", + "remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.", + "remoteExitNodeNetworkingSaveError": "Failed to save network settings", "remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets", "remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. 10.0.0.0/8) and press Enter to add.", "remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)", - "remoteExitNodeNetworkingSubnetsSave": "Save Subnets", - "remoteExitNodeNetworkingSubnetsSaveSuccessTitle": "Subnets saved", - "remoteExitNodeNetworkingSubnetsSaveSuccessDescription": "Remote subnets have been updated successfully.", "remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets", - "remoteExitNodeNetworkingSubnetsSaveError": "Failed to save subnets", "remoteExitNodeNetworkingLabelsTitle": "Preference Labels", "remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.", "remoteExitNodeNetworkingLabelsButtonText": "Select labels...", "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...", - "remoteExitNodeNetworkingLabelsSave": "Save Labels", - "remoteExitNodeNetworkingLabelsSaveSuccessTitle": "Labels saved", - "remoteExitNodeNetworkingLabelsSaveSuccessDescription": "Preference labels have been updated successfully.", "remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels", - "remoteExitNodeNetworkingLabelsSaveError": "Failed to save labels", "remoteExitNodeCreate": { "title": "Create Remote Node", "description": "Create a new self-hosted remote relay and proxy server node", diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index abac12488..92eba552b 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -3,14 +3,18 @@ import { useEffect, useMemo, useState } from "react"; import { SettingsContainer, + SettingsFormCell, + SettingsFormGrid, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, + SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -47,13 +51,13 @@ export default function NetworkingPage() { const [subnets, setSubnets] = useState([]); const [activeTagIndex, setActiveTagIndex] = useState(null); const [loadingSubnets, setLoadingSubnets] = useState(true); - const [savingSubnets, setSavingSubnets] = useState(false); // Labels state const [selectedLabels, setSelectedLabels] = useState([]); const [labelSearchQuery, setLabelSearchQuery] = useState(""); const [loadingLabels, setLoadingLabels] = useState(true); - const [savingLabels, setSavingLabels] = useState(false); + + const [saving, setSaving] = useState(false); const [debouncedLabelQuery] = useDebounce(labelSearchQuery, 150); @@ -135,17 +139,27 @@ export default function NetworkingPage() { loadLabels(); }, [remoteExitNode.remoteExitNodeId]); - const handleSaveSubnets = async () => { - setSavingSubnets(true); + const handleSave = async () => { + setSaving(true); try { - await api.post>( - `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, - { destinations: subnets.map((s) => s.text) } - ); + await Promise.all([ + api.post< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, + { destinations: subnets.map((s) => s.text) } + ), + api.post< + AxiosResponse + >( + `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`, + { labelIds: selectedLabels.map((l) => parseInt(l.id)) } + ) + ]); toast({ - title: t("remoteExitNodeNetworkingSubnetsSaveSuccessTitle"), + title: t("remoteExitNodeNetworkingSaveSuccessTitle"), description: t( - "remoteExitNodeNetworkingSubnetsSaveSuccessDescription" + "remoteExitNodeNetworkingSaveSuccessDescription" ) }); } catch (error) { @@ -154,38 +168,10 @@ export default function NetworkingPage() { title: t("error"), description: formatAxiosError(error) || - t("remoteExitNodeNetworkingSubnetsSaveError") + t("remoteExitNodeNetworkingSaveError") }); } finally { - setSavingSubnets(false); - } - }; - - const handleSaveLabels = async () => { - setSavingLabels(true); - try { - await api.post< - AxiosResponse - >( - `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/preference-labels`, - { labelIds: selectedLabels.map((l) => parseInt(l.id)) } - ); - toast({ - title: t("remoteExitNodeNetworkingLabelsSaveSuccessTitle"), - description: t( - "remoteExitNodeNetworkingLabelsSaveSuccessDescription" - ) - }); - } catch (error) { - toast({ - variant: "destructive", - title: t("error"), - description: - formatAxiosError(error) || - t("remoteExitNodeNetworkingLabelsSaveError") - }); - } finally { - setSavingLabels(false); + setSaving(false); } }; @@ -194,73 +180,93 @@ export default function NetworkingPage() { - {t("remoteExitNodeNetworkingSubnetsTitle")} + {t("remoteExitNodeNetworkingTitle")} - {t.rich("remoteExitNodeNetworkingSubnetsDescription", { - code: (chunks) => {chunks} - })}{" "} - - {t("learnMore")} - - + {t("remoteExitNodeNetworkingDescription")} - cidrRegex.test(tag.trim())} - activeTagIndex={activeTagIndex} - setActiveTagIndex={setActiveTagIndex} - disabled={loadingSubnets} - allowDuplicates={false} - inlineTags={true} - /> + + + +
+ + + cidrRegex.test(tag.trim()) + } + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + disabled={loadingSubnets} + allowDuplicates={false} + size="sm" + inlineTags={true} + /> +

+ {t.rich( + "remoteExitNodeNetworkingSubnetsDescription", + { + code: (chunks) => ( + {chunks} + ) + } + )}{" "} + + {t("learnMore")} + + +

+
+
+ +
+ + +

+ {t( + "remoteExitNodeNetworkingLabelsDescription" + )} +

+
+
+
+
- - -
- - - - - {t("remoteExitNodeNetworkingLabelsTitle")} - - - {t("remoteExitNodeNetworkingLabelsDescription")} - - - - - - - diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 37a92c409..fdbfc387d 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -43,7 +43,6 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button as ButtonUI } from "@/components/ui/button"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -73,23 +72,6 @@ export default function GeneralPage() { const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( null ); - const [isRestartDialogOpen, setIsRestartDialogOpen] = useState(false); - - async function restartSite() { - try { - await api.post(`/site/${site?.siteId}/restart`); - toast({ - title: t("siteRestarted"), - description: t("siteRestartedDescription") - }); - } catch (e) { - toast({ - variant: "destructive", - title: t("siteErrorRestart"), - description: formatAxiosError(e, t("siteErrorRestartDescription")) - }); - } - } const orgAutoUpdate = org.org.settingsEnableGlobalNewtAutoUpdate ?? false; @@ -367,52 +349,6 @@ export default function GeneralPage() { - {site && site.type === "newt" && ( - <> - - {t.rich("siteRestartDialogMessage", { - name: site.name, - b: (chunks) => {chunks} - })} -

- } - buttonText={t("siteRestartButton")} - onConfirm={restartSite} - string={site.name} - warningText={t("siteRestartWarning")} - title={t("siteRestartTitle")} - /> - - - - {t("siteRestartTitle")} - - - {t("siteRestartDescription")} - - - - -

- {t("siteRestartBody")} -

-
-
- - - -
- - )}
); } diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 3dc7a56da..efcf83e72 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -107,6 +107,7 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [restartingSite, setRestartingSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); const [isRefreshing, startTransition] = useTransition(); @@ -159,6 +160,24 @@ export default function SitesTable({ }); } + async function restartSite(siteId: number) { + try { + await api.post(`/site/${siteId}/restart`); + toast({ + title: t("siteRestarted"), + description: t("siteRestartedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteErrorRestart"), + description: formatAxiosError(e, t("siteErrorRestartDescription")) + }); + } finally { + setRestartingSite(null); + } + } + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api @@ -526,6 +545,20 @@ export default function SitesTable({ + {siteRow.type === "newt" && ( + <> + + setRestartingSite(siteRow) + } + > + + {t("siteRestartButton")} + + + + + )} { setSelectedSite(siteRow); @@ -654,6 +687,28 @@ export default function SitesTable({ + {restartingSite && ( + { + if (!val) setRestartingSite(null); + }} + dialog={ +

+ {t.rich("siteRestartDialogMessage", { + name: restartingSite.name, + b: (chunks) => {chunks} + })} +

+ } + buttonText={t("siteRestartButton")} + onConfirm={() => restartSite(restartingSite.id)} + string={restartingSite.name} + warningText={t("siteRestartWarning")} + title={t("siteRestartTitle")} + /> + )} + {selectedSite && ( Date: Tue, 30 Jun 2026 21:03:19 -0400 Subject: [PATCH 31/79] basic functionality --- .cursor/rules/Migrations.mdc | 5 + messages/en-US.json | 43 + server/db/pg/schema/schema.ts | 15 + server/db/sqlite/schema/schema.ts | 15 + server/routers/external.ts | 37 + server/routers/integration.ts | 37 + server/routers/launcher/createLauncherView.ts | 118 ++ server/routers/launcher/deleteLauncherView.ts | 97 ++ .../routers/launcher/formatLauncherAccess.ts | 139 +++ server/routers/launcher/index.ts | 7 + .../launcher/launcherResourceAccess.ts | 1090 ++++++++++++++++ server/routers/launcher/listLauncherGroups.ts | 73 ++ .../routers/launcher/listLauncherResources.ts | 78 ++ server/routers/launcher/listLauncherViews.ts | 83 ++ server/routers/launcher/types.ts | 129 ++ server/routers/launcher/updateLauncherView.ts | 164 +++ src/app/[orgId]/page.tsx | 24 +- src/components/Layout.tsx | 16 +- src/components/LayoutHeader.tsx | 53 +- src/components/LayoutSidebar.tsx | 55 +- src/components/MemberResourcesPortal.tsx | 1101 ----------------- src/components/labels-selector.tsx | 15 + .../resource-launcher/LauncherCopyIcon.tsx | 43 + .../LauncherFilterPopover.tsx | 142 +++ .../resource-launcher/LauncherGroupList.tsx | 201 +++ .../LauncherGroupSection.tsx | 164 +++ .../LauncherGroupTrigger.tsx | 67 + .../resource-launcher/LauncherLabelsRow.tsx | 175 +++ .../resource-launcher/LauncherOrgSelector.tsx | 114 ++ .../LauncherResourceAccess.tsx | 69 ++ .../LauncherResourceCard.tsx | 73 ++ .../LauncherResourceGrid.tsx | 26 + .../LauncherResourceIcon.tsx | 45 + .../LauncherResourceList.tsx | 30 + .../resource-launcher/LauncherResourceRow.tsx | 87 ++ .../LauncherSettingsMenu.tsx | 148 +++ .../resource-launcher/LauncherSortButton.tsx | 38 + .../resource-launcher/LauncherViewTabs.tsx | 121 ++ .../resource-launcher/ResourceLauncher.tsx | 532 ++++++++ .../useLauncherResourceAction.ts | 88 ++ src/lib/launcherLocalStorage.ts | 98 ++ src/lib/launcherResourceAccess.ts | 123 ++ src/lib/launcherUrlState.ts | 292 +++++ src/lib/queries.ts | 100 ++ 44 files changed, 5048 insertions(+), 1122 deletions(-) create mode 100644 .cursor/rules/Migrations.mdc create mode 100644 server/routers/launcher/createLauncherView.ts create mode 100644 server/routers/launcher/deleteLauncherView.ts create mode 100644 server/routers/launcher/formatLauncherAccess.ts create mode 100644 server/routers/launcher/index.ts create mode 100644 server/routers/launcher/launcherResourceAccess.ts create mode 100644 server/routers/launcher/listLauncherGroups.ts create mode 100644 server/routers/launcher/listLauncherResources.ts create mode 100644 server/routers/launcher/listLauncherViews.ts create mode 100644 server/routers/launcher/types.ts create mode 100644 server/routers/launcher/updateLauncherView.ts delete mode 100644 src/components/MemberResourcesPortal.tsx create mode 100644 src/components/resource-launcher/LauncherCopyIcon.tsx create mode 100644 src/components/resource-launcher/LauncherFilterPopover.tsx create mode 100644 src/components/resource-launcher/LauncherGroupList.tsx create mode 100644 src/components/resource-launcher/LauncherGroupSection.tsx create mode 100644 src/components/resource-launcher/LauncherGroupTrigger.tsx create mode 100644 src/components/resource-launcher/LauncherLabelsRow.tsx create mode 100644 src/components/resource-launcher/LauncherOrgSelector.tsx create mode 100644 src/components/resource-launcher/LauncherResourceAccess.tsx create mode 100644 src/components/resource-launcher/LauncherResourceCard.tsx create mode 100644 src/components/resource-launcher/LauncherResourceGrid.tsx create mode 100644 src/components/resource-launcher/LauncherResourceIcon.tsx create mode 100644 src/components/resource-launcher/LauncherResourceList.tsx create mode 100644 src/components/resource-launcher/LauncherResourceRow.tsx create mode 100644 src/components/resource-launcher/LauncherSettingsMenu.tsx create mode 100644 src/components/resource-launcher/LauncherSortButton.tsx create mode 100644 src/components/resource-launcher/LauncherViewTabs.tsx create mode 100644 src/components/resource-launcher/ResourceLauncher.tsx create mode 100644 src/components/resource-launcher/useLauncherResourceAction.ts create mode 100644 src/lib/launcherLocalStorage.ts create mode 100644 src/lib/launcherResourceAccess.ts create mode 100644 src/lib/launcherUrlState.ts diff --git a/.cursor/rules/Migrations.mdc b/.cursor/rules/Migrations.mdc new file mode 100644 index 000000000..d9562b4e1 --- /dev/null +++ b/.cursor/rules/Migrations.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +Don't write or edit migrations in `server/setup` unless specificall instructed to do so. diff --git a/messages/en-US.json b/messages/en-US.json index c7964f8be..d27196be2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2077,6 +2077,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", + "selectLabels": "Select labels", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", "clientInstallOlmDescription": "Install the machine client for your system", @@ -2304,6 +2305,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -3542,6 +3544,47 @@ "memberPortalEmailWhitelist": "Email Whitelist", "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", + "resourceLauncherTitle": "Resource Launcher", + "resourceLauncherDescription": "View resource details and launch them from one place", + "resourceLauncherSearchPlaceholder": "Search all sites...", + "resourceLauncherDefaultView": "Default", + "resourceLauncherSaveView": "Save View", + "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveAsNewView": "Save as New View", + "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", + "resourceLauncherSaveForEveryone": "Save for Everyone", + "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", + "resourceLauncherMakePersonal": "Make Personal", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sort", + "resourceLauncherSortAscending": "Sort ascending", + "resourceLauncherSortDescending": "Sort descending", + "resourceLauncherSettings": "Settings", + "resourceLauncherGroupBy": "Group By", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Label", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Grid", + "resourceLauncherLayoutList": "List", + "resourceLauncherShowLabels": "Show Label Tags", + "resourceLauncherShowSiteTags": "Show Site Tags", + "resourceLauncherShowRecents": "Show Recents", + "resourceLauncherDeleteView": "Delete View", + "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherNoResourcesInGroup": "No resources in this group", + "resourceLauncherCopiedToClipboard": "Copied to clipboard", + "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", + "resourceLauncherViewNamePlaceholder": "View name", + "resourceLauncherViewNameLabel": "View Name", + "resourceLauncherViewSaved": "View saved", + "resourceLauncherViewSavedDescription": "Your launcher view has been saved.", + "resourceLauncherViewSaveFailed": "Failed to save view", + "resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.", + "resourceLauncherViewDeleted": "View deleted", + "resourceLauncherViewDeletedDescription": "The launcher view has been deleted.", + "resourceLauncherViewDeleteFailed": "Failed to delete view", + "resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.", "memberPortalPrevious": "Previous", "memberPortalNext": "Next", "httpSettings": "HTTP Settings", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1b48aa520..aa8c4b58f 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -218,6 +218,20 @@ export const labels = pgTable("labels", { .notNull() }); +export const launcherViews = pgTable("launcherViews", { + viewId: serial("viewId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: varchar("name").notNull(), + config: text("config").notNull(), + createdAt: varchar("createdAt").notNull(), + updatedAt: varchar("updatedAt").notNull() +}); + export const siteLabels = pgTable( "siteLabels", { @@ -1550,6 +1564,7 @@ export type RoundTripMessageTracker = InferSelectModel< export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0c4a143f5..ac2ee0890 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -221,6 +221,20 @@ export const labels = sqliteTable("labels", { .notNull() }); +export const launcherViews = sqliteTable("launcherViews", { + viewId: integer("viewId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: text("name").notNull(), + config: text("config").notNull(), + createdAt: text("createdAt").notNull(), + updatedAt: text("updatedAt").notNull() +}); + export const siteLabels = sqliteTable( "siteLabels", { @@ -1549,6 +1563,7 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode diff --git a/server/routers/external.ts b/server/routers/external.ts index 960c00249..b4ca30cff 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -17,6 +17,7 @@ import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; +import * as launcher from "./launcher"; import * as newt from "./newt"; import * as olm from "./olm"; import * as serverInfo from "./serverInfo"; @@ -455,6 +456,42 @@ authenticated.get( resource.getUserResources ); +authenticated.get( + "/org/:orgId/launcher/groups", + verifyOrgAccess, + launcher.listLauncherGroups +); + +authenticated.get( + "/org/:orgId/launcher/resources", + verifyOrgAccess, + launcher.listLauncherResources +); + +authenticated.get( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.listLauncherViews +); + +authenticated.post( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.createLauncherView +); + +authenticated.put( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.updateLauncherView +); + +authenticated.delete( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.deleteLauncherView +); + authenticated.get( "/org/:orgId/user-resource-aliases", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 93857e1db..0bbe12163 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; +import * as launcher from "./launcher"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -159,6 +160,42 @@ authenticated.get( verifyApiKeyOrgAccess, resource.getUserResources ); + +authenticated.get( + "/org/:orgId/launcher/groups", + verifyApiKeyOrgAccess, + launcher.listLauncherGroups +); + +authenticated.get( + "/org/:orgId/launcher/resources", + verifyApiKeyOrgAccess, + launcher.listLauncherResources +); + +authenticated.get( + "/org/:orgId/launcher/views", + verifyApiKeyOrgAccess, + launcher.listLauncherViews +); + +authenticated.post( + "/org/:orgId/launcher/views", + verifyApiKeyOrgAccess, + launcher.createLauncherView +); + +authenticated.put( + "/org/:orgId/launcher/views/:viewId", + verifyApiKeyOrgAccess, + launcher.updateLauncherView +); + +authenticated.delete( + "/org/:orgId/launcher/views/:viewId", + verifyApiKeyOrgAccess, + launcher.deleteLauncherView +); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts new file mode 100644 index 000000000..fb7ff2630 --- /dev/null +++ b/server/routers/launcher/createLauncherView.ts @@ -0,0 +1,118 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; +import { launcherViewConfigSchema } from "./types"; + +const createLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128), + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function createLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = createLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + if (parsed.data.orgWide) { + const canManageOrgWide = await isOrgAdminOrOwner( + orgId, + userId, + userRoleIds + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can create org-wide views" + ) + ); + } + } + + const now = moment().toISOString(); + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: parsed.data.orgWide ? null : userId, + name: parsed.data.name, + config: JSON.stringify(parsed.data.config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { + viewId: created.viewId, + orgId: created.orgId, + userId: created.userId, + name: created.name, + config: launcherViewConfigSchema.parse( + JSON.parse(created.config) + ), + createdAt: created.createdAt, + updatedAt: created.updatedAt, + isOrgWide: created.userId == null + }, + success: true, + error: false, + message: "Launcher view created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error creating launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts new file mode 100644 index 000000000..c68c6530c --- /dev/null +++ b/server/routers/launcher/deleteLauncherView.ts @@ -0,0 +1,97 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; + +export async function deleteLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + + if (!isPersonalView && !(isOrgWideView && isAdmin)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to delete this view" + ) + ); + } + + await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher view deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error deleting launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts new file mode 100644 index 000000000..31f625bb7 --- /dev/null +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -0,0 +1,139 @@ +export type SiteResourceDestinationInput = { + mode: "host" | "cidr" | "http" | "ssh"; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; +}; + +export function resolveHttpHttpsDisplayPort( + mode: "http", + destinationPort: number | null +): number { + if (destinationPort != null) { + return destinationPort; + } + return 80; +} + +export function formatSiteResourceDestinationDisplay( + row: SiteResourceDestinationInput +): string { + if (!row.destination) { + return ""; + } + const { mode, destination, destinationPort, scheme } = row; + if (mode !== "http") { + return destination; + } + const port = resolveHttpHttpsDisplayPort(mode, destinationPort); + const downstreamScheme = scheme ?? "http"; + const hostPart = + destination.includes(":") && !destination.startsWith("[") + ? `[${destination}]` + : destination; + return `${downstreamScheme}://${hostPart}:${port}`; +} + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + const port = resource.proxyPort?.toString() ?? ""; + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts new file mode 100644 index 000000000..f23b266ea --- /dev/null +++ b/server/routers/launcher/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; +export { listLauncherGroups } from "./listLauncherGroups"; +export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherViews } from "./listLauncherViews"; +export { createLauncherView } from "./createLauncherView"; +export { updateLauncherView } from "./updateLauncherView"; +export { deleteLauncherView } from "./deleteLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts new file mode 100644 index 000000000..95b2b16e1 --- /dev/null +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -0,0 +1,1090 @@ +import { db } from "@server/db"; +import { + labels, + launcherViews, + resourceLabels, + resources, + rolePolicies, + roleResources, + roles, + roleSiteResources, + siteNetworks, + siteResourceLabels, + siteResources, + sites, + targets, + userOrgRoles, + userOrgs, + userPolicies, + userResources, + userSiteResources +} from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + and, + asc, + countDistinct, + eq, + inArray, + like, + or, + sql +} from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { + formatPublicResourceAccess, + formatSiteResourceAccess +} from "./formatLauncherAccess"; +import { + LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherGroup, + type LauncherLabel, + type LauncherListQuery, + type LauncherResource, + parseIdListParam +} from "./types"; + +const effectiveResourcePolicyId = sql< + number | null +>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`; + +export type AccessibleIds = { + resourceIds: number[]; + siteResourceIds: number[]; +}; + +export async function verifyLauncherOrgMembership( + orgId: string, + userId: string +): Promise<{ userRoleIds: number[] }> { + const [userOrg] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrg) { + throw createHttpError(HttpCode.FORBIDDEN, "User not in organization"); + } + + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ) + .then((rows) => rows.map((r) => r.roleId)); + + return { userRoleIds }; +} + +export async function isOrgAdminOrOwner( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const [membership] = await db + .select({ isOwner: userOrgs.isOwner }) + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (membership?.isOwner) { + return true; + } + + if (userRoleIds.length === 0) { + return false; + } + + const adminRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + eq(roles.isAdmin, true), + inArray(roles.roleId, userRoleIds) + ) + ) + .limit(1); + + return adminRoles.length > 0; +} + +export async function resolveAccessibleIds( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const [ + directResources, + roleResourceResults, + directPolicyResourceResults, + rolePolicyResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ + db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .innerJoin( + resources, + eq(userResources.resourceId, resources.resourceId) + ) + .where( + and( + eq(userResources.userId, userId), + eq(resources.orgId, orgId) + ) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .innerJoin( + resources, + eq(roleResources.resourceId, resources.resourceId) + ) + .where( + and( + inArray(roleResources.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + userPolicies, + eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) + ) + .where( + and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + rolePolicies, + eq( + effectiveResourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + and( + inArray(rolePolicies.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)), + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]) + ]); + + return { + resourceIds: Array.from( + new Set([ + ...directResources.map((r) => r.resourceId), + ...roleResourceResults.map((r) => r.resourceId), + ...directPolicyResourceResults.map((r) => r.resourceId), + ...rolePolicyResourceResults.map((r) => r.resourceId) + ]) + ), + siteResourceIds: Array.from( + new Set([ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]) + ) + }; +} + +function searchPattern(query: string) { + return `%${query.trim()}%`; +} + +function buildSearchConditionForPublic( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${resources.name})`, pattern), + like(sql`LOWER(${resources.fullDomain})`, pattern), + like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +function buildSearchConditionForSiteResource( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${siteResources.name})`, pattern), + like(sql`LOWER(${siteResources.destination})`, pattern), + like(sql`LOWER(${siteResources.alias})`, pattern), + like(sql`LOWER(${siteResources.fullDomain})`, pattern), + like(sql`LOWER(${siteResources.aliasAddress})`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +async function labelsEnabled(orgId: string): Promise { + return isLicensedOrSubscribed(orgId, tierMatrix.labels); +} + +async function fetchLabelsForResources( + orgId: string, + resourceIds: number[], + siteResourceIds: number[] +): Promise<{ + byResourceId: Map; + bySiteResourceId: Map; +}> { + const byResourceId = new Map(); + const bySiteResourceId = new Map(); + + if (!(await labelsEnabled(orgId))) { + return { byResourceId, bySiteResourceId }; + } + + const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ + resourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + resourceId: resourceLabels.resourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(inArray(resourceLabels.resourceId, resourceIds)) + .orderBy(asc(resourceLabels.resourceLabelId)), + siteResourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + siteResourceId: siteResourceLabels.siteResourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIds + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)) + ]); + + for (const row of resourceLabelRows) { + const list = byResourceId.get(row.resourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + byResourceId.set(row.resourceId, list); + } + + for (const row of siteResourceLabelRows) { + const list = bySiteResourceId.get(row.siteResourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + bySiteResourceId.set(row.siteResourceId, list); + } + + return { byResourceId, bySiteResourceId }; +} + +type SiteGroupRow = { + siteId: number; + name: string; + type: string; + online: boolean; + itemCount: number; +}; + +async function listSiteGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelsFeatureEnabled = await labelsEnabled(orgId); + const searchPublic = buildSearchConditionForPublic( + query.query, + labelsFeatureEnabled + ); + const searchSite = buildSearchConditionForSiteResource( + query.query, + labelsFeatureEnabled + ); + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + let publicQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + publicQuery = publicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + publicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const publicRows = await publicQuery + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + let siteResourceQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + siteResourceQuery = siteResourceQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + siteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const siteRows = await siteResourceQuery + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( + (row) => ({ + groupKey: String(row.siteId), + name: row.name, + groupType: "site" as const, + itemCount: row.itemCount, + siteType: row.type, + siteOnline: row.online + }) + ); + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +async function listLabelGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelCountMap = new Map< + number, + { labelId: number; name: string; color: string; itemCount: number } + >(); + let unlabeledCount = 0; + + if (!(await labelsEnabled(orgId))) { + return { groups: [], total: 0 }; + } + + const matchesLabelFilters = (labelId: number) => + labelFilterIds.length === 0 || labelFilterIds.includes(labelId); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + const searchPublic = buildSearchConditionForPublic(query.query, true); + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(resources.resourceId) + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledPublicIds = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions)); + + const labeledSet = new Set(labeledPublicIds.map((r) => r.resourceId)); + unlabeledCount += accessible.resourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + const searchSite = buildSearchConditionForSiteResource( + query.query, + true + ); + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledSiteIds = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)); + + const labeledSet = new Set(labeledSiteIds.map((r) => r.siteResourceId)); + unlabeledCount += accessible.siteResourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map( + (row) => ({ + groupKey: String(row.labelId), + name: row.name, + groupType: "label" as const, + itemCount: row.itemCount, + labelColor: row.color + }) + ); + + if (unlabeledCount > 0 && labelFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_UNLABELED_GROUP_KEY, + name: "Unlabeled", + groupType: "label", + itemCount: unlabeledCount, + labelColor: "#a1a1aa" + }); + } + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listLauncherGroupsForUser( + orgId: string, + userId: string, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + if (query.groupBy === "label") { + return listLabelGroups(orgId, accessible, query); + } + + return listSiteGroups(orgId, accessible, query); +} + +async function mapPublicResources( + orgId: string, + resourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + mode: resources.mode, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + proxyPort: resources.proxyPort, + wildcard: resources.wildcard, + enabled: resources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online + }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `public:${row.resourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatPublicResourceAccess({ + mode: row.mode, + fullDomain: row.fullDomain, + ssl: row.ssl, + proxyPort: row.proxyPort, + wildcard: row.wildcard + }); + + result.push({ + launcherResourceKey: key, + resourceType: "public", + resourceId: row.resourceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.byResourceId.get(row.resourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +async function mapSiteResources( + orgId: string, + siteResourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + mode: siteResources.mode, + destination: siteResources.destination, + destinationPort: siteResources.destinationPort, + scheme: siteResources.scheme, + ssl: siteResources.ssl, + fullDomain: siteResources.fullDomain, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + enabled: siteResources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + inArray(siteResources.siteResourceId, siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `site:${row.siteResourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatSiteResourceAccess({ + mode: row.mode, + destination: row.destination, + destinationPort: row.destinationPort, + scheme: row.scheme, + ssl: row.ssl, + fullDomain: row.fullDomain, + alias: row.alias, + aliasAddress: row.aliasAddress + }); + + result.push({ + launcherResourceKey: key, + resourceType: "site", + resourceId: row.siteResourceId, + siteResourceId: row.siteResourceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +function filterResourcesByLabel( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_UNLABELED_GROUP_KEY) { + return items.filter((item) => item.labels.length === 0); + } + const labelId = Number.parseInt(groupKey, 10); + return items.filter((item) => + item.labels.some((label) => label.labelId === labelId) + ); +} + +function filterResourcesBySearch( + items: LauncherResource[], + query: string +): LauncherResource[] { + if (!query.trim()) { + return items; + } + const pattern = query.trim().toLowerCase(); + return items.filter( + (item) => + item.name.toLowerCase().includes(pattern) || + item.accessDisplay.toLowerCase().includes(pattern) || + item.accessCopyValue.toLowerCase().includes(pattern) || + item.labels.some((label) => + label.name.toLowerCase().includes(pattern) + ) || + item.site?.name.toLowerCase().includes(pattern) + ); +} + +function sortLauncherResources( + items: LauncherResource[], + order: "asc" | "desc" +): LauncherResource[] { + return [...items].sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return order === "desc" ? -cmp : cmp; + }); +} + +export async function listLauncherResourcesForUser( + orgId: string, + userId: string, + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + + let filteredResourceIds = accessible.resourceIds; + let filteredSiteResourceIds = accessible.siteResourceIds; + + if (siteFilterIds.length > 0 && accessible.resourceIds.length > 0) { + const publicOnSites = await db + .select({ resourceId: resources.resourceId }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + inArray(resources.resourceId, accessible.resourceIds), + inArray(targets.siteId, siteFilterIds) + ) + ); + filteredResourceIds = publicOnSites.map((r) => r.resourceId); + } + + if (siteFilterIds.length > 0 && accessible.siteResourceIds.length > 0) { + const privateOnSites = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + inArray( + siteResources.siteResourceId, + accessible.siteResourceIds + ), + inArray(siteNetworks.siteId, siteFilterIds) + ) + ); + filteredSiteResourceIds = privateOnSites.map((r) => r.siteResourceId); + } + + if (labelFilterIds.length > 0) { + if (filteredResourceIds.length > 0) { + const withLabels = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .where( + and( + inArray(resourceLabels.resourceId, filteredResourceIds), + inArray(resourceLabels.labelId, labelFilterIds) + ) + ); + filteredResourceIds = withLabels.map((r) => r.resourceId); + } + if (filteredSiteResourceIds.length > 0) { + const withLabels = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .where( + and( + inArray( + siteResourceLabels.siteResourceId, + filteredSiteResourceIds + ), + inArray(siteResourceLabels.labelId, labelFilterIds) + ) + ); + filteredSiteResourceIds = withLabels.map((r) => r.siteResourceId); + } + } + + const labelMaps = await fetchLabelsForResources( + orgId, + filteredResourceIds, + filteredSiteResourceIds + ); + + const siteIdFilter = + query.groupBy === "site" + ? Number.parseInt(query.groupKey, 10) + : undefined; + + const [publicItems, siteItems] = await Promise.all([ + mapPublicResources( + orgId, + filteredResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ), + mapSiteResources( + orgId, + filteredSiteResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ) + ]); + + let items = [...publicItems, ...siteItems]; + items = filterResourcesBySearch(items, query.query); + + if (query.groupBy === "label") { + items = filterResourcesByLabel(items, query.groupKey); + } + + items = sortLauncherResources(items, query.order); + + const total = items.length; + const offset = (query.page - 1) * query.pageSize; + return { + resources: items.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts new file mode 100644 index 000000000..69ad1a10d --- /dev/null +++ b/server/routers/launcher/listLauncherGroups.ts @@ -0,0 +1,73 @@ +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listLauncherGroupsForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +export async function listLauncherGroups( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { groups, total } = await listLauncherGroupsForUser( + orgId, + userId, + parsed.data + ); + + return response(res, { + data: { + groups, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher groups retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher groups:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts new file mode 100644 index 000000000..a4b2be993 --- /dev/null +++ b/server/routers/launcher/listLauncherResources.ts @@ -0,0 +1,78 @@ +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { listLauncherResourcesForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({ + groupKey: z.string().min(1) +}); + +export async function listLauncherResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = listLauncherResourcesQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { resources, total } = await listLauncherResourcesForUser( + orgId, + userId, + parsed.data + ); + + return response(res, { + data: { + resources, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher resources:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts new file mode 100644 index 000000000..019dbd2da --- /dev/null +++ b/server/routers/launcher/listLauncherViews.ts @@ -0,0 +1,83 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; +import { verifyLauncherOrgMembership } from "./launcherResourceAccess"; + +function mapViewRow( + row: typeof launcherViews.$inferSelect +): LauncherViewRecord { + return { + viewId: row.viewId, + orgId: row.orgId, + userId: row.userId, + name: row.name, + config: launcherViewConfigSchema.parse(JSON.parse(row.config)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isOrgWide: row.userId == null + }; +} + +export async function listLauncherViews( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + await verifyLauncherOrgMembership(orgId, userId); + + const rows = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + or( + eq(launcherViews.userId, userId), + isNull(launcherViews.userId) + ) + ) + ); + + return response(res, { + data: { + views: rows.map(mapViewRow) + }, + success: true, + error: false, + message: "Launcher views retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher views:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts new file mode 100644 index 000000000..6774a1549 --- /dev/null +++ b/server/routers/launcher/types.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; + +export const launcherViewConfigSchema = z.object({ + groupBy: z.enum(["site", "label"]).default("site"), + layout: z.enum(["grid", "list"]).default("grid"), + sortBy: z.literal("name").default("name"), + order: z.enum(["asc", "desc"]).default("asc"), + showLabels: z.boolean().default(true), + showSiteTags: z.boolean().default(true), + showRecents: z.boolean().default(false).optional(), + siteIds: z.array(z.number()).default([]), + labelIds: z.array(z.number()).default([]), + query: z.string().default("") +}); + +export type LauncherViewConfig = z.infer; + +export const defaultLauncherViewConfig: LauncherViewConfig = + launcherViewConfigSchema.parse({}); + +export type LauncherLabel = { + labelId: number; + name: string; + color: string; +}; + +export type LauncherSiteInfo = { + siteId: number; + name: string; + type: string; + online?: boolean; +}; + +export type LauncherResource = { + launcherResourceKey: string; + resourceType: "public" | "site"; + resourceId: number; + siteResourceId?: number; + name: string; + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; + iconUrl: string | null; + enabled: boolean; + mode: string; + labels: LauncherLabel[]; + site?: LauncherSiteInfo; +}; + +export type LauncherGroup = { + groupKey: string; + name: string; + groupType: "site" | "label"; + itemCount: number; + siteType?: string; + siteOnline?: boolean; + labelColor?: string; +}; + +export type ListLauncherGroupsResponse = { + groups: LauncherGroup[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherResourcesResponse = { + resources: LauncherResource[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type LauncherViewRecord = { + viewId: number; + orgId: string; + userId: string | null; + name: string; + config: LauncherViewConfig; + createdAt: string; + updatedAt: string; + isOrgWide: boolean; +}; + +export type ListLauncherViewsResponse = { + views: LauncherViewRecord[]; +}; + +export const launcherListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default(""), + groupBy: z.enum(["site", "label"]).optional().default("site"), + groupKey: z.string().optional(), + siteIds: z.string().optional(), + labelIds: z.string().optional(), + sort_by: z.literal("name").optional().default("name"), + order: z.enum(["asc", "desc"]).optional().default("asc") +}); + +export type LauncherListQuery = z.infer; + +export function parseIdListParam(value: string | undefined): number[] { + if (!value?.trim()) { + return []; + } + return value + .split(",") + .map((part) => Number.parseInt(part.trim(), 10)) + .filter((id) => Number.isFinite(id)); +} + +export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; + +export type LauncherViewSelection = + | { type: "default" } + | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts new file mode 100644 index 000000000..2db865952 --- /dev/null +++ b/server/routers/launcher/updateLauncherView.ts @@ -0,0 +1,164 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; +import { launcherViewConfigSchema } from "./types"; + +const updateLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128).optional(), + config: launcherViewConfigSchema.optional(), + orgWide: z.boolean().optional() +}); + +export async function updateLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const parsed = updateLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + + if (!isPersonalView && !(isOrgWideView && isAdmin)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to update this view" + ) + ); + } + + if (parsed.data.orgWide === true && !isAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can make views org-wide" + ) + ); + } + + if (parsed.data.orgWide === false && isOrgWideView && !isAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can change org-wide views" + ) + ); + } + + const nextUserId = + parsed.data.orgWide === true + ? null + : parsed.data.orgWide === false + ? userId + : existing.userId; + + const [updated] = await db + .update(launcherViews) + .set({ + name: parsed.data.name ?? existing.name, + config: parsed.data.config + ? JSON.stringify(parsed.data.config) + : existing.config, + userId: nextUserId, + updatedAt: moment().toISOString() + }) + .where(eq(launcherViews.viewId, viewId)) + .returning(); + + return response(res, { + data: { + viewId: updated.viewId, + orgId: updated.orgId, + userId: updated.userId, + name: updated.name, + config: launcherViewConfigSchema.parse( + JSON.parse(updated.config) + ), + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + isOrgWide: updated.userId == null + }, + success: true, + error: false, + message: "Launcher view updated successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error updating launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index fc806acc1..0c3ad6547 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,5 +1,5 @@ import { Layout } from "@app/components/Layout"; -import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; @@ -18,7 +18,6 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; - const env = pullEnv(); if (!orgId) { redirect(`/`); @@ -40,12 +39,6 @@ export default async function OrgPage(props: OrgPageProps) { overview = res.data.data; } catch (e) {} - // If user is admin or owner, redirect to settings - if (overview?.isAdmin || overview?.isOwner) { - redirect(`/${orgId}/settings`); - } - - // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -60,10 +53,21 @@ export default async function OrgPage(props: OrgPageProps) { } } catch (e) {} + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + return ( - - {overview && } + + {overview ? ( + + ) : null} ); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..5657366f8 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -16,6 +16,8 @@ interface LayoutProps { showHeader?: boolean; showTopBar?: boolean; defaultSidebarCollapsed?: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export async function Layout({ @@ -26,7 +28,9 @@ export async function Layout({ showSidebar = true, showHeader = true, showTopBar = true, - defaultSidebarCollapsed = false + defaultSidebarCollapsed = false, + launcherMode = false, + showViewAsAdmin = false }: LayoutProps) { const allCookies = await cookies(); const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value; @@ -68,7 +72,15 @@ export async function Layout({ )} {/* Desktop header */} - {showHeader && } + {showHeader && ( + + )} {/* Main content */}
diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..434963882 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -8,16 +8,31 @@ import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector"; +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; -interface LayoutHeaderProps { +type LayoutHeaderProps = { showTopBar: boolean; -} + launcherMode?: boolean; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + showViewAsAdmin?: boolean; +}; -export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { +export function LayoutHeader({ + showTopBar, + launcherMode = false, + orgId, + orgs, + showViewAsAdmin = false +}: LayoutHeaderProps) { const { theme } = useTheme(); const [path, setPath] = useState(""); const { env } = useEnvContext(); const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); const logoWidth = isUnlocked() ? env.branding.logo?.navbar?.width || 98 @@ -53,16 +68,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
-
- +
+ - {/* {build === "saas" && ( - Cloud Beta - )} */} + {launcherMode ? ( + <> + + {showViewAsAdmin && orgId ? ( + + ) : null} + + ) : null}
{showTopBar && ( diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a7c3a141f..a1a7bc412 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -18,7 +18,13 @@ 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, PanelRightOpen, Server } from "lucide-react"; +import { + ArrowRight, + ExternalLink, + PanelRightOpen, + Server, + SquareMousePointer +} from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -130,6 +136,13 @@ export function LayoutSidebar({ const showTrial = build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + return (
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + + + + {!isSidebarCollapsed && ( + + {t("resourceLauncherTitle")} + + )} + +
+ )} {!isAdminPage && user.serverAdmin && (
{ - const [faviconError, setFaviconError] = useState(false); - const [faviconLoaded, setFaviconLoaded] = useState(false); - - // Extract domain for favicon URL - const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; - const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; - - const handleFaviconLoad = () => { - setFaviconLoaded(true); - setFaviconError(false); - }; - - const handleFaviconError = () => { - setFaviconError(true); - setFaviconLoaded(false); - }; - - if (faviconError || !enabled) { - return ( - - ); - } - - return ( -
- {!faviconLoaded && ( -
- )} - {`${cleanDomain} -
- ); -}; - -// Resource Info component -const ResourceInfo = ({ resource }: { resource: Resource }) => { - const t = useTranslations(); - const hasAuthMethods = - resource.sso || - resource.password || - resource.pincode || - resource.whitelist; - - const hasAnyInfo = - Boolean(resource.siteName) || - Boolean(hasAuthMethods) || - !resource.enabled; - - if (!hasAnyInfo) return null; - - const infoContent = ( -
- {/* Site Information */} - {resource.siteName && ( -
-
- {t("site")} -
-
- - {resource.siteName} -
-
- )} - - {/* Authentication Methods */} - {hasAuthMethods && ( -
-
- {t("memberPortalAuthMethods")} -
-
- {resource.sso && ( -
-
- -
- - {t("memberPortalSso")} - -
- )} - {resource.password && ( -
-
- -
- - {t("memberPortalPasswordProtected")} - -
- )} - {resource.pincode && ( -
-
- -
- - {t("memberPortalPinCode")} - -
- )} - {resource.whitelist && ( -
-
- -
- - {t("memberPortalEmailWhitelist")} - -
- )} -
-
- )} - - {/* Resource Status - if disabled */} - {!resource.enabled && ( -
-
- - - {t("memberPortalResourceDisabled")} - -
-
- )} -
- ); - - return {infoContent}; -}; - -// Pagination component -const PaginationControls = ({ - currentPage, - totalPages, - onPageChange, - totalItems, - itemsPerPage -}: { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - totalItems: number; - itemsPerPage: number; -}) => { - const t = useTranslations(); - const startItem = (currentPage - 1) * itemsPerPage + 1; - const endItem = Math.min(currentPage * itemsPerPage, totalItems); - - if (totalPages <= 1) return null; - - return ( -
-
- {t("memberPortalShowingResources", { - start: startItem, - end: endItem, - total: totalItems - })} -
- -
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => { - // Show first page, last page, current page, and 2 pages around current - const showPage = - page === 1 || - page === totalPages || - Math.abs(page - currentPage) <= 1; - - const showEllipsis = - (page === 2 && currentPage > 4) || - (page === totalPages - 1 && - currentPage < totalPages - 3); - - if (!showPage && !showEllipsis) return null; - - if (showEllipsis) { - return ( - - ... - - ); - } - - return ( - - ); - } - )} -
- - -
-
- ); -}; - -// Loading skeleton component -const ResourceCardSkeleton = () => ( - - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); - -export default function MemberResourcesPortal({ - orgId -}: MemberResourcesPortalProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const { toast } = useToast(); - - const [resources, setResources] = useState([]); - const [siteResources, setSiteResources] = useState([]); - const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState< - SiteResource[] - >([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("name-asc"); - const [refreshing, setRefreshing] = useState(false); - - // Pagination state - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 12; // 3x4 grid on desktop - - const fetchUserResources = async (isRefresh = false) => { - try { - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } - setError(null); - - const response = await api.get( - `/org/${orgId}/user-resources` - ); - - 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(t("memberPortalFailedToLoad")); - } - } catch (err) { - console.error("Error fetching user resources:", err); - setError(t("memberPortalFailedToLoadDescription")); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - useEffect(() => { - fetchUserResources(); - }, [orgId, api]); - - // Filter and sort resources - useEffect(() => { - const filtered = resources.filter( - (resource) => - resource.name - .toLowerCase() - .includes(searchQuery.toLowerCase()) || - resource.domain - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - - // Sort resources - filtered.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": - return a.domain.localeCompare(b.domain); - case "domain-desc": - return b.domain.localeCompare(a.domain); - case "status-enabled": - // Enabled first, then protected vs unprotected - if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; - return b.protected ? 1 : -1; - case "status-disabled": - // Disabled first, then unprotected vs protected - if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; - return a.protected ? 1 : -1; - default: - return a.name.localeCompare(b.name); - } - }); - - 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, siteResources, searchQuery, sortBy]); - - // Calculate pagination - 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 - window.open(resource.domain, "_blank"); - }; - - const handleRefresh = () => { - fetchUserResources(true); - }; - - const handleRetry = () => { - fetchUserResources(); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - // Scroll to top when page changes - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - if (loading) { - return ( -
- - - {/* Search and Sort Controls - Skeleton */} -
-
-
-
-
-
-
-
- - {/* Loading Skeletons */} -
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
-
- ); - } - - if (error) { - return ( -
- - - -
- -
-

- {t("memberPortalUnableToLoad")} -

-

- {error} -

- -
-
-
- ); - } - - return ( -
- - - {/* Search and Sort Controls with Refresh */} -
-
- {/* Search */} -
- setSearchQuery(e.target.value)} - className="w-full pl-8 bg-card" - /> - -
- - {/* Sort */} -
- -
-
- - {/* Refresh Button */} - -
- - {/* Resources Content */} - {filteredResources.length === 0 && - filteredSiteResources.length === 0 ? ( - /* Enhanced Empty State */ - - -
- {searchQuery ? ( - - ) : ( - - )} -
-

- {searchQuery - ? t("memberPortalNoResourcesFound") - : t("memberPortalNoResourcesAvailable")} -

-

- {searchQuery - ? t("memberPortalNoResourcesMatchSearch", { - query: searchQuery - }) - : t("memberPortalNoResourcesAccess")} -

-
- {searchQuery ? ( - - ) : ( - - )} -
-
-
- ) : ( - <> - {/* Public Resources Section */} - {paginatedResources.length > 0 && ( - <> -
-

- - {t("memberPortalPublicResources")} -

-

- {t( - "memberPortalPublicResourcesDescription" - )} -

-
-
- {paginatedResources.map((resource) => ( - -
-
-
- - - - - { - resource.name - } - - - -

- { - resource.name - } -

-
-
-
-
- -
- - {resource.mode.toUpperCase()} - - -
-
- -
- - -
-
- -
- -
-
- ))} -
- - )} - - {/* Private Resources (Site Resources) Section */} - {paginatedSiteResources.length > 0 && ( - <> -
-

- - {t("memberPortalPrivateResources")} -

-

- {t( - "memberPortalPrivateResourcesDescription" - )} -

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

- { - siteResource.name - } -

-
-
-
-
- -
- - {siteResource.mode.toUpperCase()} - - -
-
- {t( - "memberPortalResourceDetails" - )} -
-
- - {t( - "memberPortalMode" - )} - : - - - {siteResource.mode.toUpperCase()} - -
- {siteResource.destination && ( -
- - {t( - "memberPortalDestination" - )} - : - - - { - siteResource.destination - } - -
- )} - {siteResource.alias && ( -
- - {t( - "memberPortalAlias" - )} - : - - - { - siteResource.alias - } - -
- )} -
- - {t( - "status" - )} - : - - - {siteResource.enabled - ? t( - "enabled" - ) - : t( - "disabled" - )} - -
-
-
-
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - /* HTTP mode - show as clickable link */ - - ) : siteResource.alias ? ( - /* Alias as primary */ -
-
- {siteResource.alias} -
- -
- ) : siteResource.destination ? ( - /* Destination as primary when no alias */ -
-
- { - siteResource.destination - } -
- -
- ) : ( - /* niceId fallback when no alias and no destination */ -
-
- { - siteResource.niceId - } -
- -
- )} -
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - - ) : null} -
- - {t( - "memberPortalRequiresClientConnection" - )} -
-
-
- ))} -
- - )} - - {/* Pagination Controls */} - - - )} -
- ); -} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 5cf7ce944..eda4bad1c 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -38,6 +38,21 @@ export type LabelsSelectorProps = { toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; }; +export function formatLabelsSelectorLabel( + selectedLabels: SelectedLabel[], + t: (key: string, values?: { count: number }) => string +): string { + if (selectedLabels.length === 0) { + return t("selectLabels"); + } + if (selectedLabels.length === 1) { + return selectedLabels[0]!.name; + } + return t("labelsSelectorLabelsCount", { + count: selectedLabels.length + }); +} + export const LABEL_COLORS = { red: "#ff6467", green: "#05df72", diff --git a/src/components/resource-launcher/LauncherCopyIcon.tsx b/src/components/resource-launcher/LauncherCopyIcon.tsx new file mode 100644 index 000000000..bfaf21d04 --- /dev/null +++ b/src/components/resource-launcher/LauncherCopyIcon.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { Check, Copy } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type LauncherCopyIconProps = { + text: string; + className?: string; +}; + +export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + + if (!text) { + return null; + } + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx new file mode 100644 index 000000000..36d6a3baa --- /dev/null +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + formatMultiSitesSelectorLabel, + MultiSitesSelector +} from "@app/components/multi-site-selector"; +import { + formatLabelsSelectorLabel, + LabelsSelector, + type SelectedLabel +} from "@app/components/labels-selector"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; +import { ChevronsUpDown, Funnel } from "lucide-react"; +import { useState } from "react"; +import type { Selectedsite } from "@app/components/site-selector"; + +type LauncherFilterPopoverProps = { + orgId: string; + selectedSites: Selectedsite[]; + selectedLabels: SelectedLabel[]; + onSitesChange: (sites: Selectedsite[]) => void; + onLabelsChange: (labels: SelectedLabel[]) => void; +}; + +export function LauncherFilterPopover({ + orgId, + selectedSites, + selectedLabels, + onSitesChange, + onLabelsChange +}: LauncherFilterPopoverProps) { + const t = useTranslations(); + const [sitesOpen, setSitesOpen] = useState(false); + const [labelsOpen, setLabelsOpen] = useState(false); + + return ( + + + + + +
+
+

{t("sites")}

+ + + + + + + + +
+
+

{t("labels")}

+ + + + + + { + if (action === "attach") { + onLabelsChange([ + ...selectedLabels, + label + ]); + } else { + onLabelsChange( + selectedLabels.filter( + (item) => + item.labelId !== + label.labelId + ) + ); + } + }} + /> + + +
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx new file mode 100644 index 000000000..854241cdd --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -0,0 +1,201 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { LauncherGroupSection } from "./LauncherGroupSection"; + +type LauncherGroupListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + searchQuery: string; +}; + +function buildResourceFilters( + config: LauncherViewConfig, + searchQuery: string, + groupKey: string +) { + return { + query: searchQuery, + groupBy: config.groupBy, + groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }; +} + +export function LauncherGroupList({ + orgId, + activeViewId, + config, + searchQuery +}: LauncherGroupListProps) { + const queryClient = useQueryClient(); + const loadMoreRef = useRef(null); + const [isPrefetching, setIsPrefetching] = useState(false); + const prefetchBatchKeyRef = useRef(null); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.groups(orgId, { + query: searchQuery, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }) + }); + + const groups = data?.pages.flatMap((page) => page.groups) ?? []; + + const batchKey = useMemo( + () => + JSON.stringify({ + activeViewId, + searchQuery, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sortBy: config.sortBy, + order: config.order + }), + [ + activeViewId, + config.groupBy, + config.labelIds, + config.order, + config.siteIds, + config.sortBy, + searchQuery + ] + ); + + const openGroupKeys = useMemo( + () => + groups + .filter((group) => + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + true + ) + ) + .map((group) => group.groupKey), + [activeViewId, config.groupBy, groups, orgId] + ); + + useEffect(() => { + if (isLoading) { + return; + } + + if (openGroupKeys.length === 0) { + prefetchBatchKeyRef.current = batchKey; + setIsPrefetching(false); + return; + } + + if (prefetchBatchKeyRef.current === batchKey) { + return; + } + + let cancelled = false; + setIsPrefetching(true); + + void Promise.all( + openGroupKeys.map((groupKey) => + queryClient.prefetchInfiniteQuery( + launcherQueries.resources( + orgId, + buildResourceFilters(config, searchQuery, groupKey) + ) + ) + ) + ).finally(() => { + if (!cancelled) { + prefetchBatchKeyRef.current = batchKey; + setIsPrefetching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + batchKey, + config, + isLoading, + openGroupKeys, + orgId, + queryClient, + searchQuery + ]); + + const isBatchPending = prefetchBatchKeyRef.current !== batchKey; + const isBodyLoading = + isLoading || + (isBatchPending && + openGroupKeys.length > 0 && + (isPrefetching || !isLoading)); + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage || isBodyLoading) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]); + + if (isBodyLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {groups.map((group) => ( + + ))} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx new file mode 100644 index 000000000..0f174bd86 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent +} from "@app/components/ui/collapsible"; +import { cn } from "@app/lib/cn"; +import { + readLauncherGroupOpen, + writeLauncherGroupOpen, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherGroup, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { LauncherGroupTrigger } from "./LauncherGroupTrigger"; +import { LauncherResourceGrid } from "./LauncherResourceGrid"; +import { LauncherResourceList } from "./LauncherResourceList"; + +type LauncherGroupSectionProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + group: LauncherGroup; + config: LauncherViewConfig; + searchQuery: string; + defaultOpen?: boolean; +}; + +export function LauncherGroupSection({ + orgId, + activeViewId, + group, + config, + searchQuery, + defaultOpen = true +}: LauncherGroupSectionProps) { + const t = useTranslations(); + const loadMoreRef = useRef(null); + const [isOpen, setIsOpen] = useState(() => + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + + useEffect(() => { + setIsOpen( + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + }, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + writeLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + open + ); + }; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, { + query: searchQuery, + groupBy: config.groupBy, + groupKey: group.groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + enabled: isOpen + }); + + const resources = data?.pages.flatMap((page) => page.resources) ?? []; + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const groupTitle = + group.groupKey === "unlabeled" + ? t("resourceLauncherUnlabeled") + : group.name; + + return ( + + + + + {isLoading ? ( +
+ +
+ ) : resources.length === 0 ? ( +

+ {t("resourceLauncherNoResourcesInGroup")} +

+ ) : config.layout === "grid" ? ( + + ) : ( + + )} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} + + + ); +} diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx new file mode 100644 index 000000000..a43536fd5 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { CollapsibleTrigger } from "@app/components/ui/collapsible"; +import type { LauncherGroup } from "@server/routers/launcher/types"; +import { ChevronDown, ChevronLeft } from "lucide-react"; + +type LauncherGroupTriggerProps = { + group: LauncherGroup; + title: string; + isOpen: boolean; +}; + +function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) { + if (group.groupType === "label") { + return ( + + ); + } + + if (group.groupType === "site") { + if ( + (group.siteType === "newt" || group.siteType === "wireguard") && + typeof group.siteOnline === "boolean" + ) { + return ( + + ); + } + + return ; + } + + return null; +} + +export function LauncherGroupTrigger({ + group, + title, + isOpen +}: LauncherGroupTriggerProps) { + return ( + + {group.groupType === "site" || group.groupType === "label" ? ( + + ) : null} + + + {title} ({group.itemCount}) + + {isOpen ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/resource-launcher/LauncherLabelsRow.tsx b/src/components/resource-launcher/LauncherLabelsRow.tsx new file mode 100644 index 000000000..e2c913ae9 --- /dev/null +++ b/src/components/resource-launcher/LauncherLabelsRow.tsx @@ -0,0 +1,175 @@ +"use client"; + +import type { LauncherLabel } from "@server/routers/launcher/types"; +import { LabelBadge } from "@app/components/label-badge"; +import { LabelOverflowBadge } from "@app/components/label-overflow-badge"; +import { cn } from "@app/lib/cn"; +import { useLayoutEffect, useRef, useState } from "react"; + +const MAX_LABEL_ROWS = 2; +const SINGLE_ROW_MAX_LABELS = 5; + +type LauncherLabelsRowProps = { + labels: LauncherLabel[]; + className?: string; + variant?: "wrap" | "single-row"; +}; + +function countFlexRows(container: HTMLElement): number { + const rowTops = new Set(); + + for (const child of container.children) { + const element = child as HTMLElement; + if (element.style.display === "none") { + continue; + } + rowTops.add(element.offsetTop); + } + + return rowTops.size; +} + +export function LauncherLabelsRow({ + labels, + className, + variant = "wrap" +}: LauncherLabelsRowProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(labels.length); + + const labelKey = labels.map((label) => label.labelId).join(","); + + useLayoutEffect(() => { + if (variant === "single-row") { + return; + } + + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure || labels.length === 0) { + return; + } + + const recompute = () => { + const width = container.clientWidth; + if (width <= 0) { + setVisibleCount(labels.length); + return; + } + + measure.style.width = `${width}px`; + + const labelNodes = measure.querySelectorAll( + "[data-measure-label]" + ); + const overflowNode = measure.querySelector( + "[data-measure-overflow]" + ); + + const fits = (visible: number) => { + labelNodes.forEach((node, index) => { + node.style.display = index < visible ? "" : "none"; + }); + + if (overflowNode) { + const overflowCount = labels.length - visible; + if (overflowCount > 0) { + overflowNode.style.display = ""; + } else { + overflowNode.style.display = "none"; + } + } + + return countFlexRows(measure) <= MAX_LABEL_ROWS; + }; + + let best = 0; + for (let visible = labels.length; visible >= 0; visible--) { + if (fits(visible)) { + best = visible; + break; + } + } + + setVisibleCount(best); + }; + + recompute(); + + const observer = new ResizeObserver(recompute); + observer.observe(container); + + return () => observer.disconnect(); + }, [labelKey, labels, variant]); + + if (labels.length === 0) { + return null; + } + + const resolvedVisibleCount = + variant === "single-row" + ? Math.min(labels.length, SINGLE_ROW_MAX_LABELS) + : visibleCount; + const visibleLabels = labels.slice(0, resolvedVisibleCount); + const overflowLabels = labels.slice(resolvedVisibleCount); + + return ( +
+
+ {visibleLabels.map((label) => ( + + ))} + {overflowLabels.length > 0 ? ( + ({ + color: label.color, + name: label.name + }))} + displayOnly + className="shrink-0" + /> + ) : null} +
+ + {variant === "wrap" ? ( +
+ {labels.map((label) => ( + + + + ))} + + + +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherOrgSelector.tsx b/src/components/resource-launcher/LauncherOrgSelector.tsx new file mode 100644 index 000000000..795e21927 --- /dev/null +++ b/src/components/resource-launcher/LauncherOrgSelector.tsx @@ -0,0 +1,114 @@ +"use client"; + +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 { ListUserOrgsResponse } from "@server/routers/org"; +import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; + +type LauncherOrgSelectorProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; +}; + +export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const t = useTranslations(); + + const selectedOrg = orgs?.find((org) => org.orgId === orgId); + + const sortedOrgs = useMemo(() => { + if (!orgs?.length) { + return orgs ?? []; + } + return [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) { + return -1; + } + if (!aPrimary && bPrimary) { + return 1; + } + return 0; + }); + }, [orgs]); + + return ( + + + + + + + + + {t("orgNotFound2")} + + {sortedOrgs.map((org) => ( + { + setOpen(false); + const newPath = pathname.includes( + "/settings/" + ) + ? pathname.replace( + /^\/[^/]+/, + `/${org.orgId}` + ) + : `/${org.orgId}`; + router.push(newPath); + }} + > +
+ + {org.name} + + + {org.orgId} + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx new file mode 100644 index 000000000..729036589 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import Link from "next/link"; +import { LauncherCopyIcon } from "./LauncherCopyIcon"; + +type LauncherResourceAccessProps = { + accessDisplay: string; + accessCopyValue: string; + accessUrl?: string | null; + variant: "grid" | "list"; +}; + +export function LauncherResourceAccess({ + accessDisplay, + accessCopyValue, + accessUrl, + variant +}: LauncherResourceAccessProps) { + if (!accessDisplay) { + return null; + } + + const href = accessUrl ?? undefined; + const canLink = href && isSafeUrlForLink(href); + const copyValue = canLink ? href : accessCopyValue; + + if (variant === "list") { + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); + } + + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx new file mode 100644 index 000000000..506893c29 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { + getLauncherResourceClickProps, + useLauncherResourceAction +} from "./useLauncherResourceAction"; + +type LauncherResourceCardProps = { + resource: LauncherResource; + showLabels: boolean; +}; + +export function LauncherResourceCard({ + resource, + showLabels +}: LauncherResourceCardProps) { + const hasIcon = Boolean(resource.iconUrl); + const { handleAction, isClickable } = useLauncherResourceAction({ + accessUrl: resource.accessUrl, + accessCopyValue: resource.accessCopyValue + }); + const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + + return ( +
+
+ {hasIcon ? ( + + ) : null} + +
+
+ {resource.name} +
+ +
+
+ + {showLabels && resource.labels.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceGrid.tsx b/src/components/resource-launcher/LauncherResourceGrid.tsx new file mode 100644 index 000000000..99b80a35e --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceGrid.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceCard } from "./LauncherResourceCard"; + +type LauncherResourceGridProps = { + resources: LauncherResource[]; + showLabels: boolean; +}; + +export function LauncherResourceGrid({ + resources, + showLabels +}: LauncherResourceGridProps) { + return ( +
+ {resources.map((resource) => ( + + ))} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceIcon.tsx b/src/components/resource-launcher/LauncherResourceIcon.tsx new file mode 100644 index 000000000..a4abbd63f --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceIcon.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { cn } from "@app/lib/cn"; + +type LauncherResourceIconProps = { + iconUrl?: string | null; + name: string; + className?: string; + variant?: "grid" | "list"; +}; + +export function LauncherResourceIcon({ + iconUrl, + name, + className, + variant = "grid" +}: LauncherResourceIconProps) { + const dimension = variant === "list" ? "size-5" : "size-10"; + + if (iconUrl) { + return ( + {name} + ); + } + + if (variant === "list") { + return ( +
+ - +
+ ); + } + + return null; +} diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx new file mode 100644 index 000000000..69d9a5385 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceRow } from "./LauncherResourceRow"; + +type LauncherResourceListProps = { + resources: LauncherResource[]; + showLabels: boolean; + showSiteTags: boolean; +}; + +export function LauncherResourceList({ + resources, + showLabels, + showSiteTags +}: LauncherResourceListProps) { + return ( +
+ {resources.map((resource, index) => ( + + ))} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx new file mode 100644 index 000000000..21dd763fe --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { LabelBadge } from "@app/components/label-badge"; +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { + getLauncherResourceClickProps, + useLauncherResourceAction +} from "./useLauncherResourceAction"; + +type LauncherResourceRowProps = { + resource: LauncherResource; + showLabels: boolean; + showSiteTags: boolean; + isLast?: boolean; +}; + +export function LauncherResourceRow({ + resource, + showLabels, + showSiteTags, + isLast = false +}: LauncherResourceRowProps) { + const hasTags = + (showSiteTags && resource.site) || + (showLabels && resource.labels.length > 0); + const { handleAction, isClickable } = useLauncherResourceAction({ + accessUrl: resource.accessUrl, + accessCopyValue: resource.accessCopyValue + }); + const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + + return ( +
+ + + + {resource.name} + + + + + {hasTags ? ( +
+ {showSiteTags && resource.site ? ( + + ) : null} + {showLabels ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx new file mode 100644 index 000000000..086df395d --- /dev/null +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; +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 type { LauncherViewConfig } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import { Settings } from "lucide-react"; + +type LauncherSettingsMenuProps = { + config: LauncherViewConfig; + isDefaultView: boolean; + onConfigChange: (patch: Partial) => void; + onDeleteView: () => void; +}; + +export function LauncherSettingsMenu({ + config, + isDefaultView, + onConfigChange, + onDeleteView +}: LauncherSettingsMenuProps) { + const t = useTranslations(); + + return ( + + + + + +
+
+

+ {t("resourceLauncherGroupBy")} +

+ +
+ +
+

+ {t("resourceLauncherLayout")} +

+ +
+ +
+
+ + + onConfigChange({ showLabels: checked }) + } + /> +
+
+ + + onConfigChange({ showSiteTags: checked }) + } + /> +
+
+ + {!isDefaultView ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherSortButton.tsx b/src/components/resource-launcher/LauncherSortButton.tsx new file mode 100644 index 000000000..d79c3c6c2 --- /dev/null +++ b/src/components/resource-launcher/LauncherSortButton.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { ArrowDown01, ArrowUp10 } from "lucide-react"; + +type LauncherSortButtonProps = { + order: "asc" | "desc"; + onToggle: () => void; +}; + +export function LauncherSortButton({ + order, + onToggle +}: LauncherSortButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx new file mode 100644 index 000000000..474d04e67 --- /dev/null +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useTranslations } from "next-intl"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +type LauncherViewTabsProps = { + activeViewId: number | "default"; + savedViews: Array<{ viewId: number; name: string }>; + onSelectView: (viewId: number | "default") => void; +}; + +export function LauncherViewTabs({ + activeViewId, + savedViews, + onSelectView +}: LauncherViewTabsProps) { + const t = useTranslations(); + + const viewOptions: Array<{ + value: number | "default"; + label: string; + }> = [ + { value: "default", label: t("resourceLauncherDefaultView") }, + ...savedViews.map((view) => ({ + value: view.viewId, + label: view.name + })) + ]; + + return ( +
+ {viewOptions.map((option) => { + const isSelected = activeViewId === option.value; + return ( + + ); + })} +
+ ); +} + +type LauncherSaveViewMenuProps = { + isDefaultView: boolean; + isAdmin: boolean; + isOrgWideView: boolean; + hasUnsavedChanges: boolean; + onSaveToCurrent: () => void; + onSaveAsNew: () => void; + onSaveForEveryone: () => void; + onMakePersonal: () => void; +}; + +export function LauncherSaveViewMenu({ + isDefaultView, + isAdmin, + isOrgWideView, + hasUnsavedChanges, + onSaveToCurrent, + onSaveAsNew, + onSaveForEveryone, + onMakePersonal +}: LauncherSaveViewMenuProps) { + const t = useTranslations(); + + return ( + + + + + + {!isDefaultView ? ( + + {t("resourceLauncherSaveToCurrentView")} + + ) : null} + + {t("resourceLauncherSaveAsNewView")} + + {isAdmin && !isDefaultView && !isOrgWideView ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + {isAdmin && !isDefaultView && isOrgWideView ? ( + + {t("resourceLauncherMakePersonal")} + + ) : null} + + + ); +} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx new file mode 100644 index 000000000..de73b7035 --- /dev/null +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + readLauncherLastView, + writeLauncherLastView, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { + buildLauncherPath, + getLauncherUrlBaseConfig, + isLauncherConfigEqual, + resolveLauncherStateFromUrl, + serializeLauncherUrlState +} from "@app/lib/launcherUrlState"; +import { launcherQueries } from "@app/lib/queries"; +import { useToast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + defaultLauncherViewConfig, + type LauncherViewConfig, + type LauncherViewRecord +} from "@server/routers/launcher/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedLabel } from "@app/components/labels-selector"; +import { LauncherFilterPopover } from "./LauncherFilterPopover"; +import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; +import { LauncherSortButton } from "./LauncherSortButton"; +import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +type ResourceLauncherProps = { + orgId: string; + isAdmin: boolean; +}; + +export default function ResourceLauncher({ + orgId, + isAdmin +}: ResourceLauncherProps) { + const t = useTranslations(); + const { toast } = useToast(); + const { env } = useEnvContext(); + const queryClient = useQueryClient(); + const api = createApiClient({ env }); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [activeViewId, setActiveViewId] = + useState("default"); + const hasRestoredLastView = useRef(false); + const isApplyingUrlRef = useRef(false); + + const [config, setConfig] = useState( + defaultLauncherViewConfig + ); + const [savedConfig, setSavedConfig] = useState( + defaultLauncherViewConfig + ); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [newViewName, setNewViewName] = useState(""); + const [saveOrgWide, setSaveOrgWide] = useState(false); + + const configRef = useRef(config); + configRef.current = config; + const searchInputRef = useRef(searchInput); + searchInputRef.current = searchInput; + const activeViewIdRef = useRef(activeViewId); + activeViewIdRef.current = activeViewId; + + const { data: views = [], isLoading: viewsLoading } = useQuery( + launcherQueries.views(orgId) + ); + + const syncUrl = useCallback( + (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { + if (isApplyingUrlRef.current) { + return; + } + + const params = serializeLauncherUrlState({ + viewId, + config: nextConfig + }); + const path = buildLauncherPath(orgId, params); + router.replace(path, { scroll: false }); + }, + [orgId, router] + ); + + const debouncedSyncSearch = useDebouncedCallback( + (viewId: LauncherActiveViewId, query: string) => { + const nextConfig = { ...configRef.current, query }; + setSearchQuery(query); + syncUrl(viewId, nextConfig); + }, + 300 + ); + + useEffect(() => { + if (viewsLoading) { + return; + } + + let fallbackViewId: LauncherActiveViewId | null = null; + if (!hasRestoredLastView.current) { + hasRestoredLastView.current = true; + fallbackViewId = readLauncherLastView(orgId); + } + + isApplyingUrlRef.current = true; + const resolved = resolveLauncherStateFromUrl( + new URLSearchParams(searchParams), + views, + fallbackViewId + ); + + setActiveViewId(resolved.activeViewId); + setConfig(resolved.config); + setSavedConfig(resolved.savedConfig); + setSearchInput(resolved.config.query); + setSearchQuery(resolved.config.query); + isApplyingUrlRef.current = false; + }, [orgId, searchParams, views, viewsLoading]); + + const selectView = useCallback( + (viewId: LauncherActiveViewId) => { + writeLauncherLastView(orgId, viewId); + const baseConfig = getLauncherUrlBaseConfig(viewId, views); + syncUrl(viewId, baseConfig); + }, + [orgId, syncUrl, views] + ); + + const activeSavedView = useMemo( + () => + activeViewId === "default" + ? null + : views.find((view) => view.viewId === activeViewId), + [activeViewId, views] + ); + + const isDefaultView = activeViewId === "default"; + const isOrgWideView = Boolean(activeSavedView?.isOrgWide); + const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); + + const selectedSites: Selectedsite[] = useMemo( + () => + config.siteIds.map((siteId) => ({ + siteId, + name: String(siteId), + type: "newt" + })), + [config.siteIds] + ); + + const selectedLabels: SelectedLabel[] = useMemo( + () => + config.labelIds.map((labelId) => ({ + labelId, + name: String(labelId), + color: "#a1a1aa" + })), + [config.labelIds] + ); + + const invalidateLauncher = () => { + void queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "LAUNCHER"] + }); + }; + + const createViewMutation = useMutation({ + mutationFn: async (payload: { + name: string; + config: LauncherViewConfig; + orgWide: boolean; + }) => { + const res = await api.post(`/org/${orgId}/launcher/views`, payload); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + invalidateLauncher(); + writeLauncherLastView(orgId, view.viewId); + + isApplyingUrlRef.current = true; + setActiveViewId(view.viewId); + setConfig(view.config); + setSavedConfig(view.config); + setSearchInput(view.config.query); + setSearchQuery(view.config.query); + isApplyingUrlRef.current = false; + + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + router.replace(buildLauncherPath(orgId, params), { scroll: false }); + + setSaveDialogOpen(false); + setNewViewName(""); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const updateViewMutation = useMutation({ + mutationFn: async (payload: { + viewId: number; + name?: string; + config?: LauncherViewConfig; + orgWide?: boolean; + }) => { + const { viewId, ...body } = payload; + const res = await api.put( + `/org/${orgId}/launcher/views/${viewId}`, + body + ); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + invalidateLauncher(); + + isApplyingUrlRef.current = true; + setActiveViewId(view.viewId); + setConfig(view.config); + setSavedConfig(view.config); + setSearchInput(view.config.query); + setSearchQuery(view.config.query); + isApplyingUrlRef.current = false; + + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + router.replace(buildLauncherPath(orgId, params), { scroll: false }); + + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const deleteViewMutation = useMutation({ + mutationFn: async (viewId: number) => { + await api.delete(`/org/${orgId}/launcher/views/${viewId}`); + }, + onSuccess: () => { + invalidateLauncher(); + selectView("default"); + toast({ + title: t("resourceLauncherViewDeleted"), + description: t("resourceLauncherViewDeletedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewDeleteFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewDeleteFailedDescription") + ) + }); + } + }); + + const applyConfigPatch = useCallback( + (patch: Partial) => { + const nextConfig = { + ...configRef.current, + ...patch, + query: searchInputRef.current + }; + syncUrl(activeViewIdRef.current, nextConfig); + }, + [syncUrl] + ); + + const handleSaveToCurrent = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + config + }); + }; + + const handleSaveAsNew = () => { + setSaveOrgWide(false); + setNewViewName(""); + setSaveDialogOpen(true); + }; + + const handleSaveForEveryone = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: true + }); + }; + + const handleMakePersonal = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: false + }); + }; + + const handleCreateView = () => { + if (!newViewName.trim()) { + return; + } + createViewMutation.mutate({ + name: newViewName.trim(), + config, + orgWide: saveOrgWide && isAdmin + }); + }; + + return ( +
+ + +
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedSyncSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ {!viewsLoading ? ( + ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> + ) : null} +
+
+ + + applyConfigPatch({ + siteIds: sites.map((site) => site.siteId) + }) + } + onLabelsChange={(labels) => + applyConfigPatch({ + labelIds: labels.map( + (label) => label.labelId + ) + }) + } + /> + + applyConfigPatch({ + order: + config.order === "asc" ? "desc" : "asc" + }) + } + /> + { + if (!isDefaultView) { + deleteViewMutation.mutate(activeViewId); + } + }} + /> +
+
+
+ + + + + + + + {t("resourceLauncherSaveAsNewView")} + + + {t("resourceLauncherSaveAsNewViewDescription")} + + + +
+ + + setNewViewName(event.target.value) + } + /> +
+ {isAdmin ? ( +
+ + setSaveOrgWide(checked === true) + } + /> +

+ {t( + "resourceLauncherSaveForEveryoneDescription" + )} +

+
+ ) : null} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts new file mode 100644 index 000000000..427ee64bf --- /dev/null +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useToast } from "@app/hooks/useToast"; +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import { useTranslations } from "next-intl"; +import { useCallback, type KeyboardEvent, type MouseEvent } from "react"; + +type LauncherResourceActionInput = { + accessUrl?: string | null; + accessCopyValue: string; +}; + +export function useLauncherResourceAction({ + accessUrl, + accessCopyValue +}: LauncherResourceActionInput) { + const { toast } = useToast(); + const t = useTranslations(); + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + const isClickable = canLink || Boolean(accessCopyValue); + + const handleAction = useCallback(() => { + if (canLink && href) { + window.open(href, "_blank", "noopener,noreferrer"); + return; + } + + if (!accessCopyValue) { + return; + } + + void navigator.clipboard.writeText(accessCopyValue).then(() => { + toast({ + title: t("resourceLauncherCopiedToClipboard"), + description: t("resourceLauncherCopiedAccessDescription"), + duration: 2000 + }); + }); + }, [accessCopyValue, canLink, href, t, toast]); + + return { handleAction, isClickable }; +} + +export function isLauncherResourceInteractiveTarget( + target: EventTarget | null +): boolean { + if (!(target instanceof Element)) { + return false; + } + + return Boolean( + target.closest("a, button, [role='button'], input, textarea, select") + ); +} + +function handleLauncherResourceClick( + event: MouseEvent, + handleAction: () => void +) { + if (isLauncherResourceInteractiveTarget(event.target)) { + return; + } + + handleAction(); +} + +export function getLauncherResourceClickProps( + handleAction: () => void, + isClickable: boolean +) { + return { + onClick: (event: MouseEvent) => + handleLauncherResourceClick(event, handleAction), + className: isClickable ? "cursor-pointer" : undefined, + role: isClickable ? ("button" as const) : undefined, + tabIndex: isClickable ? 0 : undefined, + onKeyDown: isClickable + ? (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleAction(); + } + } + : undefined + }; +} diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts new file mode 100644 index 000000000..6e1fb60a5 --- /dev/null +++ b/src/lib/launcherLocalStorage.ts @@ -0,0 +1,98 @@ +export type LauncherActiveViewId = number | "default"; + +const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:"; +const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:"; + +function lastViewKey(orgId: string) { + return `${LAST_VIEW_PREFIX}${orgId}`; +} + +function groupOpenKey( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +) { + return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; +} + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") { + return fallback; + } + + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return fallback; + } +} + +function writeJson(key: string, value: unknown) { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`Error writing localStorage key "${key}":`, error); + } +} + +export function readLauncherLastView( + orgId: string +): LauncherActiveViewId | null { + const value = readJson( + lastViewKey(orgId), + null + ); + if (value === "default" || typeof value === "number") { + return value; + } + return null; +} + +export function writeLauncherLastView( + orgId: string, + viewId: LauncherActiveViewId +) { + writeJson(lastViewKey(orgId), viewId); +} + +export function readLauncherGroupOpenState( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +): Record { + return readJson>( + groupOpenKey(orgId, viewId, groupBy), + {} + ); +} + +export function readLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + defaultOpen: boolean +): boolean { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + return groupKey in state ? state[groupKey] : defaultOpen; +} + +export function writeLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + isOpen: boolean +) { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + writeJson(groupOpenKey(orgId, viewId, groupBy), { + ...state, + [groupKey]: isOpen + }); +} diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts new file mode 100644 index 000000000..d7dd888ac --- /dev/null +++ b/src/lib/launcherResourceAccess.ts @@ -0,0 +1,123 @@ +import { + formatSiteResourceDestinationDisplay, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export { + formatSiteResourceDestinationDisplay, + resolveHttpHttpsDisplayPort, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + const port = resource.proxyPort?.toString() ?? ""; + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} + +export function isSafeUrlForLink(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts new file mode 100644 index 000000000..13f74218c --- /dev/null +++ b/src/lib/launcherUrlState.ts @@ -0,0 +1,292 @@ +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { + defaultLauncherViewConfig, + parseIdListParam, + type LauncherViewConfig, + type LauncherViewRecord +} from "@server/routers/launcher/types"; +import { z } from "zod"; + +const launcherUrlBooleanSchema = z + .enum(["0", "1"]) + .transform((value) => value === "1"); + +export type LauncherUrlConfigOverrides = Partial< + Pick< + LauncherViewConfig, + | "groupBy" + | "layout" + | "order" + | "showLabels" + | "showSiteTags" + | "siteIds" + | "labelIds" + | "query" + > +>; + +export type ParsedLauncherUrlState = { + viewId: LauncherActiveViewId | null; + configOverrides: LauncherUrlConfigOverrides; + hasAnyLauncherParams: boolean; +}; + +export type ResolvedLauncherState = { + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; +}; + +const LAUNCHER_CONFIG_PARAM_KEYS = [ + "query", + "groupBy", + "layout", + "order", + "showLabels", + "showSiteTags", + "siteIds", + "labelIds" +] as const; + +const LAUNCHER_URL_PARAM_KEYS = [ + "view", + ...LAUNCHER_CONFIG_PARAM_KEYS +] as const; + +export function hasLauncherConfigParams(searchParams: URLSearchParams) { + return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key)); +} + +export function isLauncherConfigEqual( + a: LauncherViewConfig, + b: LauncherViewConfig +) { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function getLauncherUrlBaseConfig( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +): LauncherViewConfig { + if (viewId === "default") { + return defaultLauncherViewConfig; + } + + const savedView = views.find((view) => view.viewId === viewId); + return savedView?.config ?? defaultLauncherViewConfig; +} + +export function resolveLauncherConfig( + baseConfig: LauncherViewConfig, + overrides: LauncherUrlConfigOverrides +): LauncherViewConfig { + return { + ...baseConfig, + ...overrides, + sortBy: "name" + }; +} + +function parseViewParam(value: string | null): LauncherActiveViewId | null { + if (value === null) { + return null; + } + + if (value === "default") { + return "default"; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return "default"; + } + + return parsed; +} + +function parseConfigOverrides( + searchParams: URLSearchParams +): LauncherUrlConfigOverrides { + const overrides: LauncherUrlConfigOverrides = {}; + + const query = searchParams.get("query"); + if (query !== null) { + overrides.query = query; + } + + const groupBy = searchParams.get("groupBy"); + if (groupBy === "site" || groupBy === "label") { + overrides.groupBy = groupBy; + } + + const layout = searchParams.get("layout"); + if (layout === "grid" || layout === "list") { + overrides.layout = layout; + } + + const order = searchParams.get("order"); + if (order === "asc" || order === "desc") { + overrides.order = order; + } + + const showLabels = searchParams.get("showLabels"); + if (showLabels !== null) { + const parsed = launcherUrlBooleanSchema.safeParse(showLabels); + if (parsed.success) { + overrides.showLabels = parsed.data; + } + } + + const showSiteTags = searchParams.get("showSiteTags"); + if (showSiteTags !== null) { + const parsed = launcherUrlBooleanSchema.safeParse(showSiteTags); + if (parsed.success) { + overrides.showSiteTags = parsed.data; + } + } + + const siteIds = searchParams.get("siteIds"); + if (siteIds !== null) { + overrides.siteIds = parseIdListParam(siteIds); + } + + const labelIds = searchParams.get("labelIds"); + if (labelIds !== null) { + overrides.labelIds = parseIdListParam(labelIds); + } + + return overrides; +} + +export function parseLauncherUrlState( + searchParams: URLSearchParams +): ParsedLauncherUrlState { + const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) => + searchParams.has(key) + ); + + return { + viewId: parseViewParam(searchParams.get("view")), + configOverrides: parseConfigOverrides(searchParams), + hasAnyLauncherParams + }; +} + +function isValidActiveViewId( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +) { + return viewId === "default" || views.some((view) => view.viewId === viewId); +} + +export function resolveLauncherStateFromUrl( + searchParams: URLSearchParams, + views: LauncherViewRecord[], + fallbackViewId: LauncherActiveViewId | null +): ResolvedLauncherState { + const parsed = parseLauncherUrlState(searchParams); + + let activeViewId: LauncherActiveViewId = "default"; + + if (parsed.viewId !== null) { + activeViewId = isValidActiveViewId(parsed.viewId, views) + ? parsed.viewId + : "default"; + } else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) { + activeViewId = isValidActiveViewId(fallbackViewId, views) + ? fallbackViewId + : "default"; + } + + const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + + let config: LauncherViewConfig; + if (hasLauncherConfigParams(searchParams)) { + config = resolveLauncherConfig( + defaultLauncherViewConfig, + parsed.configOverrides + ); + } else if (activeViewId !== "default") { + config = savedConfig; + } else { + config = defaultLauncherViewConfig; + } + + return { + activeViewId, + config, + savedConfig + }; +} + +function idListsEqual(a: number[], b: number[]) { + if (a.length !== b.length) { + return false; + } + + return a.every((value, index) => value === b[index]); +} + +export function serializeLauncherUrlState({ + viewId, + config +}: { + viewId: LauncherActiveViewId; + config: LauncherViewConfig; +}): URLSearchParams { + const baseConfig = defaultLauncherViewConfig; + const params = new URLSearchParams(); + + if (viewId !== "default") { + params.set("view", String(viewId)); + } + + if (config.query !== baseConfig.query && config.query) { + params.set("query", config.query); + } else if (config.query !== baseConfig.query && !config.query) { + params.set("query", ""); + } + + if (config.groupBy !== baseConfig.groupBy) { + params.set("groupBy", config.groupBy); + } + + if (config.layout !== baseConfig.layout) { + params.set("layout", config.layout); + } + + if (config.order !== baseConfig.order) { + params.set("order", config.order); + } + + if (config.showLabels !== baseConfig.showLabels) { + params.set("showLabels", config.showLabels ? "1" : "0"); + } + + if (config.showSiteTags !== baseConfig.showSiteTags) { + params.set("showSiteTags", config.showSiteTags ? "1" : "0"); + } + + if (!idListsEqual(config.siteIds, baseConfig.siteIds)) { + if (config.siteIds.length > 0) { + params.set("siteIds", config.siteIds.join(",")); + } else { + params.set("siteIds", ""); + } + } + + if (!idListsEqual(config.labelIds, baseConfig.labelIds)) { + if (config.labelIds.length > 0) { + params.set("labelIds", config.labelIds.join(",")); + } else { + params.set("labelIds", ""); + } + } + + return params; +} + +export function buildLauncherPath(orgId: string, params: URLSearchParams) { + const query = params.toString(); + return query ? `/${orgId}?${query}` : `/${orgId}`; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b8a50a908..4d1cdc75c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -46,6 +46,13 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { + ListLauncherGroupsResponse, + ListLauncherResourcesResponse, + ListLauncherViewsResponse, + LauncherListQuery, + LauncherViewConfig +} from "@server/routers/launcher/types"; export type ProductUpdate = { link: string | null; @@ -1166,3 +1173,96 @@ export const domainQueries = { refetchInterval: durationToMs(10, "seconds") }) }; + +export type LauncherQueryFilters = { + query?: string; + groupBy?: LauncherListQuery["groupBy"]; + groupKey?: string; + siteIds?: number[]; + labelIds?: number[]; + sort_by?: LauncherListQuery["sort_by"]; + order?: LauncherListQuery["order"]; + pageSize?: number; +}; + +function launcherSearchParams(filters: LauncherQueryFilters, page: number) { + const sp = new URLSearchParams(); + sp.set("page", String(page)); + sp.set("pageSize", String(filters.pageSize ?? 20)); + if (filters.query) { + sp.set("query", filters.query); + } + if (filters.groupBy) { + sp.set("groupBy", filters.groupBy); + } + if (filters.groupKey) { + sp.set("groupKey", filters.groupKey); + } + if (filters.siteIds?.length) { + sp.set("siteIds", filters.siteIds.join(",")); + } + if (filters.labelIds?.length) { + sp.set("labelIds", filters.labelIds.join(",")); + } + if (filters.sort_by) { + sp.set("sort_by", filters.sort_by); + } + if (filters.order) { + sp.set("order", filters.order); + } + return sp; +} + +export const launcherQueries = { + views: (orgId: string) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, { signal }); + return res.data.data.views; + } + }), + groups: (orgId: string, filters: LauncherQueryFilters) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = launcherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }), + resources: ( + orgId: string, + filters: LauncherQueryFilters & { groupKey: string } + ) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = launcherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/resources?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }) +}; From fed4ec42c4aa5cd6b91ec2885d8029a6c02229a5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:15:03 -0400 Subject: [PATCH 32/79] add server side fetching --- src/app/[orgId]/loading.tsx | 9 + src/app/[orgId]/page.tsx | 28 ++- .../resource-launcher/LauncherGroupList.tsx | 214 ++++++------------ .../LauncherGroupSection.tsx | 42 +++- .../resource-launcher/ResourceLauncher.tsx | 212 ++++++++--------- src/lib/launcherSearchParams.ts | 43 ++++ src/lib/launcherServerData.ts | 128 +++++++++++ src/lib/queries.ts | 48 +--- 8 files changed, 411 insertions(+), 313 deletions(-) create mode 100644 src/app/[orgId]/loading.tsx create mode 100644 src/lib/launcherSearchParams.ts create mode 100644 src/lib/launcherServerData.ts diff --git a/src/app/[orgId]/loading.tsx b/src/app/[orgId]/loading.tsx new file mode 100644 index 000000000..0f7ea0d31 --- /dev/null +++ b/src/app/[orgId]/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function OrgPageLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 0c3ad6547..b76b77b7b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,8 +2,8 @@ import { Layout } from "@app/components/Layout"; import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { fetchLauncherPageData } from "@app/lib/launcherServerData"; import { verifySession } from "@app/lib/auth/verifySession"; -import { pullEnv } from "@app/lib/pullEnv"; import UserProvider from "@app/providers/UserProvider"; import { ListUserOrgsResponse } from "@server/routers/org"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; @@ -13,8 +13,11 @@ import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; +export const dynamic = "force-dynamic"; + export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; @@ -55,6 +58,15 @@ export default async function OrgPage(props: OrgPageProps) { const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + const searchParams = new URLSearchParams(await props.searchParams); + const launcherData = overview + ? await fetchLauncherPageData( + orgId, + searchParams, + await authCookieHeader() + ) + : null; + return ( - {overview ? ( - + {overview && launcherData ? ( + ) : null} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index 854241cdd..c90c3a7cc 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -1,159 +1,80 @@ "use client"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; -import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage"; +import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; -import type { LauncherViewConfig } from "@server/routers/launcher/types"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { + LauncherGroup, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { LauncherGroupSection } from "./LauncherGroupSection"; type LauncherGroupListProps = { orgId: string; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; - searchQuery: string; -}; - -function buildResourceFilters( - config: LauncherViewConfig, - searchQuery: string, - groupKey: string -) { - return { - query: searchQuery, - groupBy: config.groupBy, - groupKey, - siteIds: config.siteIds, - labelIds: config.labelIds, - sort_by: config.sortBy, - order: config.order, - pageSize: 20 + initialGroups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; }; -} + resourcesByGroupKey: Record; +}; export function LauncherGroupList({ orgId, activeViewId, config, - searchQuery + initialGroups, + groupsPagination, + resourcesByGroupKey }: LauncherGroupListProps) { - const queryClient = useQueryClient(); const loadMoreRef = useRef(null); - const [isPrefetching, setIsPrefetching] = useState(false); - const prefetchBatchKeyRef = useRef(null); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + const groupFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + [ + config.groupBy, + config.labelIds, + config.order, + config.query, + config.siteIds, + config.sortBy + ] + ); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - ...launcherQueries.groups(orgId, { - query: searchQuery, - groupBy: config.groupBy, - siteIds: config.siteIds, - labelIds: config.labelIds, - sort_by: config.sortBy, - order: config.order, - pageSize: 20 - }) + ...launcherQueries.groups(orgId, groupFilters), + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false }); const groups = data?.pages.flatMap((page) => page.groups) ?? []; - const batchKey = useMemo( - () => - JSON.stringify({ - activeViewId, - searchQuery, - groupBy: config.groupBy, - siteIds: config.siteIds, - labelIds: config.labelIds, - sortBy: config.sortBy, - order: config.order - }), - [ - activeViewId, - config.groupBy, - config.labelIds, - config.order, - config.siteIds, - config.sortBy, - searchQuery - ] - ); - - const openGroupKeys = useMemo( - () => - groups - .filter((group) => - readLauncherGroupOpen( - orgId, - activeViewId, - config.groupBy, - group.groupKey, - true - ) - ) - .map((group) => group.groupKey), - [activeViewId, config.groupBy, groups, orgId] - ); - - useEffect(() => { - if (isLoading) { - return; - } - - if (openGroupKeys.length === 0) { - prefetchBatchKeyRef.current = batchKey; - setIsPrefetching(false); - return; - } - - if (prefetchBatchKeyRef.current === batchKey) { - return; - } - - let cancelled = false; - setIsPrefetching(true); - - void Promise.all( - openGroupKeys.map((groupKey) => - queryClient.prefetchInfiniteQuery( - launcherQueries.resources( - orgId, - buildResourceFilters(config, searchQuery, groupKey) - ) - ) - ) - ).finally(() => { - if (!cancelled) { - prefetchBatchKeyRef.current = batchKey; - setIsPrefetching(false); - } - }); - - return () => { - cancelled = true; - }; - }, [ - batchKey, - config, - isLoading, - openGroupKeys, - orgId, - queryClient, - searchQuery - ]); - - const isBatchPending = prefetchBatchKeyRef.current !== batchKey; - const isBodyLoading = - isLoading || - (isBatchPending && - openGroupKeys.length > 0 && - (isPrefetching || !isLoading)); - useEffect(() => { const node = loadMoreRef.current; - if (!node || !hasNextPage || isBodyLoading) { + if (!node || !hasNextPage) { return; } @@ -168,28 +89,25 @@ export function LauncherGroupList({ observer.observe(node); return () => observer.disconnect(); - }, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]); - - if (isBodyLoading) { - return ( -
- -
- ); - } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); return (
- {groups.map((group) => ( - - ))} + {groups.map((group) => { + const groupResources = resourcesByGroupKey[group.groupKey]; + + return ( + + ); + })}
{isFetchingNextPage ? (
diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 0f174bd86..8c32d9074 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -13,6 +13,7 @@ import { import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -28,7 +29,12 @@ type LauncherGroupSectionProps = { activeViewId: LauncherActiveViewId; group: LauncherGroup; config: LauncherViewConfig; - searchQuery: string; + initialResources?: LauncherResource[]; + initialResourcesPagination?: { + total: number; + page: number; + pageSize: number; + }; defaultOpen?: boolean; }; @@ -37,7 +43,8 @@ export function LauncherGroupSection({ activeViewId, group, config, - searchQuery, + initialResources, + initialResourcesPagination, defaultOpen = true }: LauncherGroupSectionProps) { const t = useTranslations(); @@ -75,10 +82,12 @@ export function LauncherGroupSection({ ); }; + const hasInitialResources = initialResources !== undefined; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ ...launcherQueries.resources(orgId, { - query: searchQuery, + query: config.query, groupBy: config.groupBy, groupKey: group.groupKey, siteIds: config.siteIds, @@ -87,14 +96,33 @@ export function LauncherGroupSection({ order: config.order, pageSize: 20 }), - enabled: isOpen + enabled: isOpen, + refetchOnMount: false, + ...(hasInitialResources + ? { + initialData: { + pages: [ + { + resources: initialResources, + pagination: initialResourcesPagination ?? { + total: initialResources.length, + page: 1, + pageSize: 20 + } + } + ], + pageParams: [1] + } + } + : {}) }); const resources = data?.pages.flatMap((page) => page.resources) ?? []; + const showInitialLoader = isLoading && resources.length === 0; useEffect(() => { const node = loadMoreRef.current; - if (!node || !hasNextPage) { + if (!node || !hasNextPage || !isOpen) { return; } @@ -109,7 +137,7 @@ export function LauncherGroupSection({ observer.observe(node); return () => observer.disconnect(); - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); const groupTitle = group.groupKey === "unlabeled" @@ -129,7 +157,7 @@ export function LauncherGroupSection({ /> - {isLoading ? ( + {showInitialLoader ? (
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index de73b7035..b09dea773 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -14,30 +14,31 @@ import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { readLauncherLastView, writeLauncherLastView, type LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { buildLauncherPath, getLauncherUrlBaseConfig, isLauncherConfigEqual, - resolveLauncherStateFromUrl, + parseLauncherUrlState, serializeLauncherUrlState } from "@app/lib/launcherUrlState"; -import { launcherQueries } from "@app/lib/queries"; import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { - defaultLauncherViewConfig, - type LauncherViewConfig, - type LauncherViewRecord +import type { + LauncherGroup, + LauncherViewConfig, + LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; @@ -52,33 +53,39 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourceLauncherProps = { orgId: string; isAdmin: boolean; + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + resourcesByGroupKey: Record; }; export default function ResourceLauncher({ orgId, - isAdmin + isAdmin, + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination, + resourcesByGroupKey }: ResourceLauncherProps) { const t = useTranslations(); const { toast } = useToast(); const { env } = useEnvContext(); - const queryClient = useQueryClient(); const api = createApiClient({ env }); const router = useRouter(); - const searchParams = useSearchParams(); - - const [activeViewId, setActiveViewId] = - useState("default"); + const { navigate, isNavigating, searchParams } = useNavigationContext(); const hasRestoredLastView = useRef(false); - const isApplyingUrlRef = useRef(false); - const [config, setConfig] = useState( - defaultLauncherViewConfig - ); - const [savedConfig, setSavedConfig] = useState( - defaultLauncherViewConfig - ); - const [searchInput, setSearchInput] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); + const [searchInput, setSearchInput] = useState(config.query); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -90,68 +97,66 @@ export default function ResourceLauncher({ const activeViewIdRef = useRef(activeViewId); activeViewIdRef.current = activeViewId; - const { data: views = [], isLoading: viewsLoading } = useQuery( - launcherQueries.views(orgId) - ); + useEffect(() => { + setSearchInput(config.query); + }, [config.query]); - const syncUrl = useCallback( + useEffect(() => { + if (hasRestoredLastView.current) { + return; + } + hasRestoredLastView.current = true; + + const parsed = parseLauncherUrlState(searchParams); + if (parsed.hasAnyLauncherParams) { + return; + } + + const lastView = readLauncherLastView(orgId); + if (lastView === null || lastView === activeViewId) { + return; + } + + const isValid = + lastView === "default" || + views.some((view) => view.viewId === lastView); + if (!isValid) { + return; + } + + const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const params = serializeLauncherUrlState({ + viewId: lastView, + config: baseConfig + }); + navigate({ searchParams: params, replace: true }); + }, [activeViewId, navigate, orgId, searchParams, views]); + + const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { - if (isApplyingUrlRef.current) { - return; - } - const params = serializeLauncherUrlState({ viewId, config: nextConfig }); - const path = buildLauncherPath(orgId, params); - router.replace(path, { scroll: false }); + navigate({ searchParams: params }); }, - [orgId, router] + [navigate] ); - const debouncedSyncSearch = useDebouncedCallback( + const debouncedNavigateSearch = useDebouncedCallback( (viewId: LauncherActiveViewId, query: string) => { - const nextConfig = { ...configRef.current, query }; - setSearchQuery(query); - syncUrl(viewId, nextConfig); + navigateToConfig(viewId, { ...configRef.current, query }); }, 300 ); - useEffect(() => { - if (viewsLoading) { - return; - } - - let fallbackViewId: LauncherActiveViewId | null = null; - if (!hasRestoredLastView.current) { - hasRestoredLastView.current = true; - fallbackViewId = readLauncherLastView(orgId); - } - - isApplyingUrlRef.current = true; - const resolved = resolveLauncherStateFromUrl( - new URLSearchParams(searchParams), - views, - fallbackViewId - ); - - setActiveViewId(resolved.activeViewId); - setConfig(resolved.config); - setSavedConfig(resolved.savedConfig); - setSearchInput(resolved.config.query); - setSearchQuery(resolved.config.query); - isApplyingUrlRef.current = false; - }, [orgId, searchParams, views, viewsLoading]); - const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); const baseConfig = getLauncherUrlBaseConfig(viewId, views); - syncUrl(viewId, baseConfig); + navigateToConfig(viewId, baseConfig); }, - [orgId, syncUrl, views] + [navigateToConfig, orgId, views] ); const activeSavedView = useMemo( @@ -186,12 +191,6 @@ export default function ResourceLauncher({ [config.labelIds] ); - const invalidateLauncher = () => { - void queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "LAUNCHER"] - }); - }; - const createViewMutation = useMutation({ mutationFn: async (payload: { name: string; @@ -202,23 +201,13 @@ export default function ResourceLauncher({ return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { - invalidateLauncher(); writeLauncherLastView(orgId, view.viewId); - - isApplyingUrlRef.current = true; - setActiveViewId(view.viewId); - setConfig(view.config); - setSavedConfig(view.config); - setSearchInput(view.config.query); - setSearchQuery(view.config.query); - isApplyingUrlRef.current = false; - const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); - router.replace(buildLauncherPath(orgId, params), { scroll: false }); - + navigate({ searchParams: params, replace: true }); + router.refresh(); setSaveDialogOpen(false); setNewViewName(""); toast({ @@ -253,22 +242,12 @@ export default function ResourceLauncher({ return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { - invalidateLauncher(); - - isApplyingUrlRef.current = true; - setActiveViewId(view.viewId); - setConfig(view.config); - setSavedConfig(view.config); - setSearchInput(view.config.query); - setSearchQuery(view.config.query); - isApplyingUrlRef.current = false; - const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); - router.replace(buildLauncherPath(orgId, params), { scroll: false }); - + navigate({ searchParams: params, replace: true }); + router.refresh(); toast({ title: t("resourceLauncherViewSaved"), description: t("resourceLauncherViewSavedDescription") @@ -291,8 +270,13 @@ export default function ResourceLauncher({ await api.delete(`/org/${orgId}/launcher/views/${viewId}`); }, onSuccess: () => { - invalidateLauncher(); - selectView("default"); + writeLauncherLastView(orgId, "default"); + const params = serializeLauncherUrlState({ + viewId: "default", + config: getLauncherUrlBaseConfig("default", views) + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); toast({ title: t("resourceLauncherViewDeleted"), description: t("resourceLauncherViewDeletedDescription") @@ -317,9 +301,9 @@ export default function ResourceLauncher({ ...patch, query: searchInputRef.current }; - syncUrl(activeViewIdRef.current, nextConfig); + navigateToConfig(activeViewIdRef.current, nextConfig); }, - [syncUrl] + [navigateToConfig] ); const handleSaveToCurrent = () => { @@ -370,7 +354,7 @@ export default function ResourceLauncher({ }; return ( -
+
{ const value = event.target.value; setSearchInput(value); - debouncedSyncSearch( + debouncedNavigateSearch( activeViewIdRef.current, value ); @@ -397,16 +381,14 @@ export default function ResourceLauncher({ className="pl-8" />
- {!viewsLoading ? ( - ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> - ) : null} + ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + />
diff --git a/src/lib/launcherSearchParams.ts b/src/lib/launcherSearchParams.ts new file mode 100644 index 000000000..acaccd06f --- /dev/null +++ b/src/lib/launcherSearchParams.ts @@ -0,0 +1,43 @@ +import type { LauncherListQuery } from "@server/routers/launcher/types"; + +export type LauncherQueryFilters = { + query?: string; + groupBy?: LauncherListQuery["groupBy"]; + groupKey?: string; + siteIds?: number[]; + labelIds?: number[]; + sort_by?: LauncherListQuery["sort_by"]; + order?: LauncherListQuery["order"]; + pageSize?: number; +}; + +export function buildLauncherSearchParams( + filters: LauncherQueryFilters, + page: number +) { + const sp = new URLSearchParams(); + sp.set("page", String(page)); + sp.set("pageSize", String(filters.pageSize ?? 20)); + if (filters.query) { + sp.set("query", filters.query); + } + if (filters.groupBy) { + sp.set("groupBy", filters.groupBy); + } + if (filters.groupKey) { + sp.set("groupKey", filters.groupKey); + } + if (filters.siteIds?.length) { + sp.set("siteIds", filters.siteIds.join(",")); + } + if (filters.labelIds?.length) { + sp.set("labelIds", filters.labelIds.join(",")); + } + if (filters.sort_by) { + sp.set("sort_by", filters.sort_by); + } + if (filters.order) { + sp.set("order", filters.order); + } + return sp; +} diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts new file mode 100644 index 000000000..da8fb1846 --- /dev/null +++ b/src/lib/launcherServerData.ts @@ -0,0 +1,128 @@ +import { internal } from "@app/lib/api"; +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +import type { + LauncherGroup, + LauncherResource, + LauncherViewConfig, + LauncherViewRecord, + ListLauncherGroupsResponse, + ListLauncherResourcesResponse, + ListLauncherViewsResponse +} from "@server/routers/launcher/types"; +import { AxiosResponse } from "axios"; + +export type LauncherGroupResources = { + resources: LauncherResource[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type LauncherPageData = { + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + resourcesByGroupKey: Record; +}; + +const emptyResources: LauncherGroupResources = { + resources: [], + pagination: { total: 0, page: 1, pageSize: 20 } +}; + +export async function fetchLauncherPageData( + orgId: string, + searchParams: URLSearchParams, + cookieHeader: Awaited< + ReturnType + > +): Promise { + let views: LauncherViewRecord[] = []; + try { + const viewsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, cookieHeader); + views = viewsRes.data.data.views; + } catch (e) {} + + const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( + searchParams, + views, + null + ); + + const groupFilters = { + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }; + + let groups: LauncherGroup[] = []; + let groupsPagination: LauncherPageData["groupsPagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const sp = buildLauncherSearchParams(groupFilters, 1); + const groupsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader); + groups = groupsRes.data.data.groups; + groupsPagination = groupsRes.data.data.pagination; + } catch (e) {} + + const resourcesByGroupKey: Record = {}; + + await Promise.all( + groups.map(async (group) => { + try { + const sp = buildLauncherSearchParams( + { + ...groupFilters, + groupKey: group.groupKey + }, + 1 + ); + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/launcher/resources?${sp.toString()}`, + cookieHeader + ); + resourcesByGroupKey[group.groupKey] = { + resources: res.data.data.resources, + pagination: res.data.data.pagination + }; + } catch (e) { + resourcesByGroupKey[group.groupKey] = emptyResources; + } + }) + ); + + return { + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination, + resourcesByGroupKey + }; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 4d1cdc75c..c5d613942 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -53,6 +53,11 @@ import type { LauncherListQuery, LauncherViewConfig } from "@server/routers/launcher/types"; +import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; + +export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; export type ProductUpdate = { link: string | null; @@ -1174,45 +1179,6 @@ export const domainQueries = { }) }; -export type LauncherQueryFilters = { - query?: string; - groupBy?: LauncherListQuery["groupBy"]; - groupKey?: string; - siteIds?: number[]; - labelIds?: number[]; - sort_by?: LauncherListQuery["sort_by"]; - order?: LauncherListQuery["order"]; - pageSize?: number; -}; - -function launcherSearchParams(filters: LauncherQueryFilters, page: number) { - const sp = new URLSearchParams(); - sp.set("page", String(page)); - sp.set("pageSize", String(filters.pageSize ?? 20)); - if (filters.query) { - sp.set("query", filters.query); - } - if (filters.groupBy) { - sp.set("groupBy", filters.groupBy); - } - if (filters.groupKey) { - sp.set("groupKey", filters.groupKey); - } - if (filters.siteIds?.length) { - sp.set("siteIds", filters.siteIds.join(",")); - } - if (filters.labelIds?.length) { - sp.set("labelIds", filters.labelIds.join(",")); - } - if (filters.sort_by) { - sp.set("sort_by", filters.sort_by); - } - if (filters.order) { - sp.set("order", filters.order); - } - return sp; -} - export const launcherQueries = { views: (orgId: string) => queryOptions({ @@ -1228,7 +1194,7 @@ export const launcherQueries = { infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, queryFn: async ({ pageParam = 1, signal, meta }) => { - const sp = launcherSearchParams(filters, pageParam); + const sp = buildLauncherSearchParams(filters, pageParam); const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); @@ -1249,7 +1215,7 @@ export const launcherQueries = { infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, queryFn: async ({ pageParam = 1, signal, meta }) => { - const sp = launcherSearchParams(filters, pageParam); + const sp = buildLauncherSearchParams(filters, pageParam); const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/resources?${sp.toString()}`, { From 9f68be2a9b588368bbdf779cd9f127cd7ea70eda Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:18:46 -0400 Subject: [PATCH 33/79] add empty state --- messages/en-US.json | 5 + .../resource-launcher/LauncherEmptyState.tsx | 118 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 36 +++++- .../resource-launcher/ResourceLauncher.tsx | 11 ++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/components/resource-launcher/LauncherEmptyState.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d27196be2..289399e5c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3573,6 +3573,11 @@ "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoResourcesInGroup": "No resources in this group", + "resourceLauncherEmptyStateTitle": "No Resources Available", + "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", + "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", + "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", + "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/src/components/resource-launcher/LauncherEmptyState.tsx b/src/components/resource-launcher/LauncherEmptyState.tsx new file mode 100644 index 000000000..193ffae78 --- /dev/null +++ b/src/components/resource-launcher/LauncherEmptyState.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { LayoutGrid, SearchX } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type LauncherEmptyStateVariant = "empty" | "noResults"; + +type LauncherEmptyStateProps = { + variant: LauncherEmptyStateVariant; + layout: "grid" | "list"; + query?: string; + onClearFilters?: () => void; +}; + +function GhostResourceGrid() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function GhostResourceList() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function LauncherEmptyState({ + variant, + layout, + query, + onClearFilters +}: LauncherEmptyStateProps) { + const t = useTranslations(); + const isNoResults = variant === "noResults"; + const Icon = isNoResults ? SearchX : LayoutGrid; + const trimmedQuery = query?.trim(); + + return ( +
+
+ {layout === "grid" ? ( + + ) : ( + + )} +
+
+
+ +
+
+

+ {isNoResults + ? t("resourceLauncherEmptyStateNoResultsTitle") + : t("resourceLauncherEmptyStateTitle")} +

+

+ {isNoResults + ? trimmedQuery + ? t( + "resourceLauncherEmptyStateNoResultsWithQuery", + { query: trimmedQuery } + ) + : t( + "resourceLauncherEmptyStateNoResultsDescription" + ) + : t("resourceLauncherEmptyStateDescription")} +

+
+ {isNoResults && onClearFilters ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index c90c3a7cc..7ca7e745d 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -10,6 +10,7 @@ import type { import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; import { LauncherGroupSection } from "./LauncherGroupSection"; type LauncherGroupListProps = { @@ -23,15 +24,25 @@ type LauncherGroupListProps = { pageSize: number; }; resourcesByGroupKey: Record; + onClearFilters?: () => void; }; +function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { + return ( + config.query.trim().length > 0 || + config.siteIds.length > 0 || + config.labelIds.length > 0 + ); +} + export function LauncherGroupList({ orgId, activeViewId, config, initialGroups, groupsPagination, - resourcesByGroupKey + resourcesByGroupKey, + onClearFilters }: LauncherGroupListProps) { const loadMoreRef = useRef(null); @@ -55,7 +66,7 @@ export function LauncherGroupList({ ] ); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery({ ...launcherQueries.groups(orgId, groupFilters), initialData: { @@ -91,6 +102,27 @@ export function LauncherGroupList({ return () => observer.disconnect(); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + if (groups.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + return (
{groups.map((group) => { diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index b09dea773..e5258d122 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -306,6 +306,16 @@ export default function ResourceLauncher({ [navigateToConfig] ); + const handleClearFilters = useCallback(() => { + setSearchInput(""); + navigateToConfig(activeViewIdRef.current, { + ...configRef.current, + query: "", + siteIds: [], + labelIds: [] + }); + }, [navigateToConfig]); + const handleSaveToCurrent = () => { if (isDefaultView) { return; @@ -448,6 +458,7 @@ export default function ResourceLauncher({ initialGroups={groups} groupsPagination={groupsPagination} resourcesByGroupKey={resourcesByGroupKey} + onClearFilters={handleClearFilters} /> From 31f675f38c8feb3d6bccd6b8fb96bf17896c1965 Mon Sep 17 00:00:00 2001 From: jaisinha77777 Date: Wed, 1 Jul 2026 07:04:54 +0530 Subject: [PATCH 34/79] Fix broken toast on private resource delete failure The delete-error handler in PrivateResourcesTable referenced two i18n keys that do not exist in the message catalog: - console.error used "resourceErrorDelete" (the catalog key is "resourceErrorDelte"), logging a raw key instead of the message. - the toast description passed "v" to formatAxiosError, so a failed delete showed the user a bogus fallback title. Point both at the existing "resourceErrorDelte" key, matching the delete handlers in PublicResourcesTable and ResourcePoliciesTable. No catalog/translation changes, so nothing changes for Crowdin. --- src/components/PrivateResourcesTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 2a4d63e85..ff854a1a8 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -186,11 +186,11 @@ export default function PrivateResourcesTable({ }); }); } catch (e) { - console.error(t("resourceErrorDelete"), e); + console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("v")) + description: formatAxiosError(e, t("resourceErrorDelte")) }); } }; From 296439fd678e64336d310009a10cadbaeba902d9 Mon Sep 17 00:00:00 2001 From: jaisinha77777 Date: Wed, 1 Jul 2026 07:17:03 +0530 Subject: [PATCH 35/79] Fix OSS build break: add siteId param to OSS listExitNodes listExitNodes is resolved via the #dynamic path alias, which maps to server/lib in the OSS build and server/private/lib in enterprise/saas. Commit 9c18936b added a 4th siteId argument to the shared caller (handleNewtPingRequestMessage) and to the enterprise implementation, but not to the OSS one, so under the OSS tsconfig the call fails: handleNewtPingRequestMessage.ts: error TS2554: Expected 1-3 arguments, but got 4. This breaks 'npx tsc --noEmit' for the OSS build (the CI 'Test with tsc' step runs it after set:oss). Add siteId?: number to the OSS signature for parity. It is unused in OSS since that build has no remote exit nodes to label-filter; accepting it keeps the two #dynamic implementations interface-compatible. --- server/lib/exitNodes/exitNodes.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/lib/exitNodes/exitNodes.ts b/server/lib/exitNodes/exitNodes.ts index fb32f4f72..f405a1114 100644 --- a/server/lib/exitNodes/exitNodes.ts +++ b/server/lib/exitNodes/exitNodes.ts @@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess( export async function listExitNodes( orgId: string, filterOnline = false, - noCloud = false + noCloud = false, + // Accepted for parity with the enterprise implementation (used there for + // site-label filtering of remote exit nodes). The OSS build has no remote + // exit nodes, so it is unused here. + siteId?: number ) { // TODO: pick which nodes to send and ping better than just all of them that are not remote const allExitNodes = await db From 376dd465b3bf8a6c545ca01ede19cc477c7644c7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:49:08 -0400 Subject: [PATCH 36/79] show no site category --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 114 +++++++++++++++++- server/routers/launcher/types.ts | 1 + .../LauncherGroupSection.tsx | 10 +- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 289399e5c..be9fe0d3d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3572,6 +3572,7 @@ "resourceLauncherDeleteView": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherEmptyStateTitle": "No Resources Available", "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 95b2b16e1..47e3529fe 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -27,6 +27,7 @@ import { countDistinct, eq, inArray, + isNull, like, or, sql @@ -38,6 +39,7 @@ import { formatSiteResourceAccess } from "./formatLauncherAccess"; import { + LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, type LauncherGroup, type LauncherLabel, @@ -508,6 +510,83 @@ async function listSiteGroups( } } + let noSiteCount = 0; + + if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) { + const noSitePublicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + noSitePublicConditions.push(searchPublic); + } + + let noSitePublicQuery = db + .select({ + itemCount: countDistinct(resources.resourceId) + }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)); + + if (labelFilterIds.length > 0) { + noSitePublicQuery = noSitePublicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + noSitePublicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const [noSitePublicRow] = await noSitePublicQuery.where( + and(...noSitePublicConditions, isNull(targets.targetId)) + ); + + noSiteCount += Number(noSitePublicRow?.itemCount ?? 0); + } + + if (accessible.siteResourceIds.length > 0 && siteFilterIds.length === 0) { + const noSiteSiteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + noSiteSiteConditions.push(searchSite); + } + + let noSiteSiteQuery = db + .select({ + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + noSiteSiteQuery = noSiteSiteQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + noSiteSiteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const [noSiteSiteRow] = await noSiteSiteQuery.where( + and(...noSiteSiteConditions, isNull(sites.siteId)) + ); + + noSiteCount += Number(noSiteSiteRow?.itemCount ?? 0); + } + let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( (row) => ({ groupKey: String(row.siteId), @@ -519,6 +598,15 @@ async function listSiteGroups( }) ); + if (noSiteCount > 0 && siteFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_NO_SITE_GROUP_KEY, + name: "No Site", + groupType: "site", + itemCount: noSiteCount + }); + } + groups.sort((a, b) => { const cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" @@ -923,6 +1011,20 @@ async function mapSiteResources( return result; } +function filterResourcesBySite( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) { + return items.filter((item) => !item.site); + } + const siteId = Number.parseInt(groupKey, 10); + if (!Number.isFinite(siteId)) { + return items; + } + return items.filter((item) => item.site?.siteId === siteId); +} + function filterResourcesByLabel( items: LauncherResource[], groupKey: string @@ -1052,10 +1154,14 @@ export async function listLauncherResourcesForUser( filteredSiteResourceIds ); - const siteIdFilter = - query.groupBy === "site" + const parsedSiteId = + query.groupBy === "site" && + query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY ? Number.parseInt(query.groupKey, 10) - : undefined; + : Number.NaN; + const siteIdFilter = Number.isFinite(parsedSiteId) + ? parsedSiteId + : undefined; const [publicItems, siteItems] = await Promise.all([ mapPublicResources( @@ -1077,6 +1183,8 @@ export async function listLauncherResourcesForUser( if (query.groupBy === "label") { items = filterResourcesByLabel(items, query.groupKey); + } else if (query.groupBy === "site") { + items = filterResourcesBySite(items, query.groupKey); } items = sortLauncherResources(items, query.order); diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 6774a1549..eba5e8530 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; +export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; export const launcherViewConfigSchema = z.object({ groupBy: z.enum(["site", "label"]).default("site"), diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 8c32d9074..7b80c4a0f 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -16,6 +16,10 @@ import type { LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; +import { + LAUNCHER_NO_SITE_GROUP_KEY, + LAUNCHER_UNLABELED_GROUP_KEY +} from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -140,9 +144,11 @@ export function LauncherGroupSection({ }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); const groupTitle = - group.groupKey === "unlabeled" + group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY ? t("resourceLauncherUnlabeled") - : group.name; + : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY + ? t("resourceLauncherNoSite") + : group.name; return ( Date: Tue, 30 Jun 2026 21:54:27 -0400 Subject: [PATCH 37/79] include exit node endpoint in tcp/udp resources --- .../routers/launcher/formatLauncherAccess.ts | 43 ++++++++++++++++--- .../launcher/launcherResourceAccess.ts | 8 +++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts index 31f625bb7..c12767ce1 100644 --- a/server/routers/launcher/formatLauncherAccess.ts +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -1,3 +1,5 @@ +import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; + export type SiteResourceDestinationInput = { mode: "host" | "cidr" | "http" | "ssh"; destination: string | null; @@ -40,6 +42,7 @@ export type PublicResourceAccessInput = { ssl: boolean; proxyPort: number | null; wildcard: boolean; + exitNodeEndpoint?: string | null; }; export type SiteResourceAccessInput = { @@ -59,12 +62,20 @@ export type LauncherAccessFields = { accessUrl: string | null; }; -export function formatPublicResourceAccess( - resource: PublicResourceAccessInput +function formatTcpUdpResourceAccess( + exitNodeEndpoint: string | null | undefined, + proxyPort: number | null ): LauncherAccessFields { - const browserModes = ["http", "ssh", "rdp", "vnc"]; - if (!browserModes.includes(resource.mode)) { - const port = resource.proxyPort?.toString() ?? ""; + if (proxyPort == null) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + if (!exitNodeEndpoint?.trim()) { + const port = proxyPort.toString(); return { accessDisplay: port, accessCopyValue: port, @@ -72,6 +83,28 @@ export function formatPublicResourceAccess( }; } + const parsed = parseEndpoint(exitNodeEndpoint); + const host = parsed?.ip ?? exitNodeEndpoint.trim(); + const access = formatEndpoint(host, proxyPort); + + return { + accessDisplay: access, + accessCopyValue: access, + accessUrl: null + }; +} + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + return formatTcpUdpResourceAccess( + resource.exitNodeEndpoint, + resource.proxyPort + ); + } + if (!resource.fullDomain) { return { accessDisplay: "", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 47e3529fe..5f4a5dd29 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -1,5 +1,6 @@ import { db } from "@server/db"; import { + exitNodes, labels, launcherViews, resourceLabels, @@ -860,11 +861,13 @@ async function mapPublicResources( siteId: sites.siteId, siteName: sites.name, siteType: sites.type, - siteOnline: sites.online + siteOnline: sites.online, + exitNodeEndpoint: exitNodes.endpoint }) .from(resources) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .where( and( inArray(resources.resourceId, resourceIds), @@ -891,7 +894,8 @@ async function mapPublicResources( fullDomain: row.fullDomain, ssl: row.ssl, proxyPort: row.proxyPort, - wildcard: row.wildcard + wildcard: row.wildcard, + exitNodeEndpoint: row.exitNodeEndpoint }); result.push({ From bc759c5c9eefd6a41a3a390494611dbc905c1a8f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 22:11:25 -0400 Subject: [PATCH 38/79] improve mobile responsiveness --- .../LauncherGroupTrigger.tsx | 2 +- .../LauncherResourceAccess.tsx | 6 +- .../LauncherResourceCard.tsx | 3 +- .../LauncherResourceList.tsx | 22 +++---- .../resource-launcher/LauncherResourceRow.tsx | 30 ++++++---- .../resource-launcher/ResourceLauncher.tsx | 60 +++++++++---------- 6 files changed, 66 insertions(+), 57 deletions(-) diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx index a43536fd5..cd4048d65 100644 --- a/src/components/resource-launcher/LauncherGroupTrigger.tsx +++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx @@ -48,7 +48,7 @@ export function LauncherGroupTrigger({ isOpen }: LauncherGroupTriggerProps) { return ( - + {group.groupType === "site" || group.groupType === "label" ? ( ) : null} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx index 729036589..81a867e17 100644 --- a/src/components/resource-launcher/LauncherResourceAccess.tsx +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -27,18 +27,18 @@ export function LauncherResourceAccess({ if (variant === "list") { return ( -
+
{canLink ? ( {accessDisplay} ) : ( - + {accessDisplay} )} diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx index 506893c29..039877393 100644 --- a/src/components/resource-launcher/LauncherResourceCard.tsx +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -29,8 +29,7 @@ export function LauncherResourceCard({ return (
- {resources.map((resource, index) => ( - - ))} +
+
+ {resources.map((resource, index) => ( + + ))} +
); } diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index 21dd763fe..b3cbde724 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -36,9 +36,8 @@ export function LauncherResourceRow({ return (
- +
+ - - {resource.name} - + + {resource.name} + +
{hasTags ? ( -
+
{showSiteTags && resource.site ? (
-
-
-
- - { - const value = event.target.value; - setSearchInput(value); - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> -
-
+
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedNavigateSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> +
From 561f75b6b1df7ad6ac0d486c7ff031bbaac90d30 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 10:03:06 -0400 Subject: [PATCH 39/79] improve responsiveness --- .../LauncherGroupSection.tsx | 1 - .../LauncherGroupTrigger.tsx | 2 +- .../LauncherResourceList.tsx | 5 +- .../resource-launcher/LauncherResourceRow.tsx | 52 +++++-------------- .../LauncherSettingsMenu.tsx | 15 ------ .../resource-launcher/ResourceLauncher.tsx | 4 +- src/lib/launcherUrlState.ts | 14 ----- 7 files changed, 18 insertions(+), 75 deletions(-) diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 7b80c4a0f..2d1b483dd 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -180,7 +180,6 @@ export function LauncherGroupSection({ )}
+ {group.groupType === "site" || group.groupType === "label" ? ( ) : null} diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index 781d361c8..d95b58adf 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,13 +6,11 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; - showSiteTags: boolean; }; export function LauncherResourceList({ resources, - showLabels, - showSiteTags + showLabels }: LauncherResourceListProps) { return (
@@ -22,7 +20,6 @@ export function LauncherResourceList({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} - showSiteTags={showSiteTags} isLast={index === resources.length - 1} /> ))} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index b3cbde724..d94823b27 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -1,6 +1,5 @@ "use client"; -import { LabelBadge } from "@app/components/label-badge"; import { cn } from "@app/lib/cn"; import type { LauncherResource } from "@server/routers/launcher/types"; import { LauncherLabelsRow } from "./LauncherLabelsRow"; @@ -14,19 +13,15 @@ import { type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; - showSiteTags: boolean; isLast?: boolean; }; export function LauncherResourceRow({ resource, showLabels, - showSiteTags, isLast = false }: LauncherResourceRowProps) { - const hasTags = - (showSiteTags && resource.site) || - (showLabels && resource.labels.length > 0); + const hasTags = showLabels && resource.labels.length > 0; const { handleAction, isClickable } = useLauncherResourceAction({ accessUrl: resource.accessUrl, accessCopyValue: resource.accessCopyValue @@ -45,24 +40,15 @@ export function LauncherResourceRow({ role={clickProps.role} tabIndex={clickProps.tabIndex} > -
- + - - {resource.name} - -
+ + {resource.name} + - {showSiteTags && resource.site ? ( - - ) : null} - {showLabels ? ( - - ) : null} +
) : null}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 086df395d..41143cd10 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -115,21 +115,6 @@ export function LauncherSettingsMenu({ } />
-
- - - onConfigChange({ showSiteTags: checked }) - } - /> -
{!isDefaultView ? ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 435388ece..46a92cc03 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -372,7 +372,7 @@ export default function ResourceLauncher({
-
+
-
+
0) { params.set("siteIds", config.siteIds.join(",")); From 3c37e10638ebf4fa052396b3b78fd80d9b5b47a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 10:11:17 -0400 Subject: [PATCH 40/79] add mobile overflow menu --- src/components/Layout.tsx | 2 + src/components/LayoutMobileMenu.tsx | 148 ++++++++++++++---- .../resource-launcher/ResourceLauncher.tsx | 60 +++---- 3 files changed, 149 insertions(+), 61 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5657366f8..32d4d620c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -68,6 +68,8 @@ export async function Layout({ navItems={navItems} showSidebar={showSidebar} showTopBar={showTopBar} + launcherMode={launcherMode} + showViewAsAdmin={showViewAsAdmin} /> )} diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..b549d1f2e 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, Menu, Server } from "lucide-react"; +import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -29,6 +29,8 @@ interface LayoutMobileMenuProps { navItems: SidebarNavSection[]; showSidebar: boolean; showTopBar: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export function LayoutMobileMenu({ @@ -36,19 +38,33 @@ export function LayoutMobileMenu({ orgs, navItems, showSidebar, - showTopBar + showTopBar, + launcherMode = false, + showViewAsAdmin = false }: LayoutMobileMenuProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const pathname = usePathname(); const isAdminPage = pathname?.startsWith("/admin"); const { user } = useUserContext(); const t = useTranslations(); + const showMobileNav = showSidebar || launcherMode; + const currentOrg = orgs?.find((org) => org.orgId === orgId); + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + + const mobileNavLinkClassName = cn( + "flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5" + ); return (
- {showSidebar && ( + {showMobileNav && (
{t("navbarDescription")} -
-
- -
-
-
-
- {!isAdminPage && - user.serverAdmin && ( + {launcherMode ? ( + <> +
+
+ +
+
+ {showViewAsAdmin && orgId ? ( +
setIsMobileMenuOpen( false @@ -94,25 +110,95 @@ export function LayoutMobileMenu({ } > - + {t( - "serverAdmin" + "resourceLauncherViewAsAdmin" )}
- )} - - setIsMobileMenuOpen(false) - } - /> -
-
-
+
+ ) : null} + + ) : ( + <> +
+
+ +
+
+
+
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "resourceLauncherTitle" + )} + + +
+ )} + {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "serverAdmin" + )} + + +
+ )} + + setIsMobileMenuOpen( + false + ) + } + /> +
+
+
+ + )}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 46a92cc03..62e22f6a0 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -371,8 +371,36 @@ export default function ResourceLauncher({ />
-
-
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedNavigateSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> +
+
-
-
- - { - const value = event.target.value; - setSearchInput(value); - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> -
From e3ef5927785a37f9c3199ea8ea1019f90d4a8d40 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 10:37:02 -0400 Subject: [PATCH 41/79] Adjust language --- messages/en-US.json | 2 +- .../[remoteExitNodeId]/layout.tsx | 8 +++--- .../[remoteExitNodeId]/networking/page.tsx | 26 ++++++++----------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 993c2fb45..5ba319dca 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2389,7 +2389,7 @@ "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secret", "remoteExitNodeNetworkingTitle": "Network Settings", - "remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it.", + "remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it. Advanced features to be used with backhaul networking configurations.", "remoteExitNodeNetworkingSave": "Save Settings", "remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved", "remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.", diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index cac55907f..b58403050 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -34,13 +34,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); const navItems = [ - { - title: "Networking", - href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking" - }, { title: t("credentials"), href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" + }, + { + title: "Networking", + href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/networking" } ]; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index 92eba552b..a74f010a6 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -143,9 +143,7 @@ export default function NetworkingPage() { setSaving(true); try { await Promise.all([ - api.post< - AxiosResponse - >( + api.post>( `/org/${orgId}/remote-exit-node/${remoteExitNode.remoteExitNodeId}/resources`, { destinations: subnets.map((s) => s.text) } ), @@ -158,9 +156,7 @@ export default function NetworkingPage() { ]); toast({ title: t("remoteExitNodeNetworkingSaveSuccessTitle"), - description: t( - "remoteExitNodeNetworkingSaveSuccessDescription" - ) + description: t("remoteExitNodeNetworkingSaveSuccessDescription") }); } catch (error) { toast({ @@ -184,6 +180,15 @@ export default function NetworkingPage() { {t("remoteExitNodeNetworkingDescription")} + + {t("learnMore")} + + @@ -221,15 +226,6 @@ export default function NetworkingPage() { ) } )}{" "} - - {t("learnMore")} - -

From 108cb6216c85c928403692ee38c9ea086af1cf3d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 10:37:17 -0400 Subject: [PATCH 42/79] Add migration to fix policy delete issues Ref #3257 --- server/routers/site/restartSite.ts | 6 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsSqlite/1.19.5.ts | 106 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 server/setup/scriptsSqlite/1.19.5.ts diff --git a/server/routers/site/restartSite.ts b/server/routers/site/restartSite.ts index 705b65db8..f60db77d3 100644 --- a/server/routers/site/restartSite.ts +++ b/server/routers/site/restartSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { sendToClient } from "../ws"; +import { canCompress } from "@server/lib/clientVersionChecks"; const updateSiteParamsSchema = z.strictObject({ siteId: z.coerce.number().int().positive() @@ -97,7 +98,10 @@ export async function restartSite( type: "newt/wg/restart", data: {} }, - { incrementConfigVersion: false, compress: false } + { + incrementConfigVersion: false, + compress: canCompress(newt.version, "newt") + } ); return response(res, { diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index bc68ba3aa..8662678d2 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -45,6 +45,7 @@ import m39 from "./scriptsSqlite/1.18.3"; import m40 from "./scriptsSqlite/1.18.4"; import m41 from "./scriptsSqlite/1.19.0"; import m42 from "./scriptsSqlite/1.19.1"; +import m43 from "./scriptsSqlite/1.19.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -87,7 +88,8 @@ const migrations = [ { version: "1.18.3", run: m39 }, { version: "1.18.4", run: m40 }, { version: "1.19.0", run: m41 }, - { version: "1.19.1", run: m42 } + { version: "1.19.1", run: m42 }, + { version: "1.19.5", run: m43 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsSqlite/1.19.5.ts b/server/setup/scriptsSqlite/1.19.5.ts new file mode 100644 index 000000000..b9fe43ecf --- /dev/null +++ b/server/setup/scriptsSqlite/1.19.5.ts @@ -0,0 +1,106 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.19.5"; + +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); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + // The 1.19.0 migration added policyPasswordId/policyPincodeId/policyWhitelistId to + // resourceSessions via inline REFERENCES clauses with no ON DELETE action, unlike the + // Postgres migration which correctly used "ON DELETE cascade". SQLite can't alter an + // existing foreign key, so rebuild the table to match resourceSessions in schema.ts. + db.prepare( + ` + CREATE TABLE 'resourceSessions_new' ( + 'id' text PRIMARY KEY NOT NULL, + 'resourceId' integer NOT NULL, + 'expiresAt' integer NOT NULL, + 'sessionLength' integer NOT NULL, + 'doNotExtend' integer DEFAULT false NOT NULL, + 'isRequestToken' integer, + 'userSessionId' text, + 'passwordId' integer, + 'pincodeId' integer, + 'whitelistId' integer, + 'accessTokenId' text, + 'policyPasswordId' integer, + 'policyPincodeId' integer, + 'policyWhitelistId' integer, + 'issuedAt' integer, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userSessionId') REFERENCES 'session'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('passwordId') REFERENCES 'resourcePassword'('passwordId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('pincodeId') REFERENCES 'resourcePincode'('pincodeId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('whitelistId') REFERENCES 'resourceWhitelist'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('accessTokenId') REFERENCES 'resourceAccessToken'('accessTokenId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyPasswordId') REFERENCES 'resourcePolicyPassword'('passwordId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyPincodeId') REFERENCES 'resourcePolicyPincode'('pincodeId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('policyWhitelistId') REFERENCES 'resourcePolicyWhitelist'('id') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + ` + INSERT INTO 'resourceSessions_new' ( + "id", + "resourceId", + "expiresAt", + "sessionLength", + "doNotExtend", + "isRequestToken", + "userSessionId", + "passwordId", + "pincodeId", + "whitelistId", + "accessTokenId", + "policyPasswordId", + "policyPincodeId", + "policyWhitelistId", + "issuedAt" + ) + SELECT + "id", + "resourceId", + "expiresAt", + "sessionLength", + "doNotExtend", + "isRequestToken", + "userSessionId", + "passwordId", + "pincodeId", + "whitelistId", + "accessTokenId", + "policyPasswordId", + "policyPincodeId", + "policyWhitelistId", + "issuedAt" + FROM 'resourceSessions'; + ` + ).run(); + + db.prepare(`DROP TABLE 'resourceSessions';`).run(); + db.prepare( + `ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} From 22dd4220feddfb2d83a866128628548354deb207 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:09:26 -0400 Subject: [PATCH 43/79] fix ordering of buttons --- src/components/resource-launcher/ResourceLauncher.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 62e22f6a0..d4098e106 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -371,8 +371,8 @@ export default function ResourceLauncher({ />
-
-
+
+
-
+
Date: Wed, 1 Jul 2026 11:26:14 -0400 Subject: [PATCH 44/79] standardize permissions in api --- messages/en-US.json | 1 + server/auth/actions.ts | 3 +- server/routers/integration.ts | 36 ---------- server/routers/launcher/createLauncherView.ts | 33 +++------- server/routers/launcher/deleteLauncherView.ts | 27 +++----- .../launcher/launcherResourceAccess.ts | 66 +------------------ server/routers/launcher/listLauncherGroups.ts | 12 +--- .../routers/launcher/listLauncherResources.ts | 12 +--- server/routers/launcher/listLauncherViews.ts | 14 +--- server/routers/launcher/updateLauncherView.ts | 39 +++++------ 10 files changed, 45 insertions(+), 198 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index be9fe0d3d..3663c8c59 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1401,6 +1401,7 @@ "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", + "actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71fb33156..093013e6d 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -178,7 +178,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + createOrgWideLauncherView = "createOrgWideLauncherView" } export async function checkUserActionPermission( diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 0bbe12163..d124263d5 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,7 +13,6 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; -import * as launcher from "./launcher"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -161,41 +160,6 @@ authenticated.get( resource.getUserResources ); -authenticated.get( - "/org/:orgId/launcher/groups", - verifyApiKeyOrgAccess, - launcher.listLauncherGroups -); - -authenticated.get( - "/org/:orgId/launcher/resources", - verifyApiKeyOrgAccess, - launcher.listLauncherResources -); - -authenticated.get( - "/org/:orgId/launcher/views", - verifyApiKeyOrgAccess, - launcher.listLauncherViews -); - -authenticated.post( - "/org/:orgId/launcher/views", - verifyApiKeyOrgAccess, - launcher.createLauncherView -); - -authenticated.put( - "/org/:orgId/launcher/views/:viewId", - verifyApiKeyOrgAccess, - launcher.updateLauncherView -); - -authenticated.delete( - "/org/:orgId/launcher/views/:viewId", - verifyApiKeyOrgAccess, - launcher.deleteLauncherView -); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index fb7ff2630..593bad304 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -1,17 +1,12 @@ import { db, launcherViews } from "@server/db"; import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -26,14 +21,8 @@ export async function createLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -51,22 +40,16 @@ export async function createLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - if (parsed.data.orgWide) { - const canManageOrgWide = await isOrgAdminOrOwner( - orgId, - userId, - userRoleIds + const canCreateOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req ); - if (!canManageOrgWide) { + if (!canCreateOrgWide) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can create org-wide views" + "User does not have permission perform this action" ) ); } diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index c68c6530c..beaf1b433 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -5,10 +5,7 @@ import HttpCode from "@server/types/HttpCode"; import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; export async function deleteLauncherView( req: Request, @@ -16,18 +13,12 @@ export async function deleteLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); + const orgId = req.userOrgId; + const userId = req.user!.userId; const viewId = Number.parseInt( getFirstString(req.params.viewId) ?? "", 10 ); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } if (!orgId || !Number.isFinite(viewId)) { return next( @@ -38,11 +29,6 @@ export async function deleteLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - const [existing] = await db .select() .from(launcherViews) @@ -62,9 +48,12 @@ export async function deleteLauncherView( const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; - const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); - if (!isPersonalView && !(isOrgWideView && isAdmin)) { + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 5f4a5dd29..3083c0672 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -15,7 +15,6 @@ import { sites, targets, userOrgRoles, - userOrgs, userPolicies, userResources, userSiteResources @@ -33,8 +32,6 @@ import { or, sql } from "drizzle-orm"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; import { formatPublicResourceAccess, formatSiteResourceAccess @@ -58,65 +55,6 @@ export type AccessibleIds = { siteResourceIds: number[]; }; -export async function verifyLauncherOrgMembership( - orgId: string, - userId: string -): Promise<{ userRoleIds: number[] }> { - const [userOrg] = await db - .select() - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .limit(1); - - if (!userOrg) { - throw createHttpError(HttpCode.FORBIDDEN, "User not in organization"); - } - - const userRoleIds = await db - .select({ roleId: userOrgRoles.roleId }) - .from(userOrgRoles) - .where( - and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) - ) - .then((rows) => rows.map((r) => r.roleId)); - - return { userRoleIds }; -} - -export async function isOrgAdminOrOwner( - orgId: string, - userId: string, - userRoleIds: number[] -): Promise { - const [membership] = await db - .select({ isOwner: userOrgs.isOwner }) - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .limit(1); - - if (membership?.isOwner) { - return true; - } - - if (userRoleIds.length === 0) { - return false; - } - - const adminRoles = await db - .select({ roleId: roles.roleId }) - .from(roles) - .where( - and( - eq(roles.orgId, orgId), - eq(roles.isAdmin, true), - inArray(roles.roleId, userRoleIds) - ) - ) - .limit(1); - - return adminRoles.length > 0; -} - export async function resolveAccessibleIds( orgId: string, userId: string, @@ -826,9 +764,9 @@ async function listLabelGroups( export async function listLauncherGroupsForUser( orgId: string, userId: string, + userRoleIds: number[], query: LauncherListQuery ): Promise<{ groups: LauncherGroup[]; total: number }> { - const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); if (query.groupBy === "label") { @@ -1077,9 +1015,9 @@ function sortLauncherResources( export async function listLauncherResourcesForUser( orgId: string, userId: string, + userRoleIds: number[], query: LauncherListQuery & { groupKey: string } ): Promise<{ resources: LauncherResource[]; total: number }> { - const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const siteFilterIds = parseIdListParam(query.siteIds); diff --git a/server/routers/launcher/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts index 69ad1a10d..a57e5864a 100644 --- a/server/routers/launcher/listLauncherGroups.ts +++ b/server/routers/launcher/listLauncherGroups.ts @@ -1,5 +1,4 @@ import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; @@ -13,14 +12,8 @@ export async function listLauncherGroups( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -41,6 +34,7 @@ export async function listLauncherGroups( const { groups, total } = await listLauncherGroupsForUser( orgId, userId, + req.userOrgRoleIds ?? [], parsed.data ); diff --git a/server/routers/launcher/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts index a4b2be993..94f18b864 100644 --- a/server/routers/launcher/listLauncherResources.ts +++ b/server/routers/launcher/listLauncherResources.ts @@ -1,5 +1,4 @@ import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; @@ -18,14 +17,8 @@ export async function listLauncherResources( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -46,6 +39,7 @@ export async function listLauncherResources( const { resources, total } = await listLauncherResourcesForUser( orgId, userId, + req.userOrgRoleIds ?? [], parsed.data ); diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index 019dbd2da..e176bae46 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -1,12 +1,10 @@ import { db, launcherViews } from "@server/db"; import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; import { and, eq, isNull, or } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; -import { verifyLauncherOrgMembership } from "./launcherResourceAccess"; function mapViewRow( row: typeof launcherViews.$inferSelect @@ -29,14 +27,8 @@ export async function listLauncherViews( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -44,8 +36,6 @@ export async function listLauncherViews( ); } - await verifyLauncherOrgMembership(orgId, userId); - const rows = await db .select() .from(launcherViews) diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 2db865952..9450da153 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -8,10 +8,7 @@ import createHttpError from "http-errors"; import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -26,18 +23,12 @@ export async function updateLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); + const orgId = req.userOrgId; + const userId = req.user!.userId; const viewId = Number.parseInt( getFirstString(req.params.viewId) ?? "", 10 ); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } if (!orgId || !Number.isFinite(viewId)) { return next( @@ -58,11 +49,6 @@ export async function updateLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - const [existing] = await db .select() .from(launcherViews) @@ -82,9 +68,12 @@ export async function updateLauncherView( const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; - const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); - if (!isPersonalView && !(isOrgWideView && isAdmin)) { + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -93,20 +82,24 @@ export async function updateLauncherView( ); } - if (parsed.data.orgWide === true && !isAdmin) { + if (parsed.data.orgWide === true && !canManageOrgWide) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can make views org-wide" + "User does not have permission perform this action" ) ); } - if (parsed.data.orgWide === false && isOrgWideView && !isAdmin) { + if ( + parsed.data.orgWide === false && + isOrgWideView && + !canManageOrgWide + ) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can change org-wide views" + "User does not have permission perform this action" ) ); } From 97cdb2eb5acca95860218405f608e80d3b41ad40 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:46:20 -0400 Subject: [PATCH 45/79] use proper label filter selector --- src/components/LabelColumnFilterButton.tsx | 79 +++------------ src/components/LabelsFilterSelector.tsx | 98 +++++++++++++++++++ .../LauncherFilterPopover.tsx | 63 +++++++++--- 3 files changed, 164 insertions(+), 76 deletions(-) create mode 100644 src/components/LabelsFilterSelector.tsx diff --git a/src/components/LabelColumnFilterButton.tsx b/src/components/LabelColumnFilterButton.tsx index ed6a1f744..9cab6c72a 100644 --- a/src/components/LabelColumnFilterButton.tsx +++ b/src/components/LabelColumnFilterButton.tsx @@ -1,14 +1,6 @@ "use client"; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; import { Popover, PopoverContent, @@ -16,16 +8,15 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; -import { CheckIcon, Funnel } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { useDebounce } from "use-debounce"; +import { Funnel } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import { LabelBadge } from "./label-badge"; import { LabelOverflowBadge } from "./label-overflow-badge"; +import { LabelsFilterSelector } from "./LabelsFilterSelector"; import { LABEL_COLORS } from "./labels-selector"; -import { Checkbox } from "./ui/checkbox"; function areSelectionsEqual(a: string[], b: string[]) { if (a.length !== b.length) { @@ -54,13 +45,9 @@ export function LabelColumnFilterButton({ const [draftValues, setDraftValues] = useState(selectedValues); const t = useTranslations(); - const [labelSearchQuery, setlabelsSearchQuery] = useState(""); - const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( orgQueries.labels({ orgId, - query: debouncedQuery, perPage: 500 }) ); @@ -152,53 +139,17 @@ export function LabelColumnFilterButton({ className={dataTableFilterPopoverContentClassName} align="start" > - - - - {t("labelsNotFound")} - - {draftValues.length > 0 && ( - { - setDraftValues([]); - }} - className="text-muted-foreground" - > - {t("accessFilterClear")} - - )} - {labels.map((label) => ( - { - toggle(label.name); - }} - className="flex items-center gap-2" - > - -
- {label.name} - - ))} - - - + draftSet.has(label.name)} + onToggle={(label) => { + toggle(label.name); + }} + showClear={draftValues.length > 0} + onClear={() => { + setDraftValues([]); + }} + />
diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx new file mode 100644 index 000000000..610bbd224 --- /dev/null +++ b/src/components/LabelsFilterSelector.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useDebounce } from "use-debounce"; +import { Checkbox } from "./ui/checkbox"; + +export type LabelFilterOption = { + labelId: number; + name: string; + color: string; +}; + +type LabelsFilterSelectorProps = { + orgId: string; + isSelected: (label: LabelFilterOption) => boolean; + onToggle: (label: LabelFilterOption) => void; + onClear?: () => void; + showClear?: boolean; +}; + +export function LabelsFilterSelector({ + orgId, + isSelected, + onToggle, + onClear, + showClear = false +}: LabelsFilterSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }) + ); + + return ( + + + + {t("labelsNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {labels.map((label) => ( + { + onToggle(label); + }} + className="flex items-center gap-2" + > + +
+ {label.name} + + ))} + + + + ); +} diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 36d6a3baa..bcf072086 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -6,9 +6,10 @@ import { } from "@app/components/multi-site-selector"; import { formatLabelsSelectorLabel, - LabelsSelector, + LABEL_COLORS, type SelectedLabel } from "@app/components/labels-selector"; +import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; import { Button } from "@app/components/ui/button"; import { Popover, @@ -16,9 +17,11 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { Selectedsite } from "@app/components/site-selector"; type LauncherFilterPopoverProps = { @@ -40,6 +43,34 @@ export function LauncherFilterPopover({ const [sitesOpen, setSitesOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + perPage: 500 + }) + ); + + const selectedLabelIds = useMemo( + () => new Set(selectedLabels.map((label) => label.labelId)), + [selectedLabels] + ); + + const resolvedSelectedLabels: SelectedLabel[] = useMemo( + () => + selectedLabels.map((selected) => { + const found = labels.find( + (label) => label.labelId === selected.labelId + ); + return ( + found ?? { + ...selected, + color: selected.color || LABEL_COLORS.gray + } + ); + }), + [labels, selectedLabels] + ); + return ( @@ -101,7 +132,7 @@ export function LauncherFilterPopover({ > {formatLabelsSelectorLabel( - selectedLabels, + resolvedSelectedLabels, t )} @@ -112,16 +143,15 @@ export function LauncherFilterPopover({ className="w-[var(--radix-popover-trigger-width)] p-0" align="start" > - { - if (action === "attach") { - onLabelsChange([ - ...selectedLabels, - label - ]); - } else { + isSelected={(label) => + selectedLabelIds.has(label.labelId) + } + onToggle={(label) => { + if ( + selectedLabelIds.has(label.labelId) + ) { onLabelsChange( selectedLabels.filter( (item) => @@ -129,8 +159,17 @@ export function LauncherFilterPopover({ label.labelId ) ); + } else { + onLabelsChange([ + ...selectedLabels, + label + ]); } }} + showClear={selectedLabels.length > 0} + onClear={() => { + onLabelsChange([]); + }} /> From 75f481bc3de0a36a7d7efe11436710783535e581 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:53:11 -0400 Subject: [PATCH 46/79] improve search bar debounce behavior --- .../resource-launcher/ResourceLauncher.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index d4098e106..979d67b54 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -85,22 +85,17 @@ export default function ResourceLauncher({ const { navigate, isNavigating, searchParams } = useNavigationContext(); const hasRestoredLastView = useRef(false); - const [searchInput, setSearchInput] = useState(config.query); + const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); const configRef = useRef(config); configRef.current = config; - const searchInputRef = useRef(searchInput); - searchInputRef.current = searchInput; + const searchInputRef = useRef(config.query); const activeViewIdRef = useRef(activeViewId); activeViewIdRef.current = activeViewId; - useEffect(() => { - setSearchInput(config.query); - }, [config.query]); - useEffect(() => { if (hasRestoredLastView.current) { return; @@ -307,7 +302,8 @@ export default function ResourceLauncher({ ); const handleClearFilters = useCallback(() => { - setSearchInput(""); + searchInputRef.current = ""; + setSearchInputResetKey((key) => key + 1); navigateToConfig(activeViewIdRef.current, { ...configRef.current, query: "", @@ -376,10 +372,11 @@ export default function ResourceLauncher({
{ - const value = event.target.value; - setSearchInput(value); + const value = event.currentTarget.value; + searchInputRef.current = value; debouncedNavigateSearch( activeViewIdRef.current, value @@ -389,6 +386,7 @@ export default function ResourceLauncher({ "resourceLauncherSearchPlaceholder" )} className="pl-8" + type="search" />
Date: Wed, 1 Jul 2026 12:17:36 -0400 Subject: [PATCH 47/79] show sites and labels for non admins --- server/routers/external.ts | 12 + server/routers/launcher/index.ts | 2 + .../launcher/launcherResourceAccess.ts | 260 ++++++++++++++++++ server/routers/launcher/listLauncherLabels.ts | 67 +++++ server/routers/launcher/listLauncherSites.ts | 67 +++++ server/routers/launcher/types.ts | 34 +++ src/components/LabelsFilterSelector.tsx | 27 +- src/components/multi-site-selector.tsx | 27 +- .../LauncherFilterPopover.tsx | 35 ++- src/lib/queries.ts | 68 +++++ 10 files changed, 583 insertions(+), 16 deletions(-) create mode 100644 server/routers/launcher/listLauncherLabels.ts create mode 100644 server/routers/launcher/listLauncherSites.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index b4ca30cff..2ff5d6c4f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -468,6 +468,18 @@ authenticated.get( launcher.listLauncherResources ); +authenticated.get( + "/org/:orgId/launcher/sites", + verifyOrgAccess, + launcher.listLauncherSites +); + +authenticated.get( + "/org/:orgId/launcher/labels", + verifyOrgAccess, + launcher.listLauncherLabels +); + authenticated.get( "/org/:orgId/launcher/views", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index f23b266ea..939bf74bc 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -1,6 +1,8 @@ export * from "./types"; export { listLauncherGroups } from "./listLauncherGroups"; export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherSites } from "./listLauncherSites"; +export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 3083c0672..85ca24a23 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -39,10 +39,12 @@ import { import { LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherFilterListQuery, type LauncherGroup, type LauncherLabel, type LauncherListQuery, type LauncherResource, + type LauncherSiteInfo, parseIdListParam } from "./types"; @@ -1138,3 +1140,261 @@ export async function listLauncherResourcesForUser( total }; } + +function buildSiteNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return or( + like(sql`LOWER(${sites.name})`, pattern), + like(sql`LOWER(${sites.niceId})`, pattern) + ); +} + +function buildLabelNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return like(sql`LOWER(${labels.name})`, pattern); +} + +async function collectAccessibleSites( + orgId: string, + accessible: AccessibleIds, + siteNameSearch?: ReturnType +): Promise> { + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (siteNameSearch) { + publicConditions.push(siteNameSearch); + } + + const publicRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (siteNameSearch) { + siteConditions.push(siteNameSearch); + } + + const siteRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + return siteCountMap; +} + +async function collectAccessibleLabels( + orgId: string, + accessible: AccessibleIds, + labelNameSearch?: ReturnType +): Promise> { + const labelMap = new Map(); + + if (!(await labelsEnabled(orgId))) { + return labelMap; + } + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + publicConditions.push(labelNameSearch); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .where(and(...publicConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + siteConditions.push(labelNameSearch); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .where(and(...siteConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + return labelMap; +} + +export async function listAccessibleLauncherSitesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ sites: LauncherSiteInfo[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const siteNameSearch = buildSiteNameSearchCondition(query.query); + const siteCountMap = await collectAccessibleSites( + orgId, + accessible, + siteNameSearch + ); + + const sites: LauncherSiteInfo[] = Array.from(siteCountMap.values()) + .map((row) => ({ + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = sites.length; + const offset = (query.page - 1) * query.pageSize; + return { + sites: sites.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listAccessibleLauncherLabelsForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ labels: LauncherLabel[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const labelNameSearch = buildLabelNameSearchCondition(query.query); + const labelMap = await collectAccessibleLabels( + orgId, + accessible, + labelNameSearch + ); + + const labelsList = Array.from(labelMap.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = labelsList.length; + const offset = (query.page - 1) * query.pageSize; + return { + labels: labelsList.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherLabels.ts b/server/routers/launcher/listLauncherLabels.ts new file mode 100644 index 000000000..4bb25c965 --- /dev/null +++ b/server/routers/launcher/listLauncherLabels.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherLabels( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { labels, total } = await listAccessibleLauncherLabelsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + labels, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher labels:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherSites.ts b/server/routers/launcher/listLauncherSites.ts new file mode 100644 index 000000000..8755f6d5a --- /dev/null +++ b/server/routers/launcher/listLauncherSites.ts @@ -0,0 +1,67 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherSites( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { sites, total } = await listAccessibleLauncherSitesForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + sites, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher sites retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher sites:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index eba5e8530..9d08271ae 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -93,6 +93,40 @@ export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; }; +export const launcherFilterListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(500) + .default(500), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default("") +}); + +export type LauncherFilterListQuery = z.infer< + typeof launcherFilterListQuerySchema +>; + +export type ListLauncherSitesResponse = { + sites: LauncherSiteInfo[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherLabelsResponse = { + labels: LauncherLabel[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + export const launcherListQuerySchema = z.strictObject({ pageSize: z.coerce .number() diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx index 610bbd224..3c9410e43 100644 --- a/src/components/LabelsFilterSelector.tsx +++ b/src/components/LabelsFilterSelector.tsx @@ -8,7 +8,7 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -27,6 +27,7 @@ type LabelsFilterSelectorProps = { onToggle: (label: LabelFilterOption) => void; onClear?: () => void; showClear?: boolean; + scope?: "org" | "launcher"; }; export function LabelsFilterSelector({ @@ -34,19 +35,33 @@ export function LabelsFilterSelector({ isSelected, onToggle, onClear, - showClear = false + showClear = false, + scope = "org" }: LabelsFilterSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( - orgQueries.labels({ + const orgLabelsQuery = useQuery({ + ...orgQueries.labels({ orgId, query: debouncedQuery, perPage: 500 - }) - ); + }), + enabled: scope === "org" + }); + const launcherLabelsQuery = useQuery({ + ...launcherQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const labels = + scope === "launcher" + ? (launcherLabelsQuery.data ?? []) + : (orgLabelsQuery.data ?? []); return ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index acb8b7dd9..fe81dc697 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -1,4 +1,4 @@ -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { @@ -19,6 +19,7 @@ export type MultiSitesSelectorProps = { selectedSites: Selectedsite[]; onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; + scope?: "org" | "launcher"; }; export function formatMultiSitesSelectorLabel( @@ -40,19 +41,33 @@ export function MultiSitesSelector({ orgId, selectedSites, onSelectionChange, - filterTypes + filterTypes, + scope = "org" }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(siteSearchQuery, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ + const orgSitesQuery = useQuery({ + ...orgQueries.sites({ orgId, query: debouncedQuery, perPage: 10 - }) - ); + }), + enabled: scope === "org" + }); + const launcherSitesQuery = useQuery({ + ...launcherQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const sites = + scope === "launcher" + ? (launcherSitesQuery.data ?? []) + : (orgSitesQuery.data ?? []); const sitesShown = useMemo(() => { const base = filterTypes diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index bcf072086..5a0e425f9 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -17,7 +17,7 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; @@ -44,12 +44,37 @@ export function LauncherFilterPopover({ const [labelsOpen, setLabelsOpen] = useState(false); const { data: labels = [] } = useQuery( - orgQueries.labels({ + launcherQueries.labels({ orgId, perPage: 500 }) ); + const { data: sites = [] } = useQuery( + launcherQueries.sites({ + orgId, + perPage: 500 + }) + ); + + const resolvedSelectedSites: Selectedsite[] = useMemo( + () => + selectedSites.map((selected) => { + const found = sites.find( + (site) => site.siteId === selected.siteId + ); + return found + ? { + siteId: found.siteId, + name: found.name, + type: found.type, + online: found.online + } + : selected; + }), + [sites, selectedSites] + ); + const selectedLabelIds = useMemo( () => new Set(selectedLabels.map((label) => label.labelId)), [selectedLabels] @@ -98,7 +123,7 @@ export function LauncherFilterPopover({ > {formatMultiSitesSelectorLabel( - selectedSites, + resolvedSelectedSites, t )} @@ -111,8 +136,9 @@ export function LauncherFilterPopover({ > @@ -145,6 +171,7 @@ export function LauncherFilterPopover({ > selectedLabelIds.has(label.labelId) } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c5d613942..14fa1d3da 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -48,7 +48,9 @@ import type { ListResourcePoliciesResponse } from "@server/routers/resource/type import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { ListLauncherGroupsResponse, + ListLauncherLabelsResponse, ListLauncherResourcesResponse, + ListLauncherSitesResponse, ListLauncherViewsResponse, LauncherListQuery, LauncherViewConfig @@ -1190,6 +1192,72 @@ export const launcherQueries = { return res.data.data.views; } }), + sites: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "SITES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal }); + return res.data.data.sites; + } + }), + labels: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "LABELS", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/labels?${sp.toString()}`, { + signal + }); + return res.data.data.labels; + } + }), groups: (orgId: string, filters: LauncherQueryFilters) => infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, From 5a7ca5b542f873b76c84fc1aa0516e9d93722c54 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 12:38:45 -0400 Subject: [PATCH 48/79] add refresh button --- messages/en-US.json | 1 + .../LauncherRefreshButton.tsx | 31 +++++++++++++++ .../resource-launcher/LauncherViewTabs.tsx | 15 +++++++- .../resource-launcher/ResourceLauncher.tsx | 38 ++++++++++++++++++- 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/components/resource-launcher/LauncherRefreshButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3663c8c59..2c938639a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3551,6 +3551,7 @@ "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherResetView": "Reset View", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx new file mode 100644 index 000000000..854128736 --- /dev/null +++ b/src/components/resource-launcher/LauncherRefreshButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { RefreshCw } from "lucide-react"; + +type LauncherRefreshButtonProps = { + onRefresh: () => void; + isRefreshing: boolean; +}; + +export function LauncherRefreshButton({ + onRefresh, + isRefreshing +}: LauncherRefreshButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx index 474d04e67..e23aaf0b4 100644 --- a/src/components/resource-launcher/LauncherViewTabs.tsx +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -5,6 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { useTranslations } from "next-intl"; @@ -71,6 +72,7 @@ type LauncherSaveViewMenuProps = { onSaveAsNew: () => void; onSaveForEveryone: () => void; onMakePersonal: () => void; + onResetView: () => void; }; export function LauncherSaveViewMenu({ @@ -81,7 +83,8 @@ export function LauncherSaveViewMenu({ onSaveToCurrent, onSaveAsNew, onSaveForEveryone, - onMakePersonal + onMakePersonal, + onResetView }: LauncherSaveViewMenuProps) { const t = useTranslations(); @@ -97,7 +100,15 @@ export function LauncherSaveViewMenu({ - {!isDefaultView ? ( + {hasUnsavedChanges ? ( + <> + + {t("resourceLauncherResetView")} + + + + ) : null} + {!isDefaultView && (isAdmin || !isOrgWideView) ? ( {t("resourceLauncherSaveToCurrentView")} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 979d67b54..7b0628d07 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -39,12 +39,20 @@ import { useMutation } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; import type { SelectedLabel } from "@app/components/labels-selector"; import { LauncherFilterPopover } from "./LauncherFilterPopover"; import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; @@ -83,6 +91,7 @@ export default function ResourceLauncher({ const api = createApiClient({ env }); const router = useRouter(); const { navigate, isNavigating, searchParams } = useNavigationContext(); + const [isRefreshing, startRefreshTransition] = useTransition(); const hasRestoredLastView = useRef(false); const [searchInputResetKey, setSearchInputResetKey] = useState(0); @@ -312,8 +321,28 @@ export default function ResourceLauncher({ }); }, [navigateToConfig]); + const handleResetView = useCallback(() => { + searchInputRef.current = savedConfig.query; + setSearchInputResetKey((key) => key + 1); + navigateToConfig(activeViewIdRef.current, savedConfig); + }, [navigateToConfig, savedConfig]); + + const refreshData = () => { + startRefreshTransition(async () => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + const handleSaveToCurrent = () => { - if (isDefaultView) { + if (isDefaultView || (isOrgWideView && !isAdmin)) { return; } updateViewMutation.mutate({ @@ -408,6 +437,7 @@ export default function ResourceLauncher({ onSaveAsNew={handleSaveAsNew} onSaveForEveryone={handleSaveForEveryone} onMakePersonal={handleMakePersonal} + onResetView={handleResetView} /> +
From 5a1d5cb66e82533e99337d74bdd8876d241c104e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 14:06:33 -0400 Subject: [PATCH 49/79] redirect to settings if org admin --- src/app/page.tsx | 10 +++++++++- src/components/RedirectToOrg.tsx | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..7f0f05b57 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -107,7 +107,15 @@ export default async function Page(props: { } if (targetOrgId) { - return ; + const targetOrg = orgs.find((org) => org.orgId === targetOrgId); + return ( + + ); } return ( diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index e647ee7a1..02ad97773 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type RedirectToOrgProps = { targetOrgId: string; + isAdminOrOwner?: boolean; }; -export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { +export default function RedirectToOrg({ + targetOrgId, + isAdminOrOwner = false +}: RedirectToOrgProps) { const router = useRouter(); useEffect(() => { try { const target = - getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; + getInternalRedirectTarget(targetOrgId) ?? + (isAdminOrOwner + ? `/${targetOrgId}/settings` + : `/${targetOrgId}`); router.replace(target); } catch { - router.replace(`/${targetOrgId}`); + router.replace( + isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}` + ); } - }, [targetOrgId, router]); + }, [targetOrgId, isAdminOrOwner, router]); return null; } From 0871a211ecaf7ef2509256e942ccb8105fbf96cb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 14:30:43 -0400 Subject: [PATCH 50/79] open side panel on click --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 4 + server/routers/launcher/types.ts | 1 + src/components/SidePanel.tsx | 164 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 6 +- .../LauncherGroupSection.tsx | 6 +- .../LauncherResourceCard.tsx | 15 +- .../LauncherResourceGrid.tsx | 5 +- .../LauncherResourceList.tsx | 5 +- .../LauncherResourcePanel.tsx | 68 ++++++++ .../resource-launcher/LauncherResourceRow.tsx | 15 +- .../resource-launcher/ResourceLauncher.tsx | 17 ++ .../useLauncherResourceAction.ts | 47 ++++- src/lib/launcherResourceAdminHref.ts | 17 ++ 14 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 src/components/SidePanel.tsx create mode 100644 src/components/resource-launcher/LauncherResourcePanel.tsx create mode 100644 src/lib/launcherResourceAdminHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2c938639a..1621bb8d4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3573,6 +3573,7 @@ "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 85ca24a23..60365d496 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -791,6 +791,7 @@ async function mapPublicResources( const rows = await db .select({ resourceId: resources.resourceId, + niceId: resources.niceId, name: resources.name, mode: resources.mode, fullDomain: resources.fullDomain, @@ -842,6 +843,7 @@ async function mapPublicResources( launcherResourceKey: key, resourceType: "public", resourceId: row.resourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, @@ -876,6 +878,7 @@ async function mapSiteResources( const rows = await db .select({ siteResourceId: siteResources.siteResourceId, + niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, destination: siteResources.destination, @@ -934,6 +937,7 @@ async function mapSiteResources( resourceType: "site", resourceId: row.siteResourceId, siteResourceId: row.siteResourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 9d08271ae..0a252d74d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -39,6 +39,7 @@ export type LauncherResource = { resourceType: "public" | "site"; resourceId: number; siteResourceId?: number; + niceId: string; name: string; accessDisplay: string; accessCopyValue: string; diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx new file mode 100644 index 000000000..969eebcfc --- /dev/null +++ b/src/components/SidePanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import * as React from "react"; + +import { useMediaQuery } from "@app/hooks/useMediaQuery"; +import { cn } from "@app/lib/cn"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger +} from "./ui/sheet"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; + +type BaseProps = { + children: React.ReactNode; +}; + +type RootSidePanelProps = BaseProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +type SidePanelProps = { + className?: string; + asChild?: true; + children?: React.ReactNode; +}; + +const desktop = "(min-width: 768px)"; + +const SidePanel = ({ children, ...props }: RootSidePanelProps) => { + return {children}; +}; + +const SidePanelTrigger = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelContent = ({ + className, + children, + ...props +}: SidePanelProps) => { + const isDesktop = useMediaQuery(desktop); + + return ( + + + e.preventDefault()} + > + {children} + + + ); +}; + +const SidePanelDescription = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => { + return ( +
+
{children}
+
+
+ ); +}; + +const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +export { + SidePanel, + SidePanelBody, + SidePanelClose, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle, + SidePanelTrigger +}; diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index 7ca7e745d..a631aff6c 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -5,6 +5,7 @@ import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -25,6 +26,7 @@ type LauncherGroupListProps = { }; resourcesByGroupKey: Record; onClearFilters?: () => void; + onResourceSelect: (resource: LauncherResource) => void; }; function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { @@ -42,7 +44,8 @@ export function LauncherGroupList({ initialGroups, groupsPagination, resourcesByGroupKey, - onClearFilters + onClearFilters, + onResourceSelect }: LauncherGroupListProps) { const loadMoreRef = useRef(null); @@ -137,6 +140,7 @@ export function LauncherGroupList({ config={config} initialResources={groupResources?.resources} initialResourcesPagination={groupResources?.pagination} + onResourceSelect={onResourceSelect} /> ); })} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 2d1b483dd..1d4881835 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -40,6 +40,7 @@ type LauncherGroupSectionProps = { pageSize: number; }; defaultOpen?: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherGroupSection({ @@ -49,7 +50,8 @@ export function LauncherGroupSection({ config, initialResources, initialResourcesPagination, - defaultOpen = true + defaultOpen = true, + onResourceSelect }: LauncherGroupSectionProps) { const t = useTranslations(); const loadMoreRef = useRef(null); @@ -175,11 +177,13 @@ export function LauncherGroupSection({ ) : ( )}
void; }; export function LauncherResourceCard({ resource, - showLabels + showLabels, + onSelect }: LauncherResourceCardProps) { const hasIcon = Boolean(resource.iconUrl); - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
void; }; export function LauncherResourceGrid({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceGridProps) { return (
@@ -19,6 +21,7 @@ export function LauncherResourceGrid({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} + onSelect={() => onResourceSelect(resource)} /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index d95b58adf..e64e2b7b0 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,11 +6,13 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherResourceList({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceListProps) { return (
@@ -21,6 +23,7 @@ export function LauncherResourceList({ resource={resource} showLabels={showLabels} isLast={index === resources.length - 1} + onSelect={() => onResourceSelect(resource)} /> ))}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx new file mode 100644 index 000000000..1bb61bd9e --- /dev/null +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + SidePanel, + SidePanelBody, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle +} from "@app/components/SidePanel"; +import { Button } from "@app/components/ui/button"; +import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; + +type LauncherResourcePanelProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + resource: LauncherResource | null; + orgId: string; + isAdmin: boolean; +}; + +export function LauncherResourcePanel({ + open, + onOpenChange, + resource, + orgId, + isAdmin +}: LauncherResourcePanelProps) { + const t = useTranslations(); + + return ( + + + + {resource?.name ?? ""} + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {isAdmin && resource ? ( + + ) : null} + + + + ); +} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index d94823b27..34558de51 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -5,28 +5,23 @@ import type { LauncherResource } from "@server/routers/launcher/types"; import { LauncherLabelsRow } from "./LauncherLabelsRow"; import { LauncherResourceAccess } from "./LauncherResourceAccess"; import { LauncherResourceIcon } from "./LauncherResourceIcon"; -import { - getLauncherResourceClickProps, - useLauncherResourceAction -} from "./useLauncherResourceAction"; +import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; isLast?: boolean; + onSelect: () => void; }; export function LauncherResourceRow({ resource, showLabels, - isLast = false + isLast = false, + onSelect }: LauncherResourceRowProps) { const hasTags = showLabels && resource.labels.length > 0; - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
(null); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -491,6 +495,19 @@ export default function ResourceLauncher({ groupsPagination={groupsPagination} resourcesByGroupKey={resourcesByGroupKey} onClearFilters={handleClearFilters} + onResourceSelect={setSelectedResource} + /> + + { + if (!open) { + setSelectedResource(null); + } + }} + resource={selectedResource} + orgId={orgId} + isAdmin={isAdmin} /> diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts index 427ee64bf..4c7d081dd 100644 --- a/src/components/resource-launcher/useLauncherResourceAction.ts +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -44,28 +44,67 @@ export function useLauncherResourceAction({ } export function isLauncherResourceInteractiveTarget( - target: EventTarget | null + target: EventTarget | null, + container?: EventTarget | null ): boolean { if (!(target instanceof Element)) { return false; } - return Boolean( - target.closest("a, button, [role='button'], input, textarea, select") + const interactive = target.closest( + "a, button, [role='button'], input, textarea, select" ); + + if (!interactive) { + return false; + } + + if (container instanceof Element && interactive === container) { + return false; + } + + return true; } function handleLauncherResourceClick( event: MouseEvent, handleAction: () => void ) { - if (isLauncherResourceInteractiveTarget(event.target)) { + if ( + isLauncherResourceInteractiveTarget(event.target, event.currentTarget) + ) { return; } handleAction(); } +export function getLauncherResourceSelectProps(onSelect: () => void) { + return { + onClick: (event: MouseEvent) => { + if ( + isLauncherResourceInteractiveTarget( + event.target, + event.currentTarget + ) + ) { + return; + } + + onSelect(); + }, + className: "cursor-pointer", + role: "button" as const, + tabIndex: 0, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + } + }; +} + export function getLauncherResourceClickProps( handleAction: () => void, isClickable: boolean diff --git a/src/lib/launcherResourceAdminHref.ts b/src/lib/launcherResourceAdminHref.ts new file mode 100644 index 000000000..db7da151e --- /dev/null +++ b/src/lib/launcherResourceAdminHref.ts @@ -0,0 +1,17 @@ +import type { LauncherResource } from "@server/routers/launcher/types"; + +export function getLauncherResourceAdminHref( + orgId: string, + resource: LauncherResource +): string { + if (resource.resourceType === "public") { + return `/${orgId}/settings/resources/public/${resource.niceId}/general`; + } + + const qs = new URLSearchParams({ query: resource.niceId }); + if (resource.site?.siteId != null) { + qs.set("siteId", String(resource.site.siteId)); + } + + return `/${orgId}/settings/resources/private?${qs.toString()}`; +} From 61fc2e5ea7e33c24fba2e83dcbe9165f1f7ec8e3 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 14:48:36 -0400 Subject: [PATCH 51/79] Fix restartSite import --- server/auth/actions.ts | 1 + server/routers/external.ts | 4 ++-- server/routers/site/restartSite.ts | 20 +++++++------------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71fb33156..f081574e7 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -21,6 +21,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + restartSite = "restartSite", resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", createResource = "createResource", diff --git a/server/routers/external.ts b/server/routers/external.ts index 326e555a8..31ed385d8 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -257,8 +257,8 @@ authenticated.delete( authenticated.post( "/site/:siteId/restart", verifySiteAccess, - verifyUserHasAction(ActionsEnum.updateSite), - logActionAudit(ActionsEnum.updateSite), + verifyUserHasAction(ActionsEnum.restartSite), + logActionAudit(ActionsEnum.restartSite), site.restartSite ); diff --git a/server/routers/site/restartSite.ts b/server/routers/site/restartSite.ts index f60db77d3..9ae6ffe21 100644 --- a/server/routers/site/restartSite.ts +++ b/server/routers/site/restartSite.ts @@ -9,8 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { sendToClient } from "../ws"; -import { canCompress } from "@server/lib/clientVersionChecks"; +import { sendToClient } from "#dynamic/routers/ws"; const updateSiteParamsSchema = z.strictObject({ siteId: z.coerce.number().int().positive() @@ -92,17 +91,12 @@ export async function restartSite( ); } - await sendToClient( - newt.newtId, - { - type: "newt/wg/restart", - data: {} - }, - { - incrementConfigVersion: false, - compress: canCompress(newt.version, "newt") - } - ); + logger.info(`Restarting site ${siteId}...`); + + await sendToClient(newt.newtId, { + type: "newt/wg/restart", + data: {} + }); return response(res, { data: null, From db0a7cc1cea91e434f678b1f43014f0dc3bd0c93 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:14:29 -0400 Subject: [PATCH 52/79] fix toolbar responsiveness and disable side panel --- server/setup/scriptsSqlite/1.13.0.ts | 28 +++ .../resource-launcher/LauncherGroupList.tsx | 2 +- .../LauncherGroupSection.tsx | 2 +- .../LauncherResourceCard.tsx | 16 +- .../LauncherResourceGrid.tsx | 8 +- .../LauncherResourceList.tsx | 8 +- .../resource-launcher/LauncherResourceRow.tsx | 16 +- .../resource-launcher/LauncherViewTabs.tsx | 2 +- .../resource-launcher/ResourceLauncher.tsx | 207 +++++++++--------- 9 files changed, 167 insertions(+), 122 deletions(-) diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index c4b49495f..a67b0fa14 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -271,6 +271,7 @@ export default async function migration() { `ALTER TABLE 'sites' DROP COLUMN 'remoteSubnets';` ).run(); +<<<<<<< Updated upstream // get all of the siteResources and set the aliasAddress to 100.96.128.x starting at .8 const siteResourcesForAlias = db .prepare( @@ -278,6 +279,15 @@ export default async function migration() { ) .all() as { siteResourceId: number; +======= + // Associate clients with site resources based on their previous site access + // Get all client-site associations from the renamed clientSitesAssociationsCache table + const clientSiteAssociations = db + .prepare(`SELECT clientId, siteId FROM 'clientSitesAssociationsCache'`) + .all() as { + clientId: number; + siteId: number; +>>>>>>> Stashed changes }[]; const updateAliasAddress = db.prepare( @@ -335,6 +345,7 @@ export default async function migration() { `INSERT INTO 'clientSiteResources' ('clientId', 'siteResourceId') VALUES (?, ?)` ); +<<<<<<< Updated upstream // create a clientSiteResourcesAssociationsCache entry for each existing association as well const insertClientSiteResourceCache = db.prepare( `INSERT INTO 'clientSiteResourcesAssociationsCache' ('clientId', 'siteResourceId') VALUES (?, ?)` @@ -345,6 +356,13 @@ export default async function migration() { const siteResources = getSiteResources.all( association.siteId ) as { +======= + // For each client-site association, find all site resources for that site + for (const association of clientSiteAssociations) { + const siteResources = db + .prepare(`SELECT siteResourceId FROM 'siteResources' WHERE siteId = ?`) + .all(association.siteId) as { +>>>>>>> Stashed changes siteResourceId: number; }[]; @@ -352,15 +370,25 @@ export default async function migration() { for (const siteResource of siteResources) { insertClientSiteResource.run( association.clientId, +<<<<<<< Updated upstream siteResource.siteResourceId ); insertClientSiteResourceCache.run( association.clientId, +======= +>>>>>>> Stashed changes siteResource.siteResourceId ); } } + // Get all clients for niceId population (used later) + const clients = db + .prepare(`SELECT clientId FROM 'clients'`) + .all() as { + clientId: number; + }[]; + // Associate existing site resources with their org's admin role const siteResourcesWithOrg = db .prepare(`SELECT siteResourceId, orgId FROM 'siteResources'`) diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index a631aff6c..bb3418f8d 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -26,7 +26,7 @@ type LauncherGroupListProps = { }; resourcesByGroupKey: Record; onClearFilters?: () => void; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 1d4881835..49b648032 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -40,7 +40,7 @@ type LauncherGroupSectionProps = { pageSize: number; }; defaultOpen?: boolean; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherGroupSection({ diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx index 71e9cbe8b..ac891e84b 100644 --- a/src/components/resource-launcher/LauncherResourceCard.tsx +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -10,7 +10,7 @@ import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; type LauncherResourceCardProps = { resource: LauncherResource; showLabels: boolean; - onSelect: () => void; + onSelect?: () => void; }; export function LauncherResourceCard({ @@ -19,18 +19,20 @@ export function LauncherResourceCard({ onSelect }: LauncherResourceCardProps) { const hasIcon = Boolean(resource.iconUrl); - const clickProps = getLauncherResourceSelectProps(onSelect); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; return (
void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherResourceGrid({ @@ -21,7 +21,11 @@ export function LauncherResourceGrid({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} - onSelect={() => onResourceSelect(resource)} + onSelect={ + onResourceSelect + ? () => onResourceSelect(resource) + : undefined + } /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index e64e2b7b0..555d112a3 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,7 +6,7 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherResourceList({ @@ -23,7 +23,11 @@ export function LauncherResourceList({ resource={resource} showLabels={showLabels} isLast={index === resources.length - 1} - onSelect={() => onResourceSelect(resource)} + onSelect={ + onResourceSelect + ? () => onResourceSelect(resource) + : undefined + } /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index 34558de51..eca882c56 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -11,7 +11,7 @@ type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; isLast?: boolean; - onSelect: () => void; + onSelect?: () => void; }; export function LauncherResourceRow({ @@ -21,19 +21,21 @@ export function LauncherResourceRow({ onSelect }: LauncherResourceRowProps) { const hasTags = showLabels && resource.labels.length > 0; - const clickProps = getLauncherResourceSelectProps(onSelect); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; return (
+
{viewOptions.map((option) => { const isSelected = activeViewId === option.value; return ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 10b9df847..6503de169 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -32,7 +32,6 @@ import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { LauncherGroup, - LauncherResource, LauncherViewConfig, LauncherViewRecord } from "@server/routers/launcher/types"; @@ -51,12 +50,13 @@ import { import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; import type { SelectedLabel } from "@app/components/labels-selector"; +import { useMediaQuery } from "@app/hooks/useMediaQuery"; +import { cn } from "@app/lib/cn"; import { LauncherFilterPopover } from "./LauncherFilterPopover"; import { LauncherGroupList } from "./LauncherGroupList"; import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; -import { LauncherResourcePanel } from "./LauncherResourcePanel"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; @@ -98,11 +98,11 @@ export default function ResourceLauncher({ const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); - const [selectedResource, setSelectedResource] = - useState(null); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const configRef = useRef(config); configRef.current = config; const searchInputRef = useRef(config.query); @@ -392,6 +392,90 @@ export default function ResourceLauncher({ }); }; + const savedViewTabs = views.map((view) => ({ + viewId: view.viewId, + name: view.name + })); + + const renderToolbarSearch = (searchClassName: string) => ( +
+ + { + const value = event.currentTarget.value; + searchInputRef.current = value; + debouncedNavigateSearch(activeViewIdRef.current, value); + }} + placeholder={t("resourceLauncherSearchPlaceholder")} + className="pl-8" + type="search" + /> +
+ ); + + const renderToolbarActions = () => ( + <> + + + applyConfigPatch({ + siteIds: sites.map((site) => site.siteId) + }) + } + onLabelsChange={(labels) => + applyConfigPatch({ + labelIds: labels.map((label) => label.labelId) + }) + } + /> + + applyConfigPatch({ + order: config.order === "asc" ? "desc" : "asc" + }) + } + /> + { + if (!isDefaultView) { + deleteViewMutation.mutate(activeViewId); + } + }} + /> + + + ); + + const renderToolbarViews = () => ( + + ); + return (
-
-
-
-
- - { - const value = event.currentTarget.value; - searchInputRef.current = value; - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - type="search" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> + {isDesktop ? ( +
+ {renderToolbarSearch("w-64")} +
+ {renderToolbarViews()}
-
- - - applyConfigPatch({ - siteIds: sites.map((site) => site.siteId) - }) - } - onLabelsChange={(labels) => - applyConfigPatch({ - labelIds: labels.map( - (label) => label.labelId - ) - }) - } - /> - - applyConfigPatch({ - order: - config.order === "asc" ? "desc" : "asc" - }) - } - /> - { - if (!isDefaultView) { - deleteViewMutation.mutate(activeViewId); - } - }} - /> - +
+ {renderToolbarActions()}
-
+ ) : ( +
+
+ {renderToolbarActions()} +
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarViews()} +
+
+ )} - - { - if (!open) { - setSelectedResource(null); - } - }} - resource={selectedResource} - orgId={orgId} - isAdmin={isAdmin} /> From 7fb95e1726918f78b284bf2c5f269965b29e0878 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:15:43 -0400 Subject: [PATCH 53/79] restore script --- server/setup/scriptsSqlite/1.13.0.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index a67b0fa14..c4b49495f 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -271,7 +271,6 @@ export default async function migration() { `ALTER TABLE 'sites' DROP COLUMN 'remoteSubnets';` ).run(); -<<<<<<< Updated upstream // get all of the siteResources and set the aliasAddress to 100.96.128.x starting at .8 const siteResourcesForAlias = db .prepare( @@ -279,15 +278,6 @@ export default async function migration() { ) .all() as { siteResourceId: number; -======= - // Associate clients with site resources based on their previous site access - // Get all client-site associations from the renamed clientSitesAssociationsCache table - const clientSiteAssociations = db - .prepare(`SELECT clientId, siteId FROM 'clientSitesAssociationsCache'`) - .all() as { - clientId: number; - siteId: number; ->>>>>>> Stashed changes }[]; const updateAliasAddress = db.prepare( @@ -345,7 +335,6 @@ export default async function migration() { `INSERT INTO 'clientSiteResources' ('clientId', 'siteResourceId') VALUES (?, ?)` ); -<<<<<<< Updated upstream // create a clientSiteResourcesAssociationsCache entry for each existing association as well const insertClientSiteResourceCache = db.prepare( `INSERT INTO 'clientSiteResourcesAssociationsCache' ('clientId', 'siteResourceId') VALUES (?, ?)` @@ -356,13 +345,6 @@ export default async function migration() { const siteResources = getSiteResources.all( association.siteId ) as { -======= - // For each client-site association, find all site resources for that site - for (const association of clientSiteAssociations) { - const siteResources = db - .prepare(`SELECT siteResourceId FROM 'siteResources' WHERE siteId = ?`) - .all(association.siteId) as { ->>>>>>> Stashed changes siteResourceId: number; }[]; @@ -370,25 +352,15 @@ export default async function migration() { for (const siteResource of siteResources) { insertClientSiteResource.run( association.clientId, -<<<<<<< Updated upstream siteResource.siteResourceId ); insertClientSiteResourceCache.run( association.clientId, -======= ->>>>>>> Stashed changes siteResource.siteResourceId ); } } - // Get all clients for niceId population (used later) - const clients = db - .prepare(`SELECT clientId FROM 'clients'`) - .all() as { - clientId: number; - }[]; - // Associate existing site resources with their org's admin role const siteResourcesWithOrg = db .prepare(`SELECT siteResourceId, orgId FROM 'siteResources'`) From 023110b3410afb20d7bfc01e2de51915c923deb4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:18:49 -0400 Subject: [PATCH 54/79] update en-us --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 1621bb8d4..7c14597a8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3568,7 +3568,7 @@ "resourceLauncherLayout": "Layout", "resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutList": "List", - "resourceLauncherShowLabels": "Show Label Tags", + "resourceLauncherShowLabels": "Show Labels", "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", From 69e7fedcfcd3aea01925a81e5d86787a4a670b7a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 15:28:26 -0400 Subject: [PATCH 55/79] Add the remote exit node info --- server/routers/newt/sync.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index b8f152bec..8e93cdf45 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -13,7 +13,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { tcpTargets, udpTargets, validHealthCheckTargets, - browserGatewayTargets + browserGatewayTargets, + remoteExitNodeSubnets } = await buildTargetConfigurationForNewtClient(site.siteId); let exitNode: ExitNode | undefined; @@ -41,7 +42,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { healthCheckTargets: validHealthCheckTargets, peers: peers, clientTargets: targets, - browserGatewayTargets: browserGatewayTargets + browserGatewayTargets: browserGatewayTargets, + remoteExitNodeSubnets: remoteExitNodeSubnets } }, { From ba33cb9895563503e50d2c90129db35303c44f6c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 15:44:08 -0400 Subject: [PATCH 56/79] Increment config version for this --- .../routers/remoteExitNode/setRemoteExitNodeResources.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts index 8416f0169..feea447ed 100644 --- a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -27,6 +27,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { sendToClientsBatch } from "#private/routers/ws"; +import { canCompress } from "@server/lib/clientVersionChecks"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -123,17 +124,21 @@ export async function setRemoteExitNodeResources( // Notify all newts connected to this remote exit node's exit node if (remoteExitNode.exitNodeId) { const connectedNewts = await db - .select({ newtId: newts.newtId }) + .select({ newtId: newts.newtId, version: newts.version }) .from(newts) .innerJoin(sites, eq(newts.siteId, sites.siteId)) .where(eq(sites.exitNodeId, remoteExitNode.exitNodeId)); await sendToClientsBatch( - connectedNewts.map(({ newtId }) => ({ + connectedNewts.map(({ newtId, version }) => ({ clientId: newtId, message: { type: "newt/wg/subnets/update", data: { subnets: destinations } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(version, "newt") } })) ); From bcc128aeb6233d7c74b33183fc668dcaa636a4b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 15:47:24 -0400 Subject: [PATCH 57/79] Fix ping function --- server/private/routers/ws/ws.ts | 4 ++-- server/routers/newt/handleNewtPingMessage.ts | 4 ++-- server/routers/newt/pingAccumulator.ts | 3 --- server/routers/ws/ws.ts | 4 ++-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 5e38c709e..f014b0e57 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -26,7 +26,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; -import { recordPing } from "@server/routers/newt/pingAccumulator"; +import { recordSitePing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import logger from "@server/logger"; @@ -1063,7 +1063,7 @@ const setupConnection = async ( // pending pings in a single batched UPDATE every ~10s, which // prevents connection pool exhaustion under load (especially // with cross-region latency to the database). - recordPing(newtClient.siteId); + recordSitePing(newtClient.siteId); }); } diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 56b8a2a24..86489bd67 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -5,7 +5,7 @@ import { Newt } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; -import { recordPing } from "./pingAccumulator"; +import { recordSitePing } from "./pingAccumulator"; /** * Handles ping messages from newt clients. @@ -35,7 +35,7 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { // batched UPDATE instead of one query per ping. This prevents // connection pool exhaustion under load, especially with // cross-region latency to the database. - recordPing(newt.siteId); + recordSitePing(newt.siteId); // Check config version and sync if stale. const configVersion = await getClientConfigVersion(newt.newtId); diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 5351c6723..be9cd4972 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -57,9 +57,6 @@ export function recordSitePing(siteId: number): void { pendingSitePings.set(siteId, now); } -/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */ -export const recordPing = recordSitePing; - /** * Record a ping for an OLM client. Batches the `clients` table update * (`online`, `lastPing`, `archived`) and, when `olmArchived` is true, diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 4ce337a20..dad29c4f0 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -14,7 +14,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; -import { recordPing } from "@server/routers/newt/pingAccumulator"; +import { recordSitePing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { messageHandlers } from "./messageHandlers"; @@ -424,7 +424,7 @@ const setupConnection = async ( // pending pings in a single batched UPDATE every ~10s, which // prevents connection pool exhaustion under load (especially // with cross-region latency to the database). - recordPing(newtClient.siteId); + recordSitePing(newtClient.siteId); }); } From 663244fa3a71c66314fd1d5bc5241517dd387565 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 16:17:27 -0400 Subject: [PATCH 58/79] support labels in list alises --- .../resource/listUserResourceAliases.ts | 166 ++++++++++++++---- 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 75dc91166..dab8afc23 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -5,8 +5,12 @@ import { userSiteResources, roleSiteResources, userOrgRoles, - userOrgs + userOrgs, + labels, + siteResourceLabels } from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -19,13 +23,33 @@ import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; +const labelFilterQuerySchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, z.array(z.string())) + .optional() + .catch([]); + function userResourceAliasesCacheKey( orgId: string, userId: string, page: number, - pageSize: number + pageSize: number, + includeLabels: boolean, + labelFilter: string[] ) { - return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`; + const labelsKey = + labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; + return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; } const listUserResourceAliasesParamsSchema = z.strictObject({ @@ -56,43 +80,35 @@ const listUserResourceAliasesQuerySchema = z.strictObject({ type: "integer", default: 1, description: "Page number to retrieve" - }) + }), + includeLabels: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform((val) => val === "true") + .openapi({ + type: "boolean", + default: false, + description: + "When true, include label names for each alias in the items field" + }), + labels: labelFilterQuerySchema.openapi({ + type: "array", + description: + "Filter by resource labels. A resource matches when it has any of the given labels (OR)." + }) }); +export type UserResourceAliasItem = { + alias: string; + labels: string[]; +}; + export type ListUserResourceAliasesResponse = PaginatedResponse<{ aliases: string[]; + items?: UserResourceAliasItem[]; }>; -// registry.registerPath({ -// method: "get", -// path: "/org/{orgId}/user-resource-aliases", -// description: -// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.", -// tags: [OpenAPITags.PrivateResource], -// request: { -// params: z.object({ -// orgId: z.string() -// }), -// query: listUserResourceAliasesQuerySchema -// }, -// responses: { -// 200: { -// description: "Successful response", -// content: { -// "application/json": { -// schema: z.object({ -// data: z.record(z.string(), z.any()).nullable(), -// success: z.boolean(), -// error: z.boolean(), -// message: z.string(), -// status: z.number() -// }) -// } -// } -// } -// } -// }); - export async function listUserResourceAliases( req: Request, res: Response, @@ -110,7 +126,12 @@ export async function listUserResourceAliases( ) ); } - const { page, pageSize } = parsedQuery.data; + const { + page, + pageSize, + includeLabels, + labels: labelFilter + } = parsedQuery.data; const parsedParams = listUserResourceAliasesParamsSchema.safeParse( req.params @@ -149,7 +170,9 @@ export async function listUserResourceAliases( orgId, userId, page, - pageSize + pageSize, + includeLabels, + labelFilter ?? [] ); const cachedData: ListUserResourceAliasesResponse | undefined = await cache.get(cacheKey); @@ -204,6 +227,7 @@ export async function listUserResourceAliases( if (accessibleSiteResourceIds.length === 0) { const data: ListUserResourceAliasesResponse = { aliases: [], + ...(includeLabels ? { items: [] } : {}), pagination: { total: 0, pageSize, @@ -224,18 +248,44 @@ export async function listUserResourceAliases( }); } - const whereClause = and( + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + + const whereConditions = [ eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")), isNotNull(siteResources.alias), ne(siteResources.alias, ""), inArray(siteResources.siteResourceId, accessibleSiteResourceIds) - ); + ]; + + if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + whereConditions.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(inArray(labels.name, labelFilter)) + ) + ); + } + + const whereClause = and(...whereConditions); const baseSelect = () => db - .select({ alias: siteResources.alias }) + .select({ + alias: siteResources.alias, + siteResourceId: siteResources.siteResourceId + }) .from(siteResources) .where(whereClause); @@ -251,8 +301,46 @@ export async function listUserResourceAliases( const aliases = rows.map((r) => r.alias as string); + let items: UserResourceAliasItem[] | undefined; + if (includeLabels) { + const siteResourceIdList = rows.map((r) => r.siteResourceId); + + let labelsForSiteResources: Array<{ + name: string; + siteResourceId: number; + }> = []; + + if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + labelsForSiteResources = await db + .select({ + name: labels.name, + siteResourceId: siteResourceLabels.siteResourceId + }) + .from(labels) + .innerJoin( + siteResourceLabels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIdList + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)); + } + + items = rows.map((row) => ({ + alias: row.alias as string, + labels: labelsForSiteResources + .filter((l) => l.siteResourceId === row.siteResourceId) + .map((l) => l.name) + })); + } + const data: ListUserResourceAliasesResponse = { aliases, + ...(items !== undefined ? { items } : {}), pagination: { total: totalCount, pageSize, From 807613f28c7ce49fe5fa21cf193fbc0a7789d91e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 17:30:07 -0400 Subject: [PATCH 59/79] Move rate limit up --- .../siteResource/setSiteResourceRoles.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 613f0aa63..277ef437e 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -9,7 +9,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations"; +import { + rebuildClientAssociationsFromSiteResource, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; const setSiteResourceRolesBodySchema = z .object({ @@ -108,6 +111,15 @@ export async function setSiteResourceRoles( ); } + if (await isOrgRebuildRateLimited(siteResource.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + // Check if any of the roleIds are admin roles const rolesToCheck = await db .select() @@ -167,15 +179,6 @@ export async function setSiteResourceRoles( } }); - if (await isOrgRebuildRateLimited(siteResource.orgId)) { - return next( - createHttpError( - HttpCode.TOO_MANY_REQUESTS, - "Too many concurrent rebuild operations for this organization. Please retry after a moment." - ) - ); - } - rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` From e35878ee5511120d08f33c0ffb7ccceaba1ec1d9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 19:59:38 -0400 Subject: [PATCH 60/79] add caching --- .../launcher/launcherResourceAccess.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 60365d496..8fe5e8ef8 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -19,6 +19,7 @@ import { userResources, userSiteResources } from "@server/db"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { @@ -57,7 +58,18 @@ export type AccessibleIds = { siteResourceIds: number[]; }; -export async function resolveAccessibleIds( +const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; + +function launcherAccessibleIdsCacheKey( + orgId: string, + userId: string, + roleIds: number[] +) { + const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); + return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`; +} + +async function resolveAccessibleIdsUncached( orgId: string, userId: string, userRoleIds: number[] @@ -158,6 +170,26 @@ export async function resolveAccessibleIds( }; } +export async function resolveAccessibleIds( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const cacheKey = launcherAccessibleIdsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + + const result = await resolveAccessibleIdsUncached( + orgId, + userId, + userRoleIds + ); + await cache.set(cacheKey, result, LAUNCHER_ACCESSIBLE_IDS_TTL_SEC); + return result; +} + function searchPattern(query: string) { return `%${query.trim()}%`; } From 4ab101f8a9de67e6ad45af5d543285f7e4930021 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 21:13:40 -0400 Subject: [PATCH 61/79] Fix lock --- server/lib/ip.ts | 6 +- server/lib/lock.ts | 72 +++++++++++++----------- server/lib/rebuildQueue.ts | 106 +++++++++++++++++++++++++++++++++-- server/private/lib/lock.ts | 111 ++++++++++++++++++------------------- 4 files changed, 199 insertions(+), 96 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 6da8bf887..56576282a 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -333,7 +333,7 @@ export async function getNextAvailableClientSubnet( if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const [org] = await transaction @@ -395,7 +395,7 @@ export async function getNextAvailableAliasAddress( if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const [org] = await trx @@ -463,7 +463,7 @@ export async function getNextAvailableOrgSubnet(): Promise<{ if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } - const release = () => lockManager.releaseLock(lockKey); + const release = () => lockManager.releaseLock(lockKey, acquired); try { const existingAddresses = await db diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 3cd1b8704..2f6fad673 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "crypto"; + const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; type LocalLockRecord = { @@ -15,58 +17,60 @@ export class LockManager { } } - private getLocalOwnerToken(): string { - return `${instanceId}:`; - } - /** - * Acquire a distributed lock using Redis SET with NX and PX options + * Acquire a local in-process lock using an optimistic Map-based check. * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds - * @returns Promise - true if lock acquired, false otherwise + * @returns Promise - a token identifying this specific acquisition + * (truthy) on success, or null if the lock could not be acquired. */ async acquireLock( lockKey: string, ttlMs: number = 30000, maxRetries: number = 3, retryDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); if (!existing) { + const token = `${instanceId}:${randomUUID()}`; localLocks.set(lockKey, { - owner: this.getLocalOwnerToken(), + owner: token, expiresAt: Date.now() + ttlMs }); - return true; - } - - if (existing.owner === this.getLocalOwnerToken()) { - existing.expiresAt = Date.now() + ttlMs; - localLocks.set(lockKey, existing); - return true; + return token; } + // The lock is currently held -- possibly by a different, unrelated + // caller in this same process. We intentionally do NOT treat + // same-process holders as automatically reentrant here: two + // independent logical operations (e.g. two different API requests) + // running concurrently in the same process must not both believe + // they hold the lock, or their writes under it can interleave + // unguarded. Just retry with backoff like any other contended lock. if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } - return false; + return null; } /** - * Release a lock using Lua script to ensure atomicity + * Release a lock previously acquired via acquireLock/acquireLockWithRetry. * @param lockKey - Unique identifier for the lock + * @param token - the exact token returned by the acquisition being released. + * Required so a caller whose TTL already expired can't delete a + * different, currently-active holder's lock. */ - async releaseLock(lockKey: string): Promise { + async releaseLock(lockKey: string, token: string): Promise { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (existing && existing.owner === this.getLocalOwnerToken()) { + if (existing && existing.owner === token) { localLocks.delete(lockKey); } } @@ -100,23 +104,29 @@ export class LockManager { const ttl = Math.max(0, existing.expiresAt - Date.now()); return { exists: true, - ownedByMe: existing.owner === this.getLocalOwnerToken(), + ownedByMe: existing.owner.startsWith(`${instanceId}:`), ttl, owner: existing.owner.split(":")[0] }; } /** - * Extend the TTL of an existing lock owned by this worker + * Extend the TTL of an existing lock, provided the token matches the + * acquisition currently holding it. * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds + * @param token - the token returned by the acquisition being extended * @returns Promise - true if extended successfully */ - async extendLock(lockKey: string, ttlMs: number): Promise { + async extendLock( + lockKey: string, + ttlMs: number, + token: string + ): Promise { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (!existing || existing.owner !== this.getLocalOwnerToken()) { + if (!existing || existing.owner !== token) { return false; } @@ -131,14 +141,14 @@ export class LockManager { * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds - * @returns Promise - true if lock acquired + * @returns Promise - token if acquired, null otherwise */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const acquired = await this.acquireLock( lockKey, @@ -148,7 +158,7 @@ export class LockManager { ); if (acquired) { - return true; + return acquired; } if (attempt < maxRetries) { @@ -158,7 +168,7 @@ export class LockManager { } } - return false; + return null; } /** @@ -173,16 +183,16 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - const acquired = await this.acquireLock(lockKey, ttlMs); + const token = await this.acquireLock(lockKey, ttlMs); - if (!acquired) { + if (!token) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { - await this.releaseLock(lockKey); + await this.releaseLock(lockKey, token); } } @@ -204,7 +214,7 @@ export class LockManager { let locksOwnedByMe = 0; for (const value of localLocks.values()) { - if (value.owner === this.getLocalOwnerToken()) { + if (value.owner.startsWith(`${instanceId}:`)) { locksOwnedByMe++; } } diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 84dce9641..07f2cb003 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -1,3 +1,5 @@ +import logger from "@server/logger"; + export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { @@ -16,12 +18,104 @@ export interface RebuildQueueManager { isQueued(job: RebuildJob): Promise; } -class NoopRebuildQueue implements RebuildQueueManager { - async enqueue(_job: RebuildJob): Promise {} - startProcessing(_handlers: RebuildJobHandlers): void {} - async isQueued(_job: RebuildJob): Promise { - return false; +// In-process FIFO used when there is no Redis to back a distributed queue +// (OSS build, or Redis unavailable). A job that loses the per-resource +// rebuild lock race lands here instead of being silently dropped, and gets +// retried shortly after against fresh DB state. +const POLL_INTERVAL_MS = 500; +const BATCH_SIZE = 5; + +function dedupeKey(job: RebuildJob): string { + return `${job.type}:${job.id}`; +} + +class InMemoryRebuildQueue implements RebuildQueueManager { + private queue: RebuildJob[] = []; + private queuedSet = new Set(); + private processing = false; + private processingStarted = false; + private handlers: RebuildJobHandlers | null = null; + + async isQueued(job: RebuildJob): Promise { + return this.queuedSet.has(dedupeKey(job)); + } + + async enqueue(job: RebuildJob): Promise { + const key = dedupeKey(job); + if (this.queuedSet.has(key)) { + logger.debug( + `Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}` + ); + return; + } + this.queuedSet.add(key); + this.queue.push(job); + logger.debug( + `Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)` + ); + } + + startProcessing(handlers: RebuildJobHandlers): void { + if (this.processingStarted) return; + this.processingStarted = true; + this.handlers = handlers; + + setInterval(() => { + this.tryProcessBatch().catch((err) => { + logger.error( + "Rebuild queue: unhandled error in process loop:", + err + ); + }); + }, POLL_INTERVAL_MS); + + logger.info("Rebuild queue processor started (in-memory)"); + } + + private async tryProcessBatch(): Promise { + if (this.processing || !this.handlers || this.queue.length === 0) { + return; + } + + this.processing = true; + try { + for (let i = 0; i < BATCH_SIZE; i++) { + const job = this.queue.shift(); + if (!job) break; // queue drained + + // Remove from the dedupe set once dequeued so the same job + // can be re-queued while this one is in progress. + this.queuedSet.delete(dedupeKey(job)); + + logger.debug( + `Rebuild queue: processing ${job.type}:${job.id}` + ); + + try { + if (job.type === "site-resource") { + await this.handlers.onSiteResource(job.id); + } else if (job.type === "client") { + await this.handlers.onClient(job.id); + } else { + logger.warn( + `Rebuild queue: unknown job type "${(job as any).type}", discarding` + ); + } + + logger.debug( + `Rebuild queue: completed ${job.type}:${job.id}` + ); + } catch (err) { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } + } + } finally { + this.processing = false; + } } } -export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); +export const rebuildQueue: RebuildQueueManager = new InMemoryRebuildQueue(); diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 26577b2b0..94b27a337 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -32,55 +32,59 @@ export class LockManager { } } - private getLocalOwnerToken(): string { - return `${instanceId}:`; - } - /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds - * @returns Promise - true if lock acquired, false otherwise + * @returns Promise - a token identifying this specific acquisition + * (truthy) on success, or null if the lock could not be acquired. */ async acquireLock( lockKey: string, ttlMs: number = 30000, maxRetries: number = 3, retryDelayMs: number = 100 - ): Promise { + ): Promise { if (!redis || !redis.status || redis.status !== "ready") { for (let attempt = 0; attempt < maxRetries; attempt++) { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); if (!existing) { + const token = `${instanceId}:${uuidv4()}`; localLocks.set(lockKey, { - owner: this.getLocalOwnerToken(), + owner: token, expiresAt: Date.now() + ttlMs }); - return true; - } - - if (existing.owner === this.getLocalOwnerToken()) { - existing.expiresAt = Date.now() + ttlMs; - localLocks.set(lockKey, existing); - return true; + return token; } + // Do not treat a same-process holder as automatically + // reentrant -- see the note in the Redis branch below. if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } - return false; + return null; } - const lockValue = `${instanceId}:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { try { + // Every acquisition attempt gets its own unique token, even + // within the same process. Two independent logical operations + // (e.g. two different API requests handled by the same server) + // racing for this key must never both believe they hold the + // lock -- if we treated "existing value starts with my + // instanceId" as reentrant success, a second unrelated caller + // on this process could barge in while the first is still + // mid-flight, and their writes under the lock would interleave + // unguarded. + const lockValue = `${instanceId}:${uuidv4()}`; + // Use SET with NX (only set if not exists) and PX (expire in milliseconds) // This is atomic and handles both setting and expiration const result = await redis.set( @@ -93,19 +97,7 @@ export class LockManager { if (result === "OK") { logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); - return true; - } - - // Check if the existing lock is from this worker (reentrant behavior) - const existingValue = await redis.get(redisKey); - if ( - existingValue && - existingValue.startsWith(`${instanceId}:`) - ) { - // Extend the lock TTL since it's the same worker - await redis.pexpire(redisKey, ttlMs); - logger.debug(`Lock extended: ${lockKey} by ${instanceId}`); - return true; + return lockValue; } // If this isn't our last attempt, wait before retrying with exponential backoff @@ -132,18 +124,23 @@ export class LockManager { logger.debug( `Failed to acquire lock ${lockKey} after ${maxRetries} attempts` ); - return false; + return null; } /** - * Release a lock using Lua script to ensure atomicity + * Release a lock previously acquired via acquireLock/acquireLockWithRetry, + * using a Lua script to ensure we only delete it if it still matches the + * exact token from that acquisition (not just "owned by this process") -- + * this ensures a caller whose TTL already expired can't delete a + * different, currently-active holder's lock. * @param lockKey - Unique identifier for the lock + * @param token - the exact token returned by the acquisition being released */ - async releaseLock(lockKey: string): Promise { + async releaseLock(lockKey: string, token: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (existing && existing.owner === this.getLocalOwnerToken()) { + if (existing && existing.owner === token) { localLocks.delete(lockKey); } return; @@ -151,13 +148,12 @@ export class LockManager { const redisKey = `lock:${lockKey}`; - // Lua script to ensure we only delete the lock if it belongs to this worker const luaScript = ` local key = KEYS[1] - local worker_prefix = ARGV[1] + local expected_value = ARGV[1] local current_value = redis.call('GET', key) - if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + if current_value and current_value == expected_value then return redis.call('DEL', key) else return 0 @@ -169,16 +165,14 @@ export class LockManager { luaScript, 1, redisKey, - `${instanceId}:` + token )) as number; if (result === 1) { logger.debug(`Lock released: ${lockKey} by ${instanceId}`); } else { logger.warn( - `Lock not released - not owned by worker: ${lockKey} by ${ - instanceId - }` + `Lock not released - token did not match current holder: ${lockKey} (attempted by ${instanceId})` ); } } catch (error) { @@ -230,7 +224,7 @@ export class LockManager { const ttl = Math.max(0, existing.expiresAt - Date.now()); return { exists: true, - ownedByMe: existing.owner === this.getLocalOwnerToken(), + ownedByMe: existing.owner.startsWith(`${instanceId}:`), ttl, owner: existing.owner.split(":")[0] }; @@ -261,17 +255,23 @@ export class LockManager { } /** - * Extend the TTL of an existing lock owned by this worker + * Extend the TTL of an existing lock, provided the token matches the + * acquisition currently holding it. * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds + * @param token - the token returned by the acquisition being extended * @returns Promise - true if extended successfully */ - async extendLock(lockKey: string, ttlMs: number): Promise { + async extendLock( + lockKey: string, + ttlMs: number, + token: string + ): Promise { if (!redis || !redis.status || redis.status !== "ready") { this.clearExpiredLocalLock(lockKey); const existing = localLocks.get(lockKey); - if (!existing || existing.owner !== this.getLocalOwnerToken()) { + if (!existing || existing.owner !== token) { return false; } @@ -282,14 +282,13 @@ export class LockManager { const redisKey = `lock:${lockKey}`; - // Lua script to extend TTL only if lock is owned by this worker const luaScript = ` local key = KEYS[1] - local worker_prefix = ARGV[1] + local expected_value = ARGV[1] local ttl = tonumber(ARGV[2]) local current_value = redis.call('GET', key) - if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then + if current_value and current_value == expected_value then return redis.call('PEXPIRE', key, ttl) else return 0 @@ -301,7 +300,7 @@ export class LockManager { luaScript, 1, redisKey, - `${instanceId}:`, + token, ttlMs.toString() )) as number; @@ -324,14 +323,14 @@ export class LockManager { * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds - * @returns Promise - true if lock acquired + * @returns Promise - token if acquired, null otherwise */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 - ): Promise { + ): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const acquired = await this.acquireLock( lockKey, @@ -341,7 +340,7 @@ export class LockManager { ); if (acquired) { - return true; + return acquired; } if (attempt < maxRetries) { @@ -355,7 +354,7 @@ export class LockManager { logger.warn( `Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts` ); - return false; + return null; } /** @@ -370,16 +369,16 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - const acquired = await this.acquireLock(lockKey, ttlMs); + const token = await this.acquireLock(lockKey, ttlMs); - if (!acquired) { + if (!token) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { - await this.releaseLock(lockKey); + await this.releaseLock(lockKey, token); } } @@ -402,7 +401,7 @@ export class LockManager { let locksOwnedByMe = 0; for (const value of localLocks.values()) { - if (value.owner === this.getLocalOwnerToken()) { + if (value.owner.startsWith(`${instanceId}:`)) { locksOwnedByMe++; } } From 9747731668f36f97d5fcfafb7c2672340978f93e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:27:51 -0400 Subject: [PATCH 62/79] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 14a4a5365..f3262c3ef 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -123,6 +123,16 @@ "siteUpdated": "Nœud mis à jour", "siteUpdatedDescription": "Le nœud a été mis à jour.", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", + "siteRestartTitle": "Redémarrer Site", + "siteRestartDescription": "Redémarrer le tunnel WireGuard pour ce site. Cela interrompra brièvement la connectivité.", + "siteRestartBody": "Utilisez cela si le tunnel du site ne fonctionne pas correctement et que vous souhaitez forcer une reconnexion sans redémarrer l'hôte.", + "siteRestartButton": "Redémarrer Site", + "siteRestartDialogMessage": "Êtes-vous sûr de vouloir redémarrer le tunnel WireGuard pour {name}? Le site perdra brièvement sa connectivité.", + "siteRestartWarning": "Le site sera brièvement déconnecté pendant le redémarrage du tunnel.", + "siteRestarted": "Site redémarré", + "siteRestartedDescription": "Le tunnel WireGuard a été redémarré.", + "siteErrorRestart": "Échec du redémarrage du site", + "siteErrorRestartDescription": "Une erreur s'est produite lors du redémarrage du site.", "siteSettingDescription": "Configurer les paramètres du site", "siteResourcesTab": "Ressources", "siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Appliquer la Config", "actionListBlueprints": "Lister les plans", "actionGetBlueprint": "Obtenez un plan", + "actionCreateOrgWideLauncherView": "Créer une vue de lancement au niveau de l'organisation", "setupToken": "Jeton de configuration", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sous-réseau", "addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.", "selectSites": "Sélectionner des sites", + "selectLabels": "Sélectionner des étiquettes", "sitesDescription": "Le client aura une connectivité vers les sites sélectionnés", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Faites fonctionner Olm sur votre système", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Sélectionner un site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}", "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nœuds distants", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Clé secrète", + "remoteExitNodeNetworkingTitle": "Paramètres du réseau", + "remoteExitNodeNetworkingDescription": "Configurez comment ce nœud de sortie distant acheminera le trafic et quels sites préfèrent se connecter via ce dernier. Fonctions avancées à utiliser avec les configurations réseau de retour.", + "remoteExitNodeNetworkingSave": "Enregistrer les paramètres", + "remoteExitNodeNetworkingSaveSuccessTitle": "Paramètres du réseau enregistrés", + "remoteExitNodeNetworkingSaveSuccessDescription": "Les paramètres du réseau ont été mis à jour avec succès.", + "remoteExitNodeNetworkingSaveError": "Échec de l'enregistrement des paramètres du réseau", + "remoteExitNodeNetworkingSubnetsTitle": "Sous-réseaux distants", + "remoteExitNodeNetworkingSubnetsDescription": "Définissez les plages CIDR que ce nœud de sortie distant acheminera. Saisissez un CIDR valide (par exemple 10.0.0.0/8) et appuyez sur Entrée pour ajouter.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux", + "remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences", + "remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.", + "remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...", + "remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes", "remoteExitNodeCreate": { "title": "Créer un nœud distant", "description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sous-réseau", + "utilitySubnet": "Routeur utilitaire", "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", "customDomain": "Domaine personnalisé", "authPage": "Pages d'authentification", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Liste blanche des e-mails", "memberPortalResourceDisabled": "Ressource désactivée", "memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources", + "resourceLauncherTitle": "Lanceur de ressources", + "resourceLauncherDescription": "Afficher les détails des ressources et les lancer depuis un seul endroit", + "resourceLauncherSearchPlaceholder": "Rechercher sur tous les sites...", + "resourceLauncherDefaultView": "Par défaut", + "resourceLauncherSaveView": "Enregistrer la vue", + "resourceLauncherSaveToCurrentView": "Enregistrer dans la vue actuelle", + "resourceLauncherResetView": "Réinitialiser la vue", + "resourceLauncherSaveAsNewView": "Enregistrer comme nouvelle vue", + "resourceLauncherSaveAsNewViewDescription": "Donnez un nom à cette vue pour enregistrer vos filtres et mise en page actuels.", + "resourceLauncherSaveForEveryone": "Enregistrer pour tout le monde", + "resourceLauncherSaveForEveryoneDescription": "Partagez cette vue avec tous les membres de l'organisation. Lorsque décochée, la vue est visible uniquement par vous.", + "resourceLauncherMakePersonal": "Rendre personnel", + "resourceLauncherFilter": "Filtrer", + "resourceLauncherSort": "Trier", + "resourceLauncherSortAscending": "Trier par ordre croissant", + "resourceLauncherSortDescending": "Trier par ordre décroissant", + "resourceLauncherSettings": "Réglages", + "resourceLauncherGroupBy": "Grouper par", + "resourceLauncherGroupBySite": "Nœud", + "resourceLauncherGroupByLabel": "Étiquette", + "resourceLauncherLayout": "Mise en page", + "resourceLauncherLayoutGrid": "Grille", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Afficher les étiquettes", + "resourceLauncherShowSiteTags": "Afficher les tags de site", + "resourceLauncherShowRecents": "Afficher les récents", + "resourceLauncherDeleteView": "Supprimer la vue", + "resourceLauncherViewAsAdmin": "Voir en tant qu'admin", + "resourceLauncherResourceDetailsDescription": "Afficher les détails pour cette ressource.", + "resourceLauncherUnlabeled": "Non étiqueté", + "resourceLauncherNoSite": "Aucun nœud", + "resourceLauncherNoResourcesInGroup": "Aucune ressource dans ce groupe", + "resourceLauncherEmptyStateTitle": "Aucune ressource disponible", + "resourceLauncherEmptyStateDescription": "Vous n'avez pas encore accès à des ressources. Contactez votre administrateur pour demander l'accès.", + "resourceLauncherEmptyStateNoResultsTitle": "Aucune ressource trouvée", + "resourceLauncherEmptyStateNoResultsDescription": "Aucune ressource ne correspond à votre recherche ou filtre actuel. Essayez de les ajuster pour trouver ce que vous cherchez.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster votre recherche ou de supprimer les filtres pour voir toutes les ressources.", + "resourceLauncherCopiedToClipboard": "Copié dans le presse-papiers", + "resourceLauncherCopiedAccessDescription": "L'accès à la ressource a été copié dans votre presse-papiers.", + "resourceLauncherViewNamePlaceholder": "Nom de la vue", + "resourceLauncherViewNameLabel": "Nom de la vue", + "resourceLauncherViewSaved": "Vue enregistrée", + "resourceLauncherViewSavedDescription": "Votre vue de lancement a été enregistrée.", + "resourceLauncherViewSaveFailed": "Échec de l'enregistrement de la vue", + "resourceLauncherViewSaveFailedDescription": "Impossible d'enregistrer la vue de lancement. Veuillez réessayer.", + "resourceLauncherViewDeleted": "Vue supprimée", + "resourceLauncherViewDeletedDescription": "La vue de lancement a été supprimée.", + "resourceLauncherViewDeleteFailed": "Impossible de supprimer la vue", + "resourceLauncherViewDeleteFailedDescription": "Impossible de supprimer la vue de lancement. Veuillez réessayer.", "memberPortalPrevious": "Précédent", "memberPortalNext": "Suivant", "httpSettings": "Paramètres HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Une clé privée est requise", "vncTitle": "VNC", - "vncSignInDescription": "Entrez votre mot de passe VNC pour vous connecter", + "vncSignInDescription": "Entrez vos identifiants VNC pour vous connecter", + "vncUsernameOptional": "Nom d'utilisateur (optionnel)", "vncPasswordOptional": "Mot de passe (facultatif)", "vncNoResourceTarget": "Aucune cible de ressource disponible", "vncFailedToLoadNovnc": "Échec du chargement de noVNC", From 6181d46a1ec1a4dcf768843714fa0cf08f16ebf2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:27:53 -0400 Subject: [PATCH 63/79] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index 7895b15e5..6903dac34 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -123,6 +123,16 @@ "siteUpdated": "Sitio actualizado", "siteUpdatedDescription": "El sitio ha sido actualizado.", "siteGeneralDescription": "Configurar la configuración general de este sitio", + "siteRestartTitle": "Reiniciar Sitio", + "siteRestartDescription": "Reinicia el túnel WireGuard para este sitio. Esto interrumpirá brevemente la conectividad.", + "siteRestartBody": "Utiliza esto si el túnel del sitio no está funcionando correctamente y quieres forzar una reconexión sin reiniciar el host.", + "siteRestartButton": "Reiniciar Sitio", + "siteRestartDialogMessage": "¿Estás seguro de que deseas reiniciar el túnel WireGuard para {name}? El sitio perderá conectividad brevemente.", + "siteRestartWarning": "El sitio se desconectará brevemente mientras se reinicia el túnel.", + "siteRestarted": "Sitio reiniciado", + "siteRestartedDescription": "El túnel WireGuard ha sido reiniciado.", + "siteErrorRestart": "Error al reiniciar el sitio", + "siteErrorRestartDescription": "Se ha producido un error al reiniciar el sitio.", "siteSettingDescription": "Configurar los ajustes en el sitio", "siteResourcesTab": "Recursos", "siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Aplicar plano", "actionListBlueprints": "Listar blueprints", "actionGetBlueprint": "Obtener blueprint", + "actionCreateOrgWideLauncherView": "Crear Vista de Lanzador para toda la Organización", "setupToken": "Configuración de token", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subred", "addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.", "selectSites": "Seleccionar sitios", + "selectLabels": "Seleccionar etiquetas", "sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Sitio", "selectSite": "Seleccionar sitio...", "multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nodos remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secreto", + "remoteExitNodeNetworkingTitle": "Ajustes de Red", + "remoteExitNodeNetworkingDescription": "Configura cómo este nodo de salida remoto dirige el tráfico y qué sitios prefieren conectarse a través de él. Características avanzadas para usar con configuraciones de red de retroceso.", + "remoteExitNodeNetworkingSave": "Guardar Ajustes", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ajustes de red guardados", + "remoteExitNodeNetworkingSaveSuccessDescription": "Los ajustes de red han sido actualizados exitosamente.", + "remoteExitNodeNetworkingSaveError": "Error al guardar los ajustes de red", + "remoteExitNodeNetworkingSubnetsTitle": "Subredes Remotas", + "remoteExitNodeNetworkingSubnetsDescription": "Define los rangos CIDR a los que este nodo de salida remoto dirigirá el tráfico. Escribe un CIDR válido (e.g. 10.0.0.0/8) y presiona Enter para añadir.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes", + "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias", + "remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...", + "remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas", "remoteExitNodeCreate": { "title": "Crear nodo remoto", "description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subred", + "utilitySubnet": "Subred de Utilidad", "subnetDescription": "La subred para la configuración de red de esta organización.", "customDomain": "Dominio personalizado", "authPage": "Páginas de autenticación", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista Blanca de Correo", "memberPortalResourceDisabled": "Recurso Deshabilitado", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "resourceLauncherTitle": "Lanzador de Recursos", + "resourceLauncherDescription": "Ve los detalles de los recursos y lánzalos desde un solo lugar", + "resourceLauncherSearchPlaceholder": "Buscar en todos los sitios...", + "resourceLauncherDefaultView": "Predeterminado", + "resourceLauncherSaveView": "Guardar Vista", + "resourceLauncherSaveToCurrentView": "Guardar en la Vista Actual", + "resourceLauncherResetView": "Restablecer Vista", + "resourceLauncherSaveAsNewView": "Guardar como Nueva Vista", + "resourceLauncherSaveAsNewViewDescription": "Ponle un nombre a esta vista para guardar tus filtros y diseño actuales.", + "resourceLauncherSaveForEveryone": "Guardar para Todos", + "resourceLauncherSaveForEveryoneDescription": "Comparte esta vista con todos los miembros de la organización. Si está desmarcado, la vista solo es visible para ti.", + "resourceLauncherMakePersonal": "Hacer Personal", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordenar", + "resourceLauncherSortAscending": "Ordenar Ascendente", + "resourceLauncherSortDescending": "Ordenar Descendente", + "resourceLauncherSettings": "Ajustes", + "resourceLauncherGroupBy": "Agrupar Por", + "resourceLauncherGroupBySite": "Sitio", + "resourceLauncherGroupByLabel": "Etiqueta", + "resourceLauncherLayout": "Disposición", + "resourceLauncherLayoutGrid": "Cuadrícula", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostrar Etiquetas", + "resourceLauncherShowSiteTags": "Mostrar Etiquetas del Sitio", + "resourceLauncherShowRecents": "Mostrar Recientes", + "resourceLauncherDeleteView": "Eliminar Vista", + "resourceLauncherViewAsAdmin": "Ver como Administrador", + "resourceLauncherResourceDetailsDescription": "Ver detalles de este recurso.", + "resourceLauncherUnlabeled": "Sin Etiqueta", + "resourceLauncherNoSite": "Sin Sitio", + "resourceLauncherNoResourcesInGroup": "No hay recursos en este grupo", + "resourceLauncherEmptyStateTitle": "No hay Recursos Disponibles", + "resourceLauncherEmptyStateDescription": "Todavía no tienes acceso a ningún recurso. Contacta a tu administrador para solicitar acceso.", + "resourceLauncherEmptyStateNoResultsTitle": "No se Encontraron Recursos", + "resourceLauncherEmptyStateNoResultsDescription": "No hay recursos que coincidan con tu búsqueda o filtros actuales. Intenta ajustarlos para encontrar lo que buscas.", + "resourceLauncherEmptyStateNoResultsWithQuery": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tu búsqueda o borrar filtros para ver todos los recursos.", + "resourceLauncherCopiedToClipboard": "Copiado al portapapeles", + "resourceLauncherCopiedAccessDescription": "El acceso al recurso ha sido copiado a tu portapapeles.", + "resourceLauncherViewNamePlaceholder": "Nombre de la Vista", + "resourceLauncherViewNameLabel": "Nombre de la Vista", + "resourceLauncherViewSaved": "Vista guardada", + "resourceLauncherViewSavedDescription": "Tu vista del lanzador ha sido guardada.", + "resourceLauncherViewSaveFailed": "Error al guardar la vista", + "resourceLauncherViewSaveFailedDescription": "No se pudo guardar la vista del lanzador. Por favor, intenta de nuevo.", + "resourceLauncherViewDeleted": "Vista eliminada", + "resourceLauncherViewDeletedDescription": "La vista del lanzador ha sido eliminada.", + "resourceLauncherViewDeleteFailed": "Error al eliminar la vista", + "resourceLauncherViewDeleteFailedDescription": "No se pudo eliminar la vista del lanzador. Por favor, intenta de nuevo.", "memberPortalPrevious": "Anterior", "memberPortalNext": "Siguiente", "httpSettings": "Configuración HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----", "sshPrivateKeyRequired": "Se requiere clave privada", "vncTitle": "VNC", - "vncSignInDescription": "Introduce tu contraseña VNC para conectar", + "vncSignInDescription": "Introduce tus credenciales VNC para conectarte", + "vncUsernameOptional": "Nombre de usuario (opcional)", "vncPasswordOptional": "Contraseña (opcional)", "vncNoResourceTarget": "No hay objetivo de recurso disponible", "vncFailedToLoadNovnc": "Error al cargar noVNC", From 064252586d9ca40a58e3a00e29688fb2225cce5a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:27:55 -0400 Subject: [PATCH 64/79] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 6cefe4a4d..9add9e04d 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -123,6 +123,16 @@ "siteUpdated": "Сайтът е обновен", "siteUpdatedDescription": "Сайтът е актуализиран.", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", + "siteRestartTitle": "Рестартирайте Сайта", + "siteRestartDescription": "Рестартирайте WireGuard тунела за този сайт. Това ще прекъсне връзката за кратко.", + "siteRestartBody": "Използвайте това, ако тунелът на сайта не функционира правилно и искате да принудите повторно свързване без да рестартирате хоста.", + "siteRestartButton": "Рестартирайте Сайта", + "siteRestartDialogMessage": "Сигурни ли сте, че искате да рестартирате WireGuard тунела за {name}? Сайтът ще изгуби връзка за кратко.", + "siteRestartWarning": "Сайтът ще се изключи за кратко, докато тунелът се рестартира.", + "siteRestarted": "Сайтът е рестартиран", + "siteRestartedDescription": "WireGuard тунелът е рестартиран.", + "siteErrorRestart": "Неуспешно рестартиране на сайта", + "siteErrorRestartDescription": "Възникна грешка при рестартирането на сайта.", "siteSettingDescription": "Конфигурирайте настройките на сайта", "siteResourcesTab": "Ресурси", "siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Приложи Чернова", "actionListBlueprints": "Списък с планове.", "actionGetBlueprint": "Вземи план.", + "actionCreateOrgWideLauncherView": "Създайте Изглед на Стартирача за Цялата Организация", "setupToken": "Конфигурация на токен", "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Мрежа", "addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.", "selectSites": "Избор на сайтове", + "selectLabels": "Изберете етикети", "sitesDescription": "Клиентът ще има връзка с избраните сайтове", "clientInstallOlm": "Инсталиране на Olm", "clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Сайт", "selectSite": "Изберете сайт...", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}", + "labelsSelectorLabelsCount": "{count, plural, one {# етикет} other {# етикета}}", "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Отдалечени възли", "remoteExitNodeId": "ID.", "remoteExitNodeSecretKey": "Секретен ключ.", + "remoteExitNodeNetworkingTitle": "Настройки на Мрежата", + "remoteExitNodeNetworkingDescription": "Настройте как този отдалечен край маршрутизира трафика и кои сайтове предпочитат да се свържат чрез него. Усъвършенствани функции за използване при конфигурации на бекаул мрежи.", + "remoteExitNodeNetworkingSave": "Запазване на Настройките", + "remoteExitNodeNetworkingSaveSuccessTitle": "Настройките на мрежата са успешно запазени", + "remoteExitNodeNetworkingSaveSuccessDescription": "Настройките на мрежата бяха успешно обновени.", + "remoteExitNodeNetworkingSaveError": "Неуспешно запазване на мрежовите настройки", + "remoteExitNodeNetworkingSubnetsTitle": "Отдалечени Подмрежи", + "remoteExitNodeNetworkingSubnetsDescription": "Определете CIDR диапазоните, които този отдалечен край ще маршрутизира трафика към. Въведете валиден CIDR (e.g. 10.0.0.0/8) и натиснете Enter, за да добавите.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи", + "remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания", + "remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.", + "remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...", + "remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети", "remoteExitNodeCreate": { "title": "Създаване на отдалечен възел.", "description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC доставчик", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик", "subnet": "Подмрежа", + "utilitySubnet": "Мрежа на Помощни Подмрежи", "subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.", "customDomain": "Персонализиран домейн.", "authPage": "Страници за автентификация.", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Бял списък на имейли", "memberPortalResourceDisabled": "Ресурсът е деактивиран", "memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси", + "resourceLauncherTitle": "Стартер за Ресурси", + "resourceLauncherDescription": "Преглеждайте детайлите на ресурса и ги стартирайте от едно място", + "resourceLauncherSearchPlaceholder": "Търсете във всички сайтове...", + "resourceLauncherDefaultView": "По Подразбиране", + "resourceLauncherSaveView": "Запазете Изгледа", + "resourceLauncherSaveToCurrentView": "Запазете в Текущ Изглед", + "resourceLauncherResetView": "Нулирайте Изгледа", + "resourceLauncherSaveAsNewView": "Запазете като Нов Изглед", + "resourceLauncherSaveAsNewViewDescription": "Дайте име на този изглед, за да запазите текущите си филтри и оформление.", + "resourceLauncherSaveForEveryone": "Запазете за Всеки", + "resourceLauncherSaveForEveryoneDescription": "Споделете този изглед с всички членове на организацията. Когато е изключено, изгледът е видим само за вас.", + "resourceLauncherMakePersonal": "Направи Личен", + "resourceLauncherFilter": "Филтър", + "resourceLauncherSort": "Сортиране", + "resourceLauncherSortAscending": "Сортиране възходящо", + "resourceLauncherSortDescending": "Сортиране низходящо", + "resourceLauncherSettings": "Настройки", + "resourceLauncherGroupBy": "Групирай По", + "resourceLauncherGroupBySite": "Сайт", + "resourceLauncherGroupByLabel": "Етикет", + "resourceLauncherLayout": "Оформление", + "resourceLauncherLayoutGrid": "Мрежа", + "resourceLauncherLayoutList": "Списък", + "resourceLauncherShowLabels": "Показване на Етикети", + "resourceLauncherShowSiteTags": "Показване на Тагове на Сайт", + "resourceLauncherShowRecents": "Показване на Последни", + "resourceLauncherDeleteView": "Изтриване на Изглед", + "resourceLauncherViewAsAdmin": "Вижте като Админ", + "resourceLauncherResourceDetailsDescription": "Вижте детайлите за този ресурс.", + "resourceLauncherUnlabeled": "Без Етикет", + "resourceLauncherNoSite": "Няма Сайт", + "resourceLauncherNoResourcesInGroup": "Няма ресурси в тази група", + "resourceLauncherEmptyStateTitle": "Няма Налични Ресурси", + "resourceLauncherEmptyStateDescription": "Все още нямате достъп до никакви ресурси. Свържете се с вашия администратор, за да поискате достъп.", + "resourceLauncherEmptyStateNoResultsTitle": "Няма Намерени Ресурси", + "resourceLauncherEmptyStateNoResultsDescription": "Никакви ресурси не съвпадат с текущото ви търсене или филтри. Опитайте да ги коригирате, за да намерите това, което търсите.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Никакви ресурси не съвпадат с \"{query}\". Опитайте да коригирате търсенето или да изтриете филтри, за да видите всички ресурси.", + "resourceLauncherCopiedToClipboard": "Копирано в клипборда", + "resourceLauncherCopiedAccessDescription": "Достъпът до ресурса е копиран на вашия клипборд.", + "resourceLauncherViewNamePlaceholder": "Име на Изгледа", + "resourceLauncherViewNameLabel": "Име на Изгледа", + "resourceLauncherViewSaved": "Изгледът е запазен", + "resourceLauncherViewSavedDescription": "Вашият изглед на стартер е запазен.", + "resourceLauncherViewSaveFailed": "Неуспешно запазване на изгледа", + "resourceLauncherViewSaveFailedDescription": "Не можеше да се запази изгледът на стартер. Моля, опитайте отново.", + "resourceLauncherViewDeleted": "Изгледът е изтрит", + "resourceLauncherViewDeletedDescription": "Изгледът на стартер е изтрит.", + "resourceLauncherViewDeleteFailed": "Неуспешно изтриване на изгледа", + "resourceLauncherViewDeleteFailedDescription": "Не можахте да изтриете изгледа на стартер. Моля, опитайте отново.", "memberPortalPrevious": "Предишен", "memberPortalNext": "Следващ", "httpSettings": "HTTP настройки", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----", "sshPrivateKeyRequired": "Изисква се частен ключ", "vncTitle": "VNC", - "vncSignInDescription": "Въведете вашата VNC парола за свързване", + "vncSignInDescription": "Въведете VNC данните си за връзка", + "vncUsernameOptional": "Потребителско име (по избор)", "vncPasswordOptional": "Парола (по избор)", "vncNoResourceTarget": "Не е налична цел за ресурса", "vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC", From 15f36096efd74aa73d29c1ef51886ead9654dcf4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:27:57 -0400 Subject: [PATCH 65/79] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 9a175093e..ba5203938 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -123,6 +123,16 @@ "siteUpdated": "Lokalita upravena", "siteUpdatedDescription": "Lokalita byla upravena.", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", + "siteRestartTitle": "Restartovat lokalitu", + "siteRestartDescription": "Restartujte tunel WireGuard pro tuto lokalitu. To krátce přeruší konektivitu.", + "siteRestartBody": "Použijte to, pokud tunel lokality nefunguje správně a chcete vynutit opětovné připojení bez restartování hostitele.", + "siteRestartButton": "Restartovat lokalitu", + "siteRestartDialogMessage": "Opravdu chcete restartovat WireGuard tunel pro {name}? Lokalita krátce ztratí konektivitu.", + "siteRestartWarning": "Lokalita bude krátce odpojena, zatímco se tunel restartuje.", + "siteRestarted": "Lokalita restartována", + "siteRestartedDescription": "Tunel WireGuard byl restartován.", + "siteErrorRestart": "Nepodařilo se restartovat lokalitu", + "siteErrorRestartDescription": "Při restartování lokality došlo k chybě.", "siteSettingDescription": "Konfigurace nastavení na webu", "siteResourcesTab": "Zdroje", "siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Použít plán", "actionListBlueprints": "Seznam šablon", "actionGetBlueprint": "Získat šablonu", + "actionCreateOrgWideLauncherView": "Vytvořit organizační pohled", "setupToken": "Nastavit token", "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Podsíť", "addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.", "selectSites": "Vyberte stránky", + "selectLabels": "Vyberte názvy", "sitesDescription": "Klient bude mít připojení k vybraným webům", "clientInstallOlm": "Nainstalovat Olm", "clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Lokalita", "selectSite": "Vybrat lokalitu...", "multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}", + "labelsSelectorLabelsCount": "{count, plural, one {# název} few {# názvy} many {# názvů} other {# názvů}}", "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Vzdálené uzly", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Tajný klíč", + "remoteExitNodeNetworkingTitle": "Nastavení sítě", + "remoteExitNodeNetworkingDescription": "Nastavte, jak tento vzdálený výstupní uzel směruje provoz a které lokality se mají připojit přes něj. Pokročilé funkce pro použití s konfiguracemi zpětné sítě.", + "remoteExitNodeNetworkingSave": "Uložit nastavení", + "remoteExitNodeNetworkingSaveSuccessTitle": "Nastavení sítě bylo úspěšně uloženo", + "remoteExitNodeNetworkingSaveSuccessDescription": "Nastavení sítě bylo úspěšně aktualizováno.", + "remoteExitNodeNetworkingSaveError": "Nepodařilo se uložit nastavení sítě", + "remoteExitNodeNetworkingSubnetsTitle": "Dálkové podsítě", + "remoteExitNodeNetworkingSubnetsDescription": "Definujte rozsahy CIDR, ke kterým tento vzdálený výstupní uzel bude směrovat provoz. Zadejte platné CIDR (např. 10.0.0.0/8) a stiskněte Enter pro přidání.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě", + "remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí", + "remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.", + "remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...", + "remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy", "remoteExitNodeCreate": { "title": "Vytvořit vzdálený uzel", "description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsíť", + "utilitySubnet": "Nástrojová podsíť", "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", "customDomain": "Vlastní doména", "authPage": "Autentizační stránky", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Seznam povolených emailů", "memberPortalResourceDisabled": "Zdroj je zakázán", "memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů", + "resourceLauncherTitle": "Spouštěč zdrojů", + "resourceLauncherDescription": "Podívejte se na podrobnosti o zdrojích a spusťte je z jednoho místa", + "resourceLauncherSearchPlaceholder": "Hledat všechny lokality...", + "resourceLauncherDefaultView": "Výchozí", + "resourceLauncherSaveView": "Uložit pohled", + "resourceLauncherSaveToCurrentView": "Uložit do aktuálního pohledu", + "resourceLauncherResetView": "Obnovit pohled", + "resourceLauncherSaveAsNewView": "Uložit jako nový pohled", + "resourceLauncherSaveAsNewViewDescription": "Uložte tento pohled k uloženému filtrování a rozvržení.", + "resourceLauncherSaveForEveryone": "Uložit pro všechny", + "resourceLauncherSaveForEveryoneDescription": "Sdílejte tento pohled se všemi členy organizace. Pokud není zaškrtnuto, pohled je viditelný pouze vám.", + "resourceLauncherMakePersonal": "Udělat osobní", + "resourceLauncherFilter": "Filtr", + "resourceLauncherSort": "Řadit", + "resourceLauncherSortAscending": "Řadit vzestupně", + "resourceLauncherSortDescending": "Řadit sestupně", + "resourceLauncherSettings": "Nastavení", + "resourceLauncherGroupBy": "Seskupit podle", + "resourceLauncherGroupBySite": "Lokalita", + "resourceLauncherGroupByLabel": "Název", + "resourceLauncherLayout": "Rozvržení", + "resourceLauncherLayoutGrid": "Mřížka", + "resourceLauncherLayoutList": "Seznam", + "resourceLauncherShowLabels": "Zobrazit název", + "resourceLauncherShowSiteTags": "Zobrazit značky lokality", + "resourceLauncherShowRecents": "Zobrazit nedávné", + "resourceLauncherDeleteView": "Smazat pohled", + "resourceLauncherViewAsAdmin": "Zobrazit jako administrátor", + "resourceLauncherResourceDetailsDescription": "Podívejte se na podrobnosti o tomto zdroji.", + "resourceLauncherUnlabeled": "Bez nálepky", + "resourceLauncherNoSite": "Žádná lokalita", + "resourceLauncherNoResourcesInGroup": "V této skupině nejsou žádné zdroje", + "resourceLauncherEmptyStateTitle": "Žádné dostupné zdroje", + "resourceLauncherEmptyStateDescription": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého administrátora, abyste požádali o přístup.", + "resourceLauncherEmptyStateNoResultsTitle": "Nebyl nalezen žádný zdroj", + "resourceLauncherEmptyStateNoResultsDescription": "Žádný zdroj neodpovídá vašemu aktuálnímu vyhledávání nebo filtrům. Zkuste je upravit, abyste našli to, co hledáte.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Žádné zdroje neodpovídají \"{query}\". Zkuste upravit vyhledávání nebo vymazat filtry, abyste viděli všechny zdroje.", + "resourceLauncherCopiedToClipboard": "Zkopírováno do schránky", + "resourceLauncherCopiedAccessDescription": "Přístup ke zdroji byl zkopírován do vaší schránky.", + "resourceLauncherViewNamePlaceholder": "Název pohledu", + "resourceLauncherViewNameLabel": "Název pohledu", + "resourceLauncherViewSaved": "Pohled uložen", + "resourceLauncherViewSavedDescription": "Váš spouštěcí pohled byl uložen.", + "resourceLauncherViewSaveFailed": "Nepodařilo se uložit pohled", + "resourceLauncherViewSaveFailedDescription": "Nepodařilo se uložit spouštěcí pohled. Prosím zkuste to znovu.", + "resourceLauncherViewDeleted": "Pohled smazán", + "resourceLauncherViewDeletedDescription": "Spouštěcí pohled byl smazán.", + "resourceLauncherViewDeleteFailed": "Nepodařilo se smazat pohled", + "resourceLauncherViewDeleteFailedDescription": "Nepodařilo se smazat spouštěcí pohled. Prosím zkuste to znovu.", "memberPortalPrevious": "Předchozí", "memberPortalNext": "Následující", "httpSettings": "Nastavení HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----ZAČÁTEK SOUKROMÉHO KLÍČE OPENSSH-----", "sshPrivateKeyRequired": "Je vyžadován soukromý klíč", "vncTitle": "VNC", - "vncSignInDescription": "Zadejte své heslo VNC pro připojení", + "vncSignInDescription": "Zadejte své VNC přihlašovací údaje pro připojení", + "vncUsernameOptional": "Uživatelské jméno (nepovinné)", "vncPasswordOptional": "Heslo (nepovinné)", "vncNoResourceTarget": "Není k dispozici žádný cíl zdroje", "vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC", From 47eff8c948cf8bca38607daccf5c61402769cac8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:27:58 -0400 Subject: [PATCH 66/79] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index b9db7f323..81a1177df 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -123,6 +123,16 @@ "siteUpdated": "Site opdateret", "siteUpdatedDescription": "Sitet er blevet opdateret.", "siteGeneralDescription": "Konfigurer de generelle indstillinger for dette site", + "siteRestartTitle": "Genstart Site", + "siteRestartDescription": "Genstart WireGuard-tunnelen for dette site. Dette vil kortvarigt afbryde forbindelsen.", + "siteRestartBody": "Brug dette, hvis site-tunnelen ikke fungerer korrekt, og du vil tvinge en genforbindelse uden at genstarte værten.", + "siteRestartButton": "Genstart Site", + "siteRestartDialogMessage": "Er du sikker på, at du vil genstarte WireGuard-tunnelen for {name}? Sitet vil kortvarigt miste forbindelsen.", + "siteRestartWarning": "Sitet vil kortvarigt afbryde forbindelse, mens tunnelen genstarter.", + "siteRestarted": "Site genstartet", + "siteRestartedDescription": "WireGuard-tunnelen er blevet genstartet.", + "siteErrorRestart": "Kunne ikke genstarte site", + "siteErrorRestartDescription": "En fejl opstod, mens sitet blev genstartet.", "siteSettingDescription": "Konfigurer indstillingerne for sitet", "siteResourcesTab": "Ressourcer", "siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Brug blueprint", "actionListBlueprints": "Vis blueprints", "actionGetBlueprint": "Hent blueprint", + "actionCreateOrgWideLauncherView": "Opret org-dækkende launcher-visning", "setupToken": "Opsætningstoken", "setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.", "setupTokenRequired": "Opsætningstoken er nødvendig", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.", "selectSites": "Vælg sites", + "selectLabels": "Vælg etiketter", "sitesDescription": "Klienten vil have forbindelse til de valgte områdene", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Få Olm til at køre på dit system", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Websted", "selectSite": "Vælg site...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketter}}", "noSitesFound": "Ingen sites fundet.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Eksterne noder", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sikkerhedsnøgle", + "remoteExitNodeNetworkingTitle": "Netværksindstillinger", + "remoteExitNodeNetworkingDescription": "Konfigurér, hvordan denne fjerne exit-node skal dirigere trafik, og hvilke sites der foretrækkes at oprette forbindelse gennem den. Avancerede funktioner til brug med backhaul-netværkskonfigurationer.", + "remoteExitNodeNetworkingSave": "Gem indstillinger", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netværksindstillinger gemt", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netværksindstillinger er blevet opdateret.", + "remoteExitNodeNetworkingSaveError": "Kunne ikke gemme netværksindstillinger", + "remoteExitNodeNetworkingSubnetsTitle": "Fjern Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Definér de CIDR-områder, som denne fjerne exit-node vil dirigere trafik til. Indtast en gyldig CIDR (f.eks. 10.0.0.0/8) og tryk Enter for at tilføje.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets", + "remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter", + "remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.", + "remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...", + "remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter", "remoteExitNodeCreate": { "title": "Opret ekstern node", "description": "Opret en ny selvhostet ekstern relay- og proxyservernode", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC udbyder", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "subnet": "Subnet", + "utilitySubnet": "Forsynings subnet", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "customDomain": "Brugerdefineret domæne", "authPage": "Autentiseringssider", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-mailwhitelist", "memberPortalResourceDisabled": "Ressource deaktiveret", "memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer", + "resourceLauncherTitle": "Ressource Starter", + "resourceLauncherDescription": "Se ressource detaljer og start dem fra ét sted", + "resourceLauncherSearchPlaceholder": "Søg i alle sites...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Gem Visning", + "resourceLauncherSaveToCurrentView": "Gem til nuværende visning", + "resourceLauncherResetView": "Nulstil Visning", + "resourceLauncherSaveAsNewView": "Gem som Ny Visning", + "resourceLauncherSaveAsNewViewDescription": "Giv denne visning et navn for at gemme dine nuværende filtre og layout.", + "resourceLauncherSaveForEveryone": "Gem for Alle", + "resourceLauncherSaveForEveryoneDescription": "Del denne visning med alle organisationsmedlemmer. Når den er ikke markeret, er visningen kun synlig for dig.", + "resourceLauncherMakePersonal": "Gør Personlig", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sortér", + "resourceLauncherSortAscending": "Sortér stigende", + "resourceLauncherSortDescending": "Sortér faldende", + "resourceLauncherSettings": "Indstillinger", + "resourceLauncherGroupBy": "Gruppér Efter", + "resourceLauncherGroupBySite": "Websted", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Gitter", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Vis Etiketter", + "resourceLauncherShowSiteTags": "Vis Site Tags", + "resourceLauncherShowRecents": "Vis Seneste", + "resourceLauncherDeleteView": "Slet Visning", + "resourceLauncherViewAsAdmin": "Vis som Admin", + "resourceLauncherResourceDetailsDescription": "Se detaljer for denne ressource.", + "resourceLauncherUnlabeled": "Uden Etiket", + "resourceLauncherNoSite": "Ingen Site", + "resourceLauncherNoResourcesInGroup": "Ingen ressourcer i denne gruppe", + "resourceLauncherEmptyStateTitle": "Ingen ressourcer tilgængelige", + "resourceLauncherEmptyStateDescription": "Du har endnu ikke adgang til nogen ressourcer. Kontakt din administrator for at anmode om adgang.", + "resourceLauncherEmptyStateNoResultsTitle": "Ingen ressourcer fundet", + "resourceLauncherEmptyStateNoResultsDescription": "Ingen ressourcer matcher din nuværende søgning eller filtre. Prøv at justere dem for at finde det, du leder efter.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressourcer matcher \"{query}\". Prøv at justere din søgning eller rydde filtre for at se alle ressourcer.", + "resourceLauncherCopiedToClipboard": "Kopieret til udklipsholderen", + "resourceLauncherCopiedAccessDescription": "Ressourcetilgangen er kopieret til din udklipsholder.", + "resourceLauncherViewNamePlaceholder": "Vis navn", + "resourceLauncherViewNameLabel": "Vis Navn", + "resourceLauncherViewSaved": "Vis Gemt", + "resourceLauncherViewSavedDescription": "Din launcher-visning er blevet gemt.", + "resourceLauncherViewSaveFailed": "Kunne ikke gemme visning", + "resourceLauncherViewSaveFailedDescription": "Kunne ikke gemme launcher-visningen. Prøv venligst igen.", + "resourceLauncherViewDeleted": "Visning slået væk", + "resourceLauncherViewDeletedDescription": "Launcher-visningen er blevet slettet.", + "resourceLauncherViewDeleteFailed": "Kunne ikke slette visning", + "resourceLauncherViewDeleteFailedDescription": "Kan ikke slette launcher-visningen. Prøv venligst igen.", "memberPortalPrevious": "Forrige", "memberPortalNext": "Næste", "httpSettings": "HTTP Indstillinger", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØGLE-----", "sshPrivateKeyRequired": "Privat nøgle er påkrævet", "vncTitle": "VNC", - "vncSignInDescription": "Indtast VNC-adgangskoden for at oprette forbindelse til", + "vncSignInDescription": "Indtast dine VNC-legitimationsoplysninger for at oprette forbindelse", + "vncUsernameOptional": "Brugernavn (valgfrit)", "vncPasswordOptional": "Adgangskode (valgfrit)", "vncNoResourceTarget": "Intet ressourcemål tilgængeligt", "vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC", From 56266e3b62bcc3e9dc1a4d5c84820d885c90e240 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:00 -0400 Subject: [PATCH 67/79] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 38e8fafc4..ad39fda09 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -123,6 +123,16 @@ "siteUpdated": "Standort aktualisiert", "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", + "siteRestartTitle": "Standort neu starten", + "siteRestartDescription": "Starten Sie den WireGuard-Tunnel für diesen Standort neu. Dies wird die Konnektivität kurzzeitig unterbrechen.", + "siteRestartBody": "Verwenden Sie dies, wenn der Standort-Tunnel nicht ordnungsgemäß funktioniert und Sie eine erneute Verbindung erzwingen möchten, ohne den Host neu zu starten.", + "siteRestartButton": "Standort neu starten", + "siteRestartDialogMessage": "Sind Sie sicher, dass Sie den WireGuard-Tunnel für {name} neu starten möchten? Der Standort wird kurzzeitig die Konnektivität verlieren.", + "siteRestartWarning": "Der Standort wird kurzzeitig getrennt, während der Tunnel neu gestartet wird.", + "siteRestarted": "Standort neu gestartet", + "siteRestartedDescription": "Der WireGuard-Tunnel wurde neu gestartet.", + "siteErrorRestart": "Fehler beim Neustart des Standorts", + "siteErrorRestartDescription": "Ein Fehler ist aufgetreten, während der Standort neu gestartet wurde.", "siteSettingDescription": "Standorteinstellungen konfigurieren", "siteResourcesTab": "Ressourcen", "siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Blueprint anwenden", "actionListBlueprints": "Blaupausen anzeigen", "actionGetBlueprint": "Erhalte Blaupause", + "actionCreateOrgWideLauncherView": "Organisationen-Weiter-Startansicht erstellen", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnetz", "addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.", "selectSites": "Standorte auswählen", + "selectLabels": "Etiketten auswählen", "sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.", "clientInstallOlm": "Olm installieren", "clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Standort", "selectSite": "Standort auswählen...", "multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}", + "labelsSelectorLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Entfernte Knoten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheimnis", + "remoteExitNodeNetworkingTitle": "Netzwerkeinstellungen", + "remoteExitNodeNetworkingDescription": "Konfigurieren Sie, wie dieser Remote Exit Node den Datenverkehr leitet und welche Standorte bevorzugt über ihn verbinden. Erweiterte Funktionen zur Verwendung mit Backhaul-Netzwerkkonfigurationen.", + "remoteExitNodeNetworkingSave": "Einstellungen speichern", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netzwerkeinstellungen gespeichert", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netzwerkeinstellungen wurden erfolgreich aktualisiert.", + "remoteExitNodeNetworkingSaveError": "Fehler beim Speichern der Netzwerkeinstellungen", + "remoteExitNodeNetworkingSubnetsTitle": "Remote-Subnetze", + "remoteExitNodeNetworkingSubnetsDescription": "Definieren Sie die CIDR-Bereiche, an die dieser Remote Exit Node den Datenverkehr weiterleitet. Geben Sie einen gültigen CIDR (z. B. 10.0.0.0/8) ein und drücken Sie die Eingabetaste, um hinzuzufügen.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze", + "remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten", + "remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.", + "remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...", + "remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten", "remoteExitNodeCreate": { "title": "Erstelle Remote Node", "description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnetz", + "utilitySubnet": "Nutzsubnetz", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", "customDomain": "Eigene Domain", "authPage": "Authentifizierungs-Seiten", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-Mail-Whitelist", "memberPortalResourceDisabled": "Ressource deaktiviert", "memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen", + "resourceLauncherTitle": "Ressourcenstarter", + "resourceLauncherDescription": "Sehen Sie sich Ressourcendetails an und starten Sie sie von einem Ort aus", + "resourceLauncherSearchPlaceholder": "Alle Standorte durchsuchen...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Ansicht speichern", + "resourceLauncherSaveToCurrentView": "In aktueller Ansicht speichern", + "resourceLauncherResetView": "Ansicht zurücksetzen", + "resourceLauncherSaveAsNewView": "Als neue Ansicht speichern", + "resourceLauncherSaveAsNewViewDescription": "Geben Sie dieser Ansicht einen Namen, um Ihre aktuellen Filter und das Layout zu speichern.", + "resourceLauncherSaveForEveryone": "Für alle speichern", + "resourceLauncherSaveForEveryoneDescription": "Teilen Sie diese Ansicht mit allen Organisationsmitgliedern. Wenn nicht aktiviert, ist die Ansicht nur für Sie sichtbar.", + "resourceLauncherMakePersonal": "Persönlich machen", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sortieren", + "resourceLauncherSortAscending": "Aufsteigend sortieren", + "resourceLauncherSortDescending": "Absteigend sortieren", + "resourceLauncherSettings": "Einstellungen", + "resourceLauncherGroupBy": "Gruppieren nach", + "resourceLauncherGroupBySite": "Standort", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Raster", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Etiketten anzeigen", + "resourceLauncherShowSiteTags": "Standort-Tags anzeigen", + "resourceLauncherShowRecents": "Kürzlich anzeigen", + "resourceLauncherDeleteView": "Ansicht löschen", + "resourceLauncherViewAsAdmin": "Ansicht als Administrator anzeigen", + "resourceLauncherResourceDetailsDescription": "Anzeigen von Details zu dieser Ressource.", + "resourceLauncherUnlabeled": "Nicht etikettiert", + "resourceLauncherNoSite": "Kein Standort", + "resourceLauncherNoResourcesInGroup": "Keine Ressourcen in dieser Gruppe", + "resourceLauncherEmptyStateTitle": "Keine Ressourcen verfügbar", + "resourceLauncherEmptyStateDescription": "Sie haben noch keinen Zugriff auf Ressourcen. Kontaktieren Sie Ihren Administrator, um Zugriff anzufordern.", + "resourceLauncherEmptyStateNoResultsTitle": "Keine Ressourcen gefunden", + "resourceLauncherEmptyStateNoResultsDescription": "Keine Ressourcen entsprechen Ihrer aktuellen Suche oder den Filtern. Versuchen Sie, diese anzupassen, um zu finden, wonach Sie suchen.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Keine Ressourcen entsprechen \"{query}\". Versuchen Sie, Ihre Suche anzupassen oder die Filter zu löschen, um alle Ressourcen anzuzeigen.", + "resourceLauncherCopiedToClipboard": "In die Zwischenablage kopiert", + "resourceLauncherCopiedAccessDescription": "Der Ressourcenzugriff wurde in Ihre Zwischenablage kopiert.", + "resourceLauncherViewNamePlaceholder": "Ansichtsname", + "resourceLauncherViewNameLabel": "Ansichtsname", + "resourceLauncherViewSaved": "Ansicht gespeichert", + "resourceLauncherViewSavedDescription": "Ihre Startansicht wurde gespeichert.", + "resourceLauncherViewSaveFailed": "Fehler beim Speichern der Ansicht", + "resourceLauncherViewSaveFailedDescription": "Die Startansicht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "resourceLauncherViewDeleted": "Ansicht gelöscht", + "resourceLauncherViewDeletedDescription": "Die Startansicht wurde gelöscht.", + "resourceLauncherViewDeleteFailed": "Fehler beim Löschen der Ansicht", + "resourceLauncherViewDeleteFailedDescription": "Die Startansicht konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", "memberPortalPrevious": "Vorherige", "memberPortalNext": "Nächste", "httpSettings": "HTTP-Einstellungen", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich", "vncTitle": "VNC", - "vncSignInDescription": "Geben Sie Ihr VNC-Passwort ein, um sich zu verbinden", + "vncSignInDescription": "Geben Sie Ihre VNC-Zugangsdaten ein, um sich zu verbinden", + "vncUsernameOptional": "Benutzername (optional)", "vncPasswordOptional": "Passwort (optional)", "vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar", "vncFailedToLoadNovnc": "Fehler beim Laden von noVNC", From b20ed9efa9e79a28182fc3f2ef6618eb2474b06c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:02 -0400 Subject: [PATCH 68/79] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 74066721b..04de61551 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -123,6 +123,16 @@ "siteUpdated": "Sito aggiornato", "siteUpdatedDescription": "Il sito è stato aggiornato.", "siteGeneralDescription": "Configura le impostazioni generali per questo sito", + "siteRestartTitle": "Riavvia Sito", + "siteRestartDescription": "Riavvia il tunnel WireGuard per questo sito. Questo interromperà brevemente la connettività.", + "siteRestartBody": "Usalo se il tunnel del sito non funziona correttamente e vuoi forzare un riconnessione senza riavviare l'host.", + "siteRestartButton": "Riavvia Sito", + "siteRestartDialogMessage": "Sei sicuro di voler riavviare il tunnel WireGuard per {name}? Il sito perderà brevemente la connettività.", + "siteRestartWarning": "Il sito si disconnette brevemente mentre il tunnel si riavvia.", + "siteRestarted": "Sito riavviato", + "siteRestartedDescription": "Il tunnel WireGuard è stato riavviato.", + "siteErrorRestart": "Impossibile riavviare il sito", + "siteErrorRestartDescription": "Si è verificato un errore durante il riavvio del sito.", "siteSettingDescription": "Configura le impostazioni del sito", "siteResourcesTab": "Risorse", "siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Applica Progetto", "actionListBlueprints": "Elenco Blueprints", "actionGetBlueprint": "Ottieni Blueprint", + "actionCreateOrgWideLauncherView": "Crea Visualizzazione Lanscia Org-Wide", "setupToken": "Configura Token", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sottorete", "addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.", "selectSites": "Seleziona siti", + "selectLabels": "Seleziona etichette", "sitesDescription": "Il cliente avrà connettività ai siti selezionati", "clientInstallOlm": "Installa Olm", "clientInstallOlmDescription": "Avvia Olm sul tuo sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Sito", "selectSite": "Seleziona sito...", "multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}", "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nodi Remoti", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Segreto", + "remoteExitNodeNetworkingTitle": "Impostazioni di Rete", + "remoteExitNodeNetworkingDescription": "Configura come questo nodo di uscita remoto indirizza il traffico e quali siti preferiscono connettersi tramite esso. Caratteristiche avanzate da utilizzare con le configurazioni di rete backhaul.", + "remoteExitNodeNetworkingSave": "Salva Impostazioni", + "remoteExitNodeNetworkingSaveSuccessTitle": "Impostazioni di rete salvate", + "remoteExitNodeNetworkingSaveSuccessDescription": "Le impostazioni di rete sono state aggiornate con successo.", + "remoteExitNodeNetworkingSaveError": "Impossibile salvare le impostazioni di rete", + "remoteExitNodeNetworkingSubnetsTitle": "Sottoreti Remote", + "remoteExitNodeNetworkingSubnetsDescription": "Definisci gli intervalli CIDR che questo nodo di uscita remota inoltrerà il traffico. Digita un CIDR valido (ad esempio 10.0.0.0/8) e premi Invio per aggiungerlo.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito", + "remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze", + "remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...", + "remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito", "remoteExitNodeCreate": { "title": "Crea Nodo Remoto", "description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sottorete", + "utilitySubnet": "Sottorete di utilità", "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", "customDomain": "Dominio Personalizzato", "authPage": "Pagine di Autenticazione", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista Autorizzazioni Email", "memberPortalResourceDisabled": "Risorsa Disabilitata", "memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse", + "resourceLauncherTitle": "Lanscia Risorse", + "resourceLauncherDescription": "Visualizza i dettagli delle risorse e lanciale da un solo posto", + "resourceLauncherSearchPlaceholder": "Cerca tutti i siti...", + "resourceLauncherDefaultView": "Predefinito", + "resourceLauncherSaveView": "Salva Visualizzazione", + "resourceLauncherSaveToCurrentView": "Salva alla Visualizzazione Corrente", + "resourceLauncherResetView": "Reimposta Visualizzazione", + "resourceLauncherSaveAsNewView": "Salva come Nuova Visualizzazione", + "resourceLauncherSaveAsNewViewDescription": "Dai un nome a questa visualizzazione per salvare i tuoi filtri e layout attuali.", + "resourceLauncherSaveForEveryone": "Salva per Tutti", + "resourceLauncherSaveForEveryoneDescription": "Condividi questa visualizzazione con tutti i membri dell'organizzazione. Quando non è selezionata, la visualizzazione è visibile solo a te.", + "resourceLauncherMakePersonal": "Rendi Personale", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordina", + "resourceLauncherSortAscending": "Ordina in ordine crescente", + "resourceLauncherSortDescending": "Ordina in ordine decrescente", + "resourceLauncherSettings": "Impostazioni", + "resourceLauncherGroupBy": "Raggruppa per", + "resourceLauncherGroupBySite": "Sito", + "resourceLauncherGroupByLabel": "Etichetta", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Griglia", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostra Etichette", + "resourceLauncherShowSiteTags": "Mostra Tag di Sito", + "resourceLauncherShowRecents": "Mostra Recenti", + "resourceLauncherDeleteView": "Elimina Visualizzazione", + "resourceLauncherViewAsAdmin": "Visualizza come Admin", + "resourceLauncherResourceDetailsDescription": "Visualizza i dettagli per questa risorsa.", + "resourceLauncherUnlabeled": "Non Etichettato", + "resourceLauncherNoSite": "Nessun Sito", + "resourceLauncherNoResourcesInGroup": "Nessuna risorsa in questo gruppo", + "resourceLauncherEmptyStateTitle": "Non ci sono risorse disponibili", + "resourceLauncherEmptyStateDescription": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per richiedere l'accesso.", + "resourceLauncherEmptyStateNoResultsTitle": "Nessuna risorsa trovata", + "resourceLauncherEmptyStateNoResultsDescription": "Nessuna risorsa corrisponde alla tua ricerca o ai tuoi filtri attuali. Prova a modificarli per trovare ciò che stai cercando.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Nessuna risorsa corrisponde a \"{query}\". Prova a modificare la tua ricerca o a cancellare i filtri per vedere tutte le risorse.", + "resourceLauncherCopiedToClipboard": "Copiato negli appunti", + "resourceLauncherCopiedAccessDescription": "L'accesso alla risorsa è stato copiato nei tuoi appunti.", + "resourceLauncherViewNamePlaceholder": "Nome Visualizzazione", + "resourceLauncherViewNameLabel": "Nome Visualizzazione", + "resourceLauncherViewSaved": "Visualizzazione salvata", + "resourceLauncherViewSavedDescription": "La tua visualizzazione del lanscia è stata salvata.", + "resourceLauncherViewSaveFailed": "Impossibile salvare la visualizzazione", + "resourceLauncherViewSaveFailedDescription": "Impossibile salvare la visualizzazione del lanscia. Per favore riprova.", + "resourceLauncherViewDeleted": "Visualizzazione eliminata", + "resourceLauncherViewDeletedDescription": "La visualizzazione del lanscia è stata eliminata.", + "resourceLauncherViewDeleteFailed": "Impossibile eliminare la visualizzazione", + "resourceLauncherViewDeleteFailedDescription": "Non è stato possibile eliminare la visualizzazione del lanscia. Per favore riprova.", "memberPortalPrevious": "Precedente", "memberPortalNext": "Successivo", "httpSettings": "Impostazioni HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "È richiesta una chiave privata", "vncTitle": "VNC", - "vncSignInDescription": "Inserisci la tua password VNC per connetterti", + "vncSignInDescription": "Inserisci le tue credenziali VNC per connetterti", + "vncUsernameOptional": "Nome utente (facoltativo)", "vncPasswordOptional": "Password (opzionale)", "vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile", "vncFailedToLoadNovnc": "Impossibile caricare noVNC", From 40101f8bb0563f8d746c073622bb9ef33caf3b40 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:03 -0400 Subject: [PATCH 69/79] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index a53e76d18..85cc96816 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -123,6 +123,16 @@ "siteUpdated": "사이트가 업데이트되었습니다", "siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", + "siteRestartTitle": "사이트 다시 시작", + "siteRestartDescription": "이 사이트의 WireGuard 터널을 다시 시작합니다. 일시적으로 연결이 중단될 수 있습니다.", + "siteRestartBody": "사이트 터널이 제대로 작동하지 않을 경우, 호스트를 재시작하지 않고 다시 연결을 강제하려면 이 옵션을 사용하세요.", + "siteRestartButton": "사이트 다시 시작", + "siteRestartDialogMessage": "{name}의 WireGuard 터널을 재시작하시겠습니까? 이 작업으로 인해 사이트의 연결이 일시적으로 중단될 수 있습니다.", + "siteRestartWarning": "터널을 재시작하는 동안 사이트가 일시적으로 연결이 끊깁니다.", + "siteRestarted": "사이트가 재시작되었습니다", + "siteRestartedDescription": "WireGuard 터널이 재시작되었습니다.", + "siteErrorRestart": "사이트 재시작 실패", + "siteErrorRestartDescription": "사이트를 재시작하는 중 오류가 발생했습니다.", "siteSettingDescription": "사이트에서 설정을 구성하세요.", "siteResourcesTab": "리소스", "siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "청사진 적용", "actionListBlueprints": "청사진 목록", "actionGetBlueprint": "청사진 가져오기", + "actionCreateOrgWideLauncherView": "조직 전체 런처 보기 생성", "setupToken": "설정 토큰", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "서브넷", "addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "selectSites": "사이트 선택", + "selectLabels": "레이블 선택", "sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.", "clientInstallOlm": "Olm 설치", "clientInstallOlmDescription": "시스템에서 Olm을 실행하기", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "사이트", "selectSite": "사이트 선택...", "multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}", + "labelsSelectorLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}", "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "원격 노드", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "비밀", + "remoteExitNodeNetworkingTitle": "네트워크 설정", + "remoteExitNodeNetworkingDescription": "이 원격 출구 노드의 트래픽 라우팅 방법과 어떤 사이트가 이를 통해 연결하는지 구성합니다. 백홀 네트워킹 구성을 사용한 고급 기능입니다.", + "remoteExitNodeNetworkingSave": "설정 저장", + "remoteExitNodeNetworkingSaveSuccessTitle": "네트워크 설정이 저장되었습니다", + "remoteExitNodeNetworkingSaveSuccessDescription": "네트워크 설정이 성공적으로 업데이트되었습니다.", + "remoteExitNodeNetworkingSaveError": "네트워크 설정 저장 실패", + "remoteExitNodeNetworkingSubnetsTitle": "원격 서브넷", + "remoteExitNodeNetworkingSubnetsDescription": "이 원격 출구 노드가 트래픽을 라우팅할 CIDR 범위를 정의합니다. 유효한 CIDR을 입력하고 Enter를 눌러 추가하세요 (예: 10.0.0.0/8).", + "remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패", + "remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블", + "remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.", + "remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...", + "remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패", "remoteExitNodeCreate": { "title": "원격 노드 생성", "description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC 공급자", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", "subnet": "서브넷", + "utilitySubnet": "유틸리티 서브넷", "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", "customDomain": "사용자 정의 도메인", "authPage": "인증 페이지", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "이메일 화이트리스트", "memberPortalResourceDisabled": "리소스 비활성화됨", "memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중", + "resourceLauncherTitle": "리소스 런처", + "resourceLauncherDescription": "리소스 세부 정보를 보고 한 곳에서 실행하세요", + "resourceLauncherSearchPlaceholder": "모든 사이트 검색...", + "resourceLauncherDefaultView": "기본값", + "resourceLauncherSaveView": "보기를 저장", + "resourceLauncherSaveToCurrentView": "현재 보기로 저장", + "resourceLauncherResetView": "보기를 재설정", + "resourceLauncherSaveAsNewView": "새 보기로 저장", + "resourceLauncherSaveAsNewViewDescription": "현재 필터와 레이아웃을 저장할 이름을 입력하세요.", + "resourceLauncherSaveForEveryone": "모두에게 저장", + "resourceLauncherSaveForEveryoneDescription": "이 보기를 모든 조직 구성원과 공유합니다. 체크 해제하면 해당 뷰는 사용자에게만 표시됩니다.", + "resourceLauncherMakePersonal": "개인적으로 만들기", + "resourceLauncherFilter": "필터", + "resourceLauncherSort": "정렬", + "resourceLauncherSortAscending": "오름차순 정렬", + "resourceLauncherSortDescending": "내림차순 정렬", + "resourceLauncherSettings": "설정", + "resourceLauncherGroupBy": "그룹화 기준", + "resourceLauncherGroupBySite": "사이트", + "resourceLauncherGroupByLabel": "레이블", + "resourceLauncherLayout": "레이아웃", + "resourceLauncherLayoutGrid": "그리드", + "resourceLauncherLayoutList": "목록", + "resourceLauncherShowLabels": "레이블 표시", + "resourceLauncherShowSiteTags": "사이트 태그 표시", + "resourceLauncherShowRecents": "최근 항목 표시", + "resourceLauncherDeleteView": "보기 삭제", + "resourceLauncherViewAsAdmin": "관리자로 보기", + "resourceLauncherResourceDetailsDescription": "이 리소스의 세부정보를 봅니다.", + "resourceLauncherUnlabeled": "레이블 없음", + "resourceLauncherNoSite": "사이트 없음", + "resourceLauncherNoResourcesInGroup": "이 그룹에는 리소스가 없습니다", + "resourceLauncherEmptyStateTitle": "사용 가능한 리소스 없음", + "resourceLauncherEmptyStateDescription": "아직 리소스에 대한 액세스 권한이 없습니다. 액세스를 요청하려면 관리자에게 문의하세요.", + "resourceLauncherEmptyStateNoResultsTitle": "리소스를 찾을 수 없음", + "resourceLauncherEmptyStateNoResultsDescription": "현재 검색이나 필터에 맞는 리소스가 없습니다. 필터를 조정하여 찾으려는 항목을 확인해보세요.", + "resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\"와 일치하는 리소스가 없습니다. 검색을 조정하거나 필터를 지워서 모든 리소스를 확인해보세요.", + "resourceLauncherCopiedToClipboard": "클립보드에 복사됨", + "resourceLauncherCopiedAccessDescription": "리소스 액세스가 클립보드에 복사되었습니다.", + "resourceLauncherViewNamePlaceholder": "보기 이름", + "resourceLauncherViewNameLabel": "뷰 이름", + "resourceLauncherViewSaved": "보기 저장됨", + "resourceLauncherViewSavedDescription": "런처 뷰가 저장되었습니다.", + "resourceLauncherViewSaveFailed": "뷰 저장 실패", + "resourceLauncherViewSaveFailedDescription": "런처 뷰를 저장할 수 없습니다. 다시 시도하세요.", + "resourceLauncherViewDeleted": "보기 삭제됨", + "resourceLauncherViewDeletedDescription": "런처 뷰가 삭제되었습니다.", + "resourceLauncherViewDeleteFailed": "뷰 삭제 실패", + "resourceLauncherViewDeleteFailedDescription": "런처 뷰를 삭제할 수 없습니다. 다시 시도하세요.", "memberPortalPrevious": "이전", "memberPortalNext": "다음", "httpSettings": "HTTP 설정", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "프라이빗 키가 필요합니다", "vncTitle": "VNC", - "vncSignInDescription": "연결하려면 VNC 비밀번호를 입력하세요", + "vncSignInDescription": "연결하기 위해 VNC 자격 증명을 입력하세요", + "vncUsernameOptional": "사용자 이름 (선택 사항)", "vncPasswordOptional": "비밀번호 (선택 사항)", "vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다", "vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다", From c383e74df4c1eae6f07212541a97d8d63e6218d6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:05 -0400 Subject: [PATCH 70/79] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index a483c147e..702ff0325 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -123,6 +123,16 @@ "siteUpdated": "Site bijgewerkt", "siteUpdatedDescription": "De site is bijgewerkt.", "siteGeneralDescription": "Algemene instellingen voor deze site configureren", + "siteRestartTitle": "Herstart Site", + "siteRestartDescription": "Herstart de WireGuard-tunnel voor deze site. Dit zal de connectiviteit kort onderbreken.", + "siteRestartBody": "Gebruik dit als de sitetunnel niet correct functioneert en je wilt een herverbinding forceren zonder de host opnieuw op te starten.", + "siteRestartButton": "Herstart Site", + "siteRestartDialogMessage": "Weet u zeker dat u de WireGuard-tunnel voor {name} wilt herstarten? De site zal tijdelijk geen connectiviteit hebben.", + "siteRestartWarning": "De site zal kort worden losgekoppeld terwijl de tunnel opnieuw wordt gestart.", + "siteRestarted": "Site herstart", + "siteRestartedDescription": "De WireGuard-tunnel is opnieuw gestart.", + "siteErrorRestart": "Site herstarten mislukt", + "siteErrorRestartDescription": "Er is een fout opgetreden tijdens het herstarten van de site.", "siteSettingDescription": "Configureer de instellingen van de site", "siteResourcesTab": "Bronnen", "siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Blauwdruk toepassen", "actionListBlueprints": "Lijst blauwdrukken", "actionGetBlueprint": "Krijg Blauwdruk", + "actionCreateOrgWideLauncherView": "Maak Organisatiebrede Launcher Weergave", "setupToken": "Instel Token", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.", "selectSites": "Selecteer sites", + "selectLabels": "Selecteer labels", "sitesDescription": "De client heeft connectiviteit met de geselecteerde sites", "clientInstallOlm": "Installeer Olm", "clientInstallOlmDescription": "Laat Olm draaien op uw systeem", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Selecteer site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}", "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Externe knooppunten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheim", + "remoteExitNodeNetworkingTitle": "Netwerkinstellingen", + "remoteExitNodeNetworkingDescription": "Configureer hoe dit externe exit-knooppunt verkeer routeert en welke sites de voorkeur hebben om er doorheen te verbinden. Geavanceerde functies te gebruiken met backhaul-netwerkconfiguraties.", + "remoteExitNodeNetworkingSave": "Instellingen opslaan", + "remoteExitNodeNetworkingSaveSuccessTitle": "Netwerkinstellingen opgeslagen", + "remoteExitNodeNetworkingSaveSuccessDescription": "Netwerkinstellingen zijn succesvol bijgewerkt.", + "remoteExitNodeNetworkingSaveError": "Kon netwerkinstellingen niet opslaan", + "remoteExitNodeNetworkingSubnetsTitle": "Externe Subnets", + "remoteExitNodeNetworkingSubnetsDescription": "Definieer de CIDR-bereiken waarnaar dit externe exit-knooppunt verkeer zal routeren. Voer een geldige CIDR in (bijv. 10.0.0.0/8) en druk op Enter om toe te voegen.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden", + "remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels", + "remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.", + "remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...", + "remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden", "remoteExitNodeCreate": { "title": "Externe knoop aanmaken", "description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", + "utilitySubnet": "Hulpmiddel Subnet", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", "customDomain": "Aangepast domein", "authPage": "Authenticatiepagina's", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-mail whitelist", "memberPortalResourceDisabled": "Bron Uitgeschakeld", "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", + "resourceLauncherTitle": "Bron Launcher", + "resourceLauncherDescription": "Bekijk brongegevens en start ze vanaf één plek", + "resourceLauncherSearchPlaceholder": "Zoek alle sites...", + "resourceLauncherDefaultView": "Standaard", + "resourceLauncherSaveView": "Weergave Opslaan", + "resourceLauncherSaveToCurrentView": "Opslaan naar huidige weergave", + "resourceLauncherResetView": "Weergave Herstellen", + "resourceLauncherSaveAsNewView": "Opslaan als Nieuwe Weergave", + "resourceLauncherSaveAsNewViewDescription": "Geef deze weergave een naam om je huidige filters en indeling op te slaan.", + "resourceLauncherSaveForEveryone": "Opslaan voor Iedereen", + "resourceLauncherSaveForEveryoneDescription": "Deel deze weergave met alle organisatieleden. Als dit niet is aangevinkt, is de weergave alleen zichtbaar voor jou.", + "resourceLauncherMakePersonal": "Persoonlijk Maken", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sorteren", + "resourceLauncherSortAscending": "Oplopend sorteren", + "resourceLauncherSortDescending": "Aflopend sorteren", + "resourceLauncherSettings": "Instellingen", + "resourceLauncherGroupBy": "Groep Op", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Label", + "resourceLauncherLayout": "Lay-out", + "resourceLauncherLayoutGrid": "Raster", + "resourceLauncherLayoutList": "Lijst", + "resourceLauncherShowLabels": "Labels Weergeven", + "resourceLauncherShowSiteTags": "Site Tags Weergeven", + "resourceLauncherShowRecents": "Recente Weergeven", + "resourceLauncherDeleteView": "Weergave Verwijderen", + "resourceLauncherViewAsAdmin": "Bekijk als Admin", + "resourceLauncherResourceDetailsDescription": "Bekijk details voor deze bron.", + "resourceLauncherUnlabeled": "Geen label", + "resourceLauncherNoSite": "Geen Site", + "resourceLauncherNoResourcesInGroup": "Geen bronnen in deze groep", + "resourceLauncherEmptyStateTitle": "Geen Bronnen Beschikbaar", + "resourceLauncherEmptyStateDescription": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang aan te vragen.", + "resourceLauncherEmptyStateNoResultsTitle": "Geen Bronnen Gevonden", + "resourceLauncherEmptyStateNoResultsDescription": "Geen bronnen komen overeen met je huidige zoekopdracht of filters. Probeer ze aan te passen om te vinden wat je zoekt.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Geen bronnen komen overeen met \"{query}\". Probeer je zoekopdracht aan te passen of filters te wissen om alle bronnen te zien.", + "resourceLauncherCopiedToClipboard": "Gekopieerd naar klembord", + "resourceLauncherCopiedAccessDescription": "Toegang tot bron is gekopieerd naar je klembord.", + "resourceLauncherViewNamePlaceholder": "Weergavenaam", + "resourceLauncherViewNameLabel": "Weergavenaam", + "resourceLauncherViewSaved": "Weergave opgeslagen", + "resourceLauncherViewSavedDescription": "Je launcher-weergave is opgeslagen.", + "resourceLauncherViewSaveFailed": "Kon weergave niet opslaan", + "resourceLauncherViewSaveFailedDescription": "Kon de launcher-weergave niet opslaan. Probeer het opnieuw.", + "resourceLauncherViewDeleted": "Weergave verwijderd", + "resourceLauncherViewDeletedDescription": "De launcher-weergave is verwijderd.", + "resourceLauncherViewDeleteFailed": "Kon weergave niet verwijderen", + "resourceLauncherViewDeleteFailedDescription": "Kon de launcher-weergave niet verwijderen. Probeer het opnieuw.", "memberPortalPrevious": "Vorige", "memberPortalNext": "Volgende", "httpSettings": "HTTP-instellingen", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Privésleutel is vereist", "vncTitle": "VNC", - "vncSignInDescription": "Voer uw VNC-wachtwoord in om verbinding te maken", + "vncSignInDescription": "Voer uw VNC-referenties in om verbinding te maken", + "vncUsernameOptional": "Gebruikersnaam (optioneel)", "vncPasswordOptional": "Wachtwoord (optioneel)", "vncNoResourceTarget": "Geen bron doelwit beschikbaar", "vncFailedToLoadNovnc": "Laden van noVNC mislukt", From 9c2d14d8c665cd6286a9763acf01cdf66bc7aff8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:07 -0400 Subject: [PATCH 71/79] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index bfe29df7d..d6c67ac20 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -123,6 +123,16 @@ "siteUpdated": "Strona zaktualizowana", "siteUpdatedDescription": "Strona została zaktualizowana.", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", + "siteRestartTitle": "Restartuj Stronę", + "siteRestartDescription": "Uruchom ponownie tunel WireGuard dla tej strony. Spowoduje to tymczasowe przerwanie łączności.", + "siteRestartBody": "Użyj tego, jeśli tunel strony nie działa prawidłowo i chcesz wymusić ponowne połączenie bez ponownego uruchamiania hosta.", + "siteRestartButton": "Restartuj Stronę", + "siteRestartDialogMessage": "Czy na pewno chcesz uruchomić ponownie tunel WireGuard dla {name}? Strona tymczasowo straci łączność.", + "siteRestartWarning": "Strona tymczasowo rozłączy się podczas ponownego uruchamiania tunelu.", + "siteRestarted": "Strona zrestartowana", + "siteRestartedDescription": "Tunel WireGuard został ponownie uruchomiony.", + "siteErrorRestart": "Nie udało się zrestartować strony", + "siteErrorRestartDescription": "Wystąpił błąd podczas ponownego uruchamiania strony.", "siteSettingDescription": "Skonfiguruj ustawienia na stronie", "siteResourcesTab": "Zasoby", "siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Zastosuj schemat", "actionListBlueprints": "Lista planów", "actionGetBlueprint": "Pobierz plan", + "actionCreateOrgWideLauncherView": "Utwórz Widok Uruchamiacza dla Całej Organizacji", "setupToken": "Skonfiguruj token", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Podsieć", "addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.", "selectSites": "Wybierz witryny", + "selectLabels": "Wybierz etykiety", "sitesDescription": "Klient będzie miał łączność z wybranymi witrynami", "clientInstallOlm": "Zainstaluj Olm", "clientInstallOlmDescription": "Uruchom Olm na swoim systemie", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Witryna", "selectSite": "Wybierz stronę...", "multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Zdalne węzły", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sekret", + "remoteExitNodeNetworkingTitle": "Ustawienia sieciowe", + "remoteExitNodeNetworkingDescription": "Skonfiguruj, jak ten zdalny węzeł wyjściowy przekierowuje ruch i które strony preferują połączenie przez niego. Zaawansowane funkcje do użycia z konfiguracją sieci backhaul.", + "remoteExitNodeNetworkingSave": "Zapisz ustawienia", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ustawienia sieciowe zapisane", + "remoteExitNodeNetworkingSaveSuccessDescription": "Ustawienia sieciowe zostały pomyślnie zaktualizowane.", + "remoteExitNodeNetworkingSaveError": "Nie udało się zapisać ustawień sieciowych", + "remoteExitNodeNetworkingSubnetsTitle": "Zdalne Podsieci", + "remoteExitNodeNetworkingSubnetsDescription": "Zdefiniuj zakresy CIDR, które ten zdalny węzeł wyjściowy przekieruje ruch do. Wpisz prawidłowy CIDR (np. 10.0.0.0/8) i naciśnij Enter, aby dodać.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci", + "remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji", + "remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.", + "remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...", + "remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet", "remoteExitNodeCreate": { "title": "Utwórz zdalny węzeł", "description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Dostawca Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsieć", + "utilitySubnet": "Użyteczna podsieć", "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", "customDomain": "Niestandardowa domena", "authPage": "Strony uwierzytelniania", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Biała lista e-mail", "memberPortalResourceDisabled": "Zasób wyłączony", "memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}", + "resourceLauncherTitle": "Uruchamiacz Zasobów", + "resourceLauncherDescription": "Przeglądaj szczegóły zasobów i uruchamiaj je z jednego miejsca", + "resourceLauncherSearchPlaceholder": "Szukaj we wszystkich stronach...", + "resourceLauncherDefaultView": "Domyślny", + "resourceLauncherSaveView": "Zapisz Widok", + "resourceLauncherSaveToCurrentView": "Zapisz do bieżącego widoku", + "resourceLauncherResetView": "Resetuj Widok", + "resourceLauncherSaveAsNewView": "Zapisz jako Nowy Widok", + "resourceLauncherSaveAsNewViewDescription": "Nadaj nazwę temu widokowi, aby zapisać swoje bieżące filtry i układ.", + "resourceLauncherSaveForEveryone": "Zapisz dla wszystkich", + "resourceLauncherSaveForEveryoneDescription": "Udostępnij ten widok wszystkim członkom organizacji. Gdy jest niezaznaczone, widok jest widoczny tylko dla Ciebie.", + "resourceLauncherMakePersonal": "Zrób osobisty", + "resourceLauncherFilter": "Filtr", + "resourceLauncherSort": "Sortuj", + "resourceLauncherSortAscending": "Sortuj rosnąco", + "resourceLauncherSortDescending": "Sortuj malejąco", + "resourceLauncherSettings": "Ustawienia", + "resourceLauncherGroupBy": "Grupuj według", + "resourceLauncherGroupBySite": "Witryna", + "resourceLauncherGroupByLabel": "Etykieta", + "resourceLauncherLayout": "Układ", + "resourceLauncherLayoutGrid": "Siatka", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Pokaż etykiety", + "resourceLauncherShowSiteTags": "Pokaż tagi stron", + "resourceLauncherShowRecents": "Pokaż ostatnie", + "resourceLauncherDeleteView": "Usuń Widok", + "resourceLauncherViewAsAdmin": "Przeglądaj jako Administrator", + "resourceLauncherResourceDetailsDescription": "Pokaż szczegóły tego zasobu.", + "resourceLauncherUnlabeled": "Bez etykiety", + "resourceLauncherNoSite": "Brak strony", + "resourceLauncherNoResourcesInGroup": "W tej grupie nie ma zasobów", + "resourceLauncherEmptyStateTitle": "Brak dostępnych zasobów", + "resourceLauncherEmptyStateDescription": "Jeszcze nie masz dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby poprosić o dostęp.", + "resourceLauncherEmptyStateNoResultsTitle": "Nie znaleziono zasobów", + "resourceLauncherEmptyStateNoResultsDescription": "Żadne zasoby nie spełniają twojego bieżącego wyszukiwania lub filtrów. Spróbuj je dostosować, aby znaleźć to, czego szukasz.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Żadne zasoby nie odpowiadają \"{query}\". Spróbuj dostosować swoje wyszukiwanie lub usunąć filtry, aby zobaczyć wszystkie zasoby.", + "resourceLauncherCopiedToClipboard": "Skopiowano do schowka", + "resourceLauncherCopiedAccessDescription": "Dostęp do zasobu został skopiowany do schowka.", + "resourceLauncherViewNamePlaceholder": "Nazwa widoku", + "resourceLauncherViewNameLabel": "Nazwa Widoku", + "resourceLauncherViewSaved": "Widok zapisany", + "resourceLauncherViewSavedDescription": "Twój widok uruchamiacza został zapisany.", + "resourceLauncherViewSaveFailed": "Nie udało się zapisać widoku", + "resourceLauncherViewSaveFailedDescription": "Nie można zapisać widoku uruchamiacza. Proszę spróbować ponownie.", + "resourceLauncherViewDeleted": "Widok usunięty", + "resourceLauncherViewDeletedDescription": "Widok uruchamiacza został usunięty.", + "resourceLauncherViewDeleteFailed": "Nie udało się usunąć widoku", + "resourceLauncherViewDeleteFailedDescription": "Nie można usunąć widoku uruchamiacza. Proszę spróbować ponownie.", "memberPortalPrevious": "Poprzedni", "memberPortalNext": "Następny", "httpSettings": "Ustawienia HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Wymagany jest klucz prywatny", "vncTitle": "VNC", - "vncSignInDescription": "Wprowadź hasło VNC, aby się połączyć", + "vncSignInDescription": "Wprowadź swoje dane uwierzytelniające VNC aby się połączyć", + "vncUsernameOptional": "Nazwa użytkownika (opcjonalnie)", "vncPasswordOptional": "Hasło (opcjonalne)", "vncNoResourceTarget": "Brak dostępnego celu zasobu", "vncFailedToLoadNovnc": "Błąd ładowania noVNC", From da9e668fb8373c85cfd4f7f5509940190cecc8ce Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:09 -0400 Subject: [PATCH 72/79] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index ff0d543fe..1f47d92b8 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -123,6 +123,16 @@ "siteUpdated": "Site atualizado", "siteUpdatedDescription": "O site foi atualizado.", "siteGeneralDescription": "Configurar as configurações gerais para este site", + "siteRestartTitle": "Reiniciar site", + "siteRestartDescription": "Reinicie o túnel WireGuard para este site. Isso interromperá brevemente a conectividade.", + "siteRestartBody": "Use isso se o túnel do site não estiver funcionando corretamente e você quiser forçar uma reconexão sem reiniciar o host.", + "siteRestartButton": "Reiniciar site", + "siteRestartDialogMessage": "Tem certeza de que deseja reiniciar o túnel WireGuard para {name}? O site perderá brevemente a conectividade.", + "siteRestartWarning": "O site será desconectado brevemente enquanto o túnel reinicia.", + "siteRestarted": "Site reiniciado", + "siteRestartedDescription": "O túnel WireGuard foi reiniciado.", + "siteErrorRestart": "Falha ao reiniciar o site", + "siteErrorRestartDescription": "Ocorreu um erro ao reiniciar o site.", "siteSettingDescription": "Configurar as configurações no site", "siteResourcesTab": "Recursos", "siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Aplicar Diagrama", "actionListBlueprints": "Listar Modelos", "actionGetBlueprint": "Obter Modelo", + "actionCreateOrgWideLauncherView": "Criar Visualização do Lançador para Toda a Organização", "setupToken": "Configuração do Token", "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Sub-rede", "addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.", "selectSites": "Selecionar sites", + "selectLabels": "Selecionar etiquetas", "sitesDescription": "O cliente terá conectividade com os sites selecionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Execute o Olm em seu sistema", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Selecionar site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}", "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Nós remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Chave Secreta", + "remoteExitNodeNetworkingTitle": "Configurações de Rede", + "remoteExitNodeNetworkingDescription": "Configure como este nó de saída remoto roteia o tráfego e quais sites preferem se conectar através dele. Recursos avançados para serem usados com configurações de rede de backhaul.", + "remoteExitNodeNetworkingSave": "Guardar Configurações", + "remoteExitNodeNetworkingSaveSuccessTitle": "Configurações de rede salvas", + "remoteExitNodeNetworkingSaveSuccessDescription": "As configurações de rede foram atualizadas com sucesso.", + "remoteExitNodeNetworkingSaveError": "Falha ao guardar as configurações de rede", + "remoteExitNodeNetworkingSubnetsTitle": "Sub-redes Remotas", + "remoteExitNodeNetworkingSubnetsDescription": "Defina os intervalos de CIDR que este nó de saída remoto irá rotear o tráfego. Digite um CIDR válido (por exemplo, 10.0.0.0/8) e pressione Enter para adicionar.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes", + "remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência", + "remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.", + "remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...", + "remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas", "remoteExitNodeCreate": { "title": "Criar Nó Remoto", "description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Provedor Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sub-rede", + "utilitySubnet": "Sub-rede de utilidade", "subnetDescription": "A sub-rede para a configuração de rede dessa organização.", "customDomain": "Domínio Personalizado", "authPage": "Páginas de Autenticação", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Lista de E-mails Permitidos", "memberPortalResourceDisabled": "Recurso Desativado", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", + "resourceLauncherTitle": "Lançador de Recursos", + "resourceLauncherDescription": "Veja os detalhes do recurso e lance-os de um só lugar", + "resourceLauncherSearchPlaceholder": "Procurar todos os sites...", + "resourceLauncherDefaultView": "Padrão", + "resourceLauncherSaveView": "Salvar Visualização", + "resourceLauncherSaveToCurrentView": "Salvar na Visualização Atual", + "resourceLauncherResetView": "Redefinir Visualização", + "resourceLauncherSaveAsNewView": "Salvar como Nova Visualização", + "resourceLauncherSaveAsNewViewDescription": "Dê um nome a esta visualização para salvar os filtros e layout atuais.", + "resourceLauncherSaveForEveryone": "Salvar para Todos", + "resourceLauncherSaveForEveryoneDescription": "Compartilhe esta visualização com todos os membros da organização. Quando desmarcado, a visualização é visível apenas para você.", + "resourceLauncherMakePersonal": "Tornar Pessoal", + "resourceLauncherFilter": "Filtro", + "resourceLauncherSort": "Ordenar", + "resourceLauncherSortAscending": "Ordenar ascendente", + "resourceLauncherSortDescending": "Ordenar descendente", + "resourceLauncherSettings": "Configurações", + "resourceLauncherGroupBy": "Agrupar por", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Marcador", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Grade", + "resourceLauncherLayoutList": "Lista", + "resourceLauncherShowLabels": "Mostrar Marcadores", + "resourceLauncherShowSiteTags": "Mostrar Etiquetas de Site", + "resourceLauncherShowRecents": "Mostrar Recents", + "resourceLauncherDeleteView": "Excluir Visualização", + "resourceLauncherViewAsAdmin": "Visualizar como Administrador", + "resourceLauncherResourceDetailsDescription": "Veja detalhes deste recurso.", + "resourceLauncherUnlabeled": "Sem Etiqueta", + "resourceLauncherNoSite": "Sem Site", + "resourceLauncherNoResourcesInGroup": "Nenhum recurso neste grupo", + "resourceLauncherEmptyStateTitle": "Nenhum Recurso Disponível", + "resourceLauncherEmptyStateDescription": "Você não tem acesso a nenhum recurso ainda. Entre em contato com seu administrador para solicitar acesso.", + "resourceLauncherEmptyStateNoResultsTitle": "Nenhum Recurso Encontrado", + "resourceLauncherEmptyStateNoResultsDescription": "Nenhum recurso corresponde à sua busca ou filtros atuais. Experimente ajustá-los para encontrar o que está procurando.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Nenhum recurso corresponde a \"{query}\". Tente ajustar sua busca ou limpar os filtros para ver todos os recursos.", + "resourceLauncherCopiedToClipboard": "Copiado para a área de transferência", + "resourceLauncherCopiedAccessDescription": "O acesso ao recurso foi copiado para sua área de transferência.", + "resourceLauncherViewNamePlaceholder": "Nome da Visualização", + "resourceLauncherViewNameLabel": "Nome da Visualização", + "resourceLauncherViewSaved": "Visualização salva", + "resourceLauncherViewSavedDescription": "Sua visualização do lançador foi salva.", + "resourceLauncherViewSaveFailed": "Falha ao salvar visualização", + "resourceLauncherViewSaveFailedDescription": "Não foi possível salvar a visualização do lançador. Por favor, tente novamente.", + "resourceLauncherViewDeleted": "Visualização excluída", + "resourceLauncherViewDeletedDescription": "A visualização do lançador foi excluída.", + "resourceLauncherViewDeleteFailed": "Falha ao excluir visualização", + "resourceLauncherViewDeleteFailedDescription": "Não foi possível excluir a visualização do lançador. Por favor, tente novamente.", "memberPortalPrevious": "Anterior", "memberPortalNext": "Próximo", "httpSettings": "Configurações HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "Chave privada é necessária", "vncTitle": "VNC", - "vncSignInDescription": "Digite sua senha VNC para conectar", + "vncSignInDescription": "Digite suas credenciais VNC para conectar", + "vncUsernameOptional": "Nome de usuário (opcional)", "vncPasswordOptional": "Senha (opcional)", "vncNoResourceTarget": "Nenhum alvo de recurso disponível", "vncFailedToLoadNovnc": "Falha ao carregar noVNC", From ddba2aff21ba988b5fa8ea2e5932433563f58ddc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:11 -0400 Subject: [PATCH 73/79] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 523f3c2ef..579a49c8f 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -123,6 +123,16 @@ "siteUpdated": "Сайт обновлён", "siteUpdatedDescription": "Сайт был успешно обновлён.", "siteGeneralDescription": "Настройте общие параметры для этого сайта", + "siteRestartTitle": "Перезагрузить сайт", + "siteRestartDescription": "Перезапустите туннель WireGuard для этого сайта. Это кратковременно прервет соединение.", + "siteRestartBody": "Используйте это, если туннель сайта не работает должным образом и вам нужно принудительно переподключиться без перезапуска хоста.", + "siteRestartButton": "Перезагрузить сайт", + "siteRestartDialogMessage": "Вы уверены, что хотите перезапустить туннель WireGuard для {name}? Сайт кратковременно потеряет соединение.", + "siteRestartWarning": "Сайт кратковременно отключится во время перезапуска туннеля.", + "siteRestarted": "Сайт перезапущен", + "siteRestartedDescription": "Туннель WireGuard был перезапущен.", + "siteErrorRestart": "Не удалось перезапустить сайт", + "siteErrorRestartDescription": "Произошла ошибка во время перезапуска сайта.", "siteSettingDescription": "Настройка параметров на сайте", "siteResourcesTab": "Ресурсы", "siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Применить чертёж", "actionListBlueprints": "Список чертежей", "actionGetBlueprint": "Получить чертёж", + "actionCreateOrgWideLauncherView": "Создать вид запуска на уровне организации", "setupToken": "Код настройки", "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Подсеть", "addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.", "selectSites": "Выберите сайты", + "selectLabels": "Выберите метки", "sitesDescription": "Клиент будет иметь подключение к выбранным сайтам", "clientInstallOlm": "Установить Olm", "clientInstallOlmDescription": "Запустите Olm на вашей системе", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Сайт", "selectSite": "Выберите сайт...", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}", + "labelsSelectorLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Удаленные узлы", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Секретный ключ", + "remoteExitNodeNetworkingTitle": "Настройки сети", + "remoteExitNodeNetworkingDescription": "Настройте, как этот удаленный узел выхода маршрутизирует трафик и какие сайты предпочитают подключаться через него. Расширенные функции для использования с конфигурациями магистральной сети.", + "remoteExitNodeNetworkingSave": "Сохранить настройки", + "remoteExitNodeNetworkingSaveSuccessTitle": "Сетевые настройки сохранены", + "remoteExitNodeNetworkingSaveSuccessDescription": "Сетевые настройки были успешно обновлены.", + "remoteExitNodeNetworkingSaveError": "Не удалось сохранить сетевые настройки", + "remoteExitNodeNetworkingSubnetsTitle": "Удалённые подсети", + "remoteExitNodeNetworkingSubnetsDescription": "Определите диапазоны CIDR, которые этот удаленный узел выхода будет использовать для маршрутизации трафика. Введите действительный CIDR (например, 10.0.0.0/8) и нажмите Enter, чтобы добавить.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети", + "remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений", + "remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.", + "remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...", + "remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки", "remoteExitNodeCreate": { "title": "Создать удалённый узел", "description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC провайдер", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Подсеть", + "utilitySubnet": "Утилита подсети", "subnetDescription": "Подсеть для конфигурации сети этой организации.", "customDomain": "Пользовательский домен", "authPage": "Страницы аутентификации", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "Белый список email", "memberPortalResourceDisabled": "Ресурс отключён", "memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов", + "resourceLauncherTitle": "Запуск ресурса", + "resourceLauncherDescription": "Просмотр деталей ресурса и запуск их из одного места", + "resourceLauncherSearchPlaceholder": "Поиск всех сайтов...", + "resourceLauncherDefaultView": "По умолчанию", + "resourceLauncherSaveView": "Сохранить вид", + "resourceLauncherSaveToCurrentView": "Сохранить в текущий вид", + "resourceLauncherResetView": "Сбросить вид", + "resourceLauncherSaveAsNewView": "Сохранить как новый вид", + "resourceLauncherSaveAsNewViewDescription": "Дайте этому виду имя, чтобы сохранить текущие фильтры и макет.", + "resourceLauncherSaveForEveryone": "Сохранить для всех", + "resourceLauncherSaveForEveryoneDescription": "Поделитесь этим видом со всеми членами организации. Если не отмечено, видимость только для вас.", + "resourceLauncherMakePersonal": "Сделать личным", + "resourceLauncherFilter": "Фильтр", + "resourceLauncherSort": "Сортировать", + "resourceLauncherSortAscending": "Сортировать по возрастанию", + "resourceLauncherSortDescending": "Сортировать по убыванию", + "resourceLauncherSettings": "Настройки", + "resourceLauncherGroupBy": "Группировать по", + "resourceLauncherGroupBySite": "Сайт", + "resourceLauncherGroupByLabel": "Метка", + "resourceLauncherLayout": "Макет", + "resourceLauncherLayoutGrid": "Сетка", + "resourceLauncherLayoutList": "Список", + "resourceLauncherShowLabels": "Показать метки", + "resourceLauncherShowSiteTags": "Показать теги сайта", + "resourceLauncherShowRecents": "Показать недавно", + "resourceLauncherDeleteView": "Удалить вид", + "resourceLauncherViewAsAdmin": "Просмотр как администратор", + "resourceLauncherResourceDetailsDescription": "Просмотр деталей этого ресурса.", + "resourceLauncherUnlabeled": "Без меток", + "resourceLauncherNoSite": "Без сайта", + "resourceLauncherNoResourcesInGroup": "Нет ресурсов в данной группе", + "resourceLauncherEmptyStateTitle": "Нет доступных ресурсов", + "resourceLauncherEmptyStateDescription": "У вас пока нет доступа ни к одному ресурсу. Обратитесь к администратору, чтобы запросить доступ.", + "resourceLauncherEmptyStateNoResultsTitle": "Ресурсы не найдены", + "resourceLauncherEmptyStateNoResultsDescription": "Ни один ресурс не соответствует вашему текущему поисковому запросу или фильтрам. Попробуйте их изменить, чтобы найти нужное.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ни один ресурс не соответствует \"{query}\". Попробуйте изменить параметры поиска или очистить фильтры, чтобы увидеть все ресурсы.", + "resourceLauncherCopiedToClipboard": "Скопировано в буфер обмена", + "resourceLauncherCopiedAccessDescription": "Доступ к ресурсу был скопирован в ваш буфер обмена.", + "resourceLauncherViewNamePlaceholder": "Имя вида", + "resourceLauncherViewNameLabel": "Имя вида", + "resourceLauncherViewSaved": "Вид сохранён", + "resourceLauncherViewSavedDescription": "Ваш вид запуска был сохранён.", + "resourceLauncherViewSaveFailed": "Не удалось сохранить вид", + "resourceLauncherViewSaveFailedDescription": "Не удалось сохранить вид. Пожалуйста, попробуйте еще раз.", + "resourceLauncherViewDeleted": "Вид удалён", + "resourceLauncherViewDeletedDescription": "Вид запуска был удалён.", + "resourceLauncherViewDeleteFailed": "Не удалось удалить вид", + "resourceLauncherViewDeleteFailedDescription": "Не удалось удалить вид. Пожалуйста, попробуйте еще раз.", "memberPortalPrevious": "Предыдущий", "memberPortalNext": "Следующий", "httpSettings": "Настройки HTTP", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----", "sshPrivateKeyRequired": "Требуется личный ключ", "vncTitle": "VNC", - "vncSignInDescription": "Введите пароль VNC для подключения", + "vncSignInDescription": "Введите ваши учетные данные VNC для подключения", + "vncUsernameOptional": "Имя пользователя (необязательно)", "vncPasswordOptional": "Пароль (необязательно)", "vncNoResourceTarget": "Отсутствует целевой ресурс", "vncFailedToLoadNovnc": "Не удалось загрузить noVNC", From a2fea7f71411b8d786f91088b3753f54e56d523b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:12 -0400 Subject: [PATCH 74/79] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index db5f8158a..549bd205e 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -123,6 +123,16 @@ "siteUpdated": "Site güncellendi", "siteUpdatedDescription": "Site güncellendi.", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın", + "siteRestartTitle": "Siteyi Yeniden Başlat", + "siteRestartDescription": "Bu site için WireGuard tünelini yeniden başlatın. Bu, bağlantıyı kısa süreliğine keser.", + "siteRestartBody": "Site tüneli düzgün çalışmadığında ve ana bilgisayarı yeniden başlatmadan bağlantıyı yeniden sağlamak istiyorsanız bunu kullanın.", + "siteRestartButton": "Siteyi Yeniden Başlat", + "siteRestartDialogMessage": "{name} için WireGuard tünelini yeniden başlatmak istediğinizden emin misiniz? Site kısa süreliğine bağlantıyı kaybedecektir.", + "siteRestartWarning": "Tünel yeniden başlatılırken site kısa süreliğine kesintiye uğrar.", + "siteRestarted": "Site yeniden başlatıldı", + "siteRestartedDescription": "WireGuard tüneli yeniden başlatıldı.", + "siteErrorRestart": "Sitenin yeniden başlatılması başarısız oldu", + "siteErrorRestartDescription": "Site yeniden başlatılırken bir hata oluştu.", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın", "siteResourcesTab": "Kaynaklar", "siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Planı Uygula", "actionListBlueprints": "Plan Listesini Görüntüle", "actionGetBlueprint": "Planı Elde Et", + "actionCreateOrgWideLauncherView": "Kuruluş Genelinde Başlatıcı Görünümü Oluşturma", "setupToken": "Kurulum Simgesi", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Alt ağ", "addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.", "selectSites": "Siteleri seçin", + "selectLabels": "Etiketleri seçin", "sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır", "clientInstallOlm": "Olm Yükle", "clientInstallOlmDescription": "Sisteminizde Olm çalıştırın", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Site seç...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}", + "labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}", "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Uzak Düğümler", "remoteExitNodeId": "Kimlik", "remoteExitNodeSecretKey": "Gizli", + "remoteExitNodeNetworkingTitle": "Ağ Ayarları", + "remoteExitNodeNetworkingDescription": "Bu uzak çıkış düğümünün trafiği nasıl yönlendireceğini ve hangi sitelerin bu üzerinden bağlanmayı tercih edeceğini yapılandırın. Gelişmiş özellikler geri bağlantı ağ konfigürasyonları ile kullanılmalıdır.", + "remoteExitNodeNetworkingSave": "Ayarları Kaydet", + "remoteExitNodeNetworkingSaveSuccessTitle": "Ağ ayarları kaydedildi", + "remoteExitNodeNetworkingSaveSuccessDescription": "Ağ ayarları başarıyla güncellendi.", + "remoteExitNodeNetworkingSaveError": "Ağ ayarları kaydedilemedi", + "remoteExitNodeNetworkingSubnetsTitle": "Uzak Alt Ağlar", + "remoteExitNodeNetworkingSubnetsDescription": "Bu uzak çıkış düğümünün trafiği taşıyacağı CIDR aralıklarını tanımlayın. Geçerli bir CIDR (örneğin, 10.0.0.0/8) yazın ve eklemek için Enter tuşuna basın.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi", + "remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri", + "remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.", + "remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...", + "remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi", "remoteExitNodeCreate": { "title": "Uzak Düğüm Oluştur", "description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", "subnet": "Alt ağ", + "utilitySubnet": "Yardımcı Alt Ağ", "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", "customDomain": "Özel Alan", "authPage": "Kimlik Sayfaları", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-posta Beyaz Listesi", "memberPortalResourceDisabled": "Kaynak Devre Dışı", "memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor", + "resourceLauncherTitle": "Kaynak Başlatıcı", + "resourceLauncherDescription": "Kaynağın detaylarını görüntüleyin ve tek bir yerden başlatın", + "resourceLauncherSearchPlaceholder": "Tüm siteleri ara...", + "resourceLauncherDefaultView": "Varsayılan", + "resourceLauncherSaveView": "Görünümü Kaydet", + "resourceLauncherSaveToCurrentView": "Mevcut Görünüme Kaydet", + "resourceLauncherResetView": "Görünümü Sıfırla", + "resourceLauncherSaveAsNewView": "Yeni Görünüm Olarak Kaydet", + "resourceLauncherSaveAsNewViewDescription": "Geçerli filtrelerinizi ve düzeninizi kaydetmek için bu görünüme bir ad verin.", + "resourceLauncherSaveForEveryone": "Herkes İçin Kaydet", + "resourceLauncherSaveForEveryoneDescription": "Bu görünümü tüm kuruluş üyeleriyle paylaşın. İşaretli değilse, görünüm yalnızca size görünür olur.", + "resourceLauncherMakePersonal": "Kişisel Yap", + "resourceLauncherFilter": "Filtre", + "resourceLauncherSort": "Sıralama", + "resourceLauncherSortAscending": "Artan sırala", + "resourceLauncherSortDescending": "Azalan sırala", + "resourceLauncherSettings": "Ayarlar", + "resourceLauncherGroupBy": "Grupla", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Etiket", + "resourceLauncherLayout": "Düzen", + "resourceLauncherLayoutGrid": "Izgara", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Etiketleri Göster", + "resourceLauncherShowSiteTags": "Site Etiketlerini Göster", + "resourceLauncherShowRecents": "Son Eklenenleri Göster", + "resourceLauncherDeleteView": "Görünümü Sil", + "resourceLauncherViewAsAdmin": "Yönetici Olarak Görüntüle", + "resourceLauncherResourceDetailsDescription": "Bu kaynağın detaylarını görüntüleyin.", + "resourceLauncherUnlabeled": "Etiketsiz", + "resourceLauncherNoSite": "Site Yok", + "resourceLauncherNoResourcesInGroup": "Bu grupta kaynak yok", + "resourceLauncherEmptyStateTitle": "Kullanılabilir Kaynak Yok", + "resourceLauncherEmptyStateDescription": "Henüz hiçbir kaynağa erişiminiz yok. Erişim istemek için yöneticinizle iletişime geçin.", + "resourceLauncherEmptyStateNoResultsTitle": "Kaynak Bulunamadı", + "resourceLauncherEmptyStateNoResultsDescription": "Mevcut arama veya filtrelerinizle eşleşen kaynak yok. Aradığınızı bulmak için ayarları değiştirmeyi deneyin.", + "resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\" ile eşleşen kaynak yok. Tüm kaynakları görmek için aramayı düzenlemeyi veya filtreleri temizlemeyi deneyin.", + "resourceLauncherCopiedToClipboard": "Panoya kopyalandı", + "resourceLauncherCopiedAccessDescription": "Kaynağa erişim panonuza kopyalandı.", + "resourceLauncherViewNamePlaceholder": "Görünüm adı", + "resourceLauncherViewNameLabel": "Görünüm Adı", + "resourceLauncherViewSaved": "Görünüm kaydedildi", + "resourceLauncherViewSavedDescription": "Başlatıcı görünümünüz kaydedildi.", + "resourceLauncherViewSaveFailed": "Görünüm kaydedilemedi", + "resourceLauncherViewSaveFailedDescription": "Başlatıcı görünümü kaydedilemedi. Lütfen yeniden deneyin.", + "resourceLauncherViewDeleted": "Görünüm silindi", + "resourceLauncherViewDeletedDescription": "Başlatıcı görünüm silindi.", + "resourceLauncherViewDeleteFailed": "Görünüm silinemedi", + "resourceLauncherViewDeleteFailedDescription": "Başlatıcı görünümü silinemedi. Lütfen tekrar deneyin.", "memberPortalPrevious": "Önceki", "memberPortalNext": "Sonraki", "httpSettings": "HTTP Ayarları", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----", "sshPrivateKeyRequired": "Özel anahtar gereklidir", "vncTitle": "VNC", - "vncSignInDescription": "Bağlanmak için VNC parolanızı girin", + "vncSignInDescription": "Bağlanmak için VNC kimlik bilgilerinizi girin", + "vncUsernameOptional": "Kullanıcı Adı (isteğe bağlı)", "vncPasswordOptional": "Parola (isteğe bağlı)", "vncNoResourceTarget": "Kaynak hedefi mevcut değil", "vncFailedToLoadNovnc": "NoVNC yüklenemedi", From 4985ed02d3e3f4b8d2e0b70a76b6811fbd6a0e13 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:14 -0400 Subject: [PATCH 75/79] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index d4f124f96..bac29a695 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -123,6 +123,16 @@ "siteUpdated": "站点已更新", "siteUpdatedDescription": "网站已更新。", "siteGeneralDescription": "配置此站点的常规设置", + "siteRestartTitle": "重启站点", + "siteRestartDescription": "重启此站点的WireGuard隧道。此操作将暂时中断连接。", + "siteRestartBody": "如果站点隧道无法正常工作,并且您希望在不重启主机的情况下强制重新连接,请使用此选项。", + "siteRestartButton": "重启站点", + "siteRestartDialogMessage": "确定要重启{name}的WireGuard隧道吗?站点将暂时断开连接。", + "siteRestartWarning": "隧道重启时,站点将暂时断开连接。", + "siteRestarted": "站点已重启", + "siteRestartedDescription": "WireGuard隧道已重启。", + "siteErrorRestart": "重启站点失败", + "siteErrorRestartDescription": "重启站点时发生错误。", "siteSettingDescription": "配置站点设置", "siteResourcesTab": "资源", "siteResourcesNoneOnSite": "此站点尚无公开或私人资源。", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "应用蓝图", "actionListBlueprints": "列表蓝图", "actionGetBlueprint": "获取蓝图", + "actionCreateOrgWideLauncherView": "创建组织范围的启动器视图", "setupToken": "设置令牌", "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "子网", "addressDescription": "客户的内部地址。必须属于组织的子网。", "selectSites": "选择站点", + "selectLabels": "选择标签", "sitesDescription": "客户端将与所选站点进行连接", "clientInstallOlm": "安装 Olm", "clientInstallOlmDescription": "在您的系统上运行 Olm", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "站点", "selectSite": "选择站点...", "multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}", + "labelsSelectorLabelsCount": "{count, plural, other {# 标签}}", "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "远程节点", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "密钥", + "remoteExitNodeNetworkingTitle": "网络设置", + "remoteExitNodeNetworkingDescription": "配置此远程出口节点如何路由流量以及哪些站点优先通过其连接。高级功能可用于回程网络配置。", + "remoteExitNodeNetworkingSave": "保存设置", + "remoteExitNodeNetworkingSaveSuccessTitle": "网络设置已保存", + "remoteExitNodeNetworkingSaveSuccessDescription": "网络设置已成功更新。", + "remoteExitNodeNetworkingSaveError": "保存网络设置失败", + "remoteExitNodeNetworkingSubnetsTitle": "远程子网", + "remoteExitNodeNetworkingSubnetsDescription": "定义此远程出口节点将路由流量的CIDR范围。输入有效的CIDR(例如10.0.0.0/8)并按Enter键添加。", + "remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网", + "remoteExitNodeNetworkingLabelsTitle": "首选标签", + "remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。", + "remoteExitNodeNetworkingLabelsButtonText": "选择标签……", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……", + "remoteExitNodeNetworkingLabelsLoadError": "无法加载标签", "remoteExitNodeCreate": { "title": "创建远程节点", "description": "创建一个新的自托管远程中继和代理服务器节点", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "子网", + "utilitySubnet": "实用程序子网", "subnetDescription": "此组织网络配置的子网。", "customDomain": "自定义域", "authPage": "身份验证页面", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "电子邮件白名单", "memberPortalResourceDisabled": "资源已禁用", "memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源", + "resourceLauncherTitle": "资源启动器", + "resourceLauncherDescription": "查看资源详情并从一个地方启动它们", + "resourceLauncherSearchPlaceholder": "搜索所有站点……", + "resourceLauncherDefaultView": "默认", + "resourceLauncherSaveView": "保存视图", + "resourceLauncherSaveToCurrentView": "保存至当前视图", + "resourceLauncherResetView": "重置视图", + "resourceLauncherSaveAsNewView": "另存为新视图", + "resourceLauncherSaveAsNewViewDescription": "为此视图命名,以便保存您当前的过滤器和布局。", + "resourceLauncherSaveForEveryone": "为所有人保存", + "resourceLauncherSaveForEveryoneDescription": "与所有组织成员共享此视图。如果未选中,此视图仅对您可见。", + "resourceLauncherMakePersonal": "创建个人", + "resourceLauncherFilter": "筛选", + "resourceLauncherSort": "排序", + "resourceLauncherSortAscending": "按升序排序", + "resourceLauncherSortDescending": "按降序排序", + "resourceLauncherSettings": "设置", + "resourceLauncherGroupBy": "按组", + "resourceLauncherGroupBySite": "站点", + "resourceLauncherGroupByLabel": "标签", + "resourceLauncherLayout": "布局", + "resourceLauncherLayoutGrid": "网格", + "resourceLauncherLayoutList": "列表", + "resourceLauncherShowLabels": "显示标签", + "resourceLauncherShowSiteTags": "显示站点标签", + "resourceLauncherShowRecents": "显示最近使用", + "resourceLauncherDeleteView": "删除视图", + "resourceLauncherViewAsAdmin": "以管理员身份查看", + "resourceLauncherResourceDetailsDescription": "查看此资源的详细信息。", + "resourceLauncherUnlabeled": "未标记", + "resourceLauncherNoSite": "无站点", + "resourceLauncherNoResourcesInGroup": "此组中没有资源", + "resourceLauncherEmptyStateTitle": "没有可用资源", + "resourceLauncherEmptyStateDescription": "您还没有访问任何资源。请联系您的管理员以请求访问。", + "resourceLauncherEmptyStateNoResultsTitle": "未找到资源", + "resourceLauncherEmptyStateNoResultsDescription": "没有资源与您当前的搜索或过滤器匹配。尝试调整它们以找到您想要的内容。", + "resourceLauncherEmptyStateNoResultsWithQuery": "没有资源匹配\"{query}\"。尝试调整您的搜索或清除过滤器以查看所有资源。", + "resourceLauncherCopiedToClipboard": "已复制到剪贴板", + "resourceLauncherCopiedAccessDescription": "资源访问权限已复制到剪贴板。", + "resourceLauncherViewNamePlaceholder": "查看名称", + "resourceLauncherViewNameLabel": "查看名称", + "resourceLauncherViewSaved": "视图已保存", + "resourceLauncherViewSavedDescription": "您的启动器视图已保存。", + "resourceLauncherViewSaveFailed": "保存视图失败", + "resourceLauncherViewSaveFailedDescription": "无法保存启动器视图。请再试一次。", + "resourceLauncherViewDeleted": "视图已删除", + "resourceLauncherViewDeletedDescription": "启动器视图已删除。", + "resourceLauncherViewDeleteFailed": "删除视图失败", + "resourceLauncherViewDeleteFailedDescription": "无法删除启动器视图。请再试一次。", "memberPortalPrevious": "上一页", "memberPortalNext": "下一页", "httpSettings": "HTTP 设置", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyRequired": "需要私钥", "vncTitle": "VNC", - "vncSignInDescription": "输入您的 VNC 密码以连接", + "vncSignInDescription": "输入您的VNC凭据以连接", + "vncUsernameOptional": "用户名(可选)", "vncPasswordOptional": "密码 (可选)", "vncNoResourceTarget": "没有可用的资源目标", "vncFailedToLoadNovnc": "加载 noVNC 失败", From 2e3ab10f5e57a3948d5cb481a4196bb55d91c95a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 1 Jul 2026 21:28:16 -0400 Subject: [PATCH 76/79] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 81 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index f26499819..ec65f9307 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -123,6 +123,16 @@ "siteUpdated": "Område oppdatert", "siteUpdatedDescription": "Området har blitt oppdatert.", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", + "siteRestartTitle": "Start område på nytt", + "siteRestartDescription": "Start WireGuard-tunnelen for dette området på nytt. Dette vil midlertidig avbryte tilkoblingen.", + "siteRestartBody": "Bruk dette hvis områdetunnelen ikke fungerer riktig og du vil tvinge en ny tilkobling uten å starte verten på nytt.", + "siteRestartButton": "Start område på nytt", + "siteRestartDialogMessage": "Er du sikker på at du vil starte WireGuard-tunnelen for {name} på nytt? Området vil midlertidig miste tilkoblingen.", + "siteRestartWarning": "Området vil kobles kort fra mens tunnelen starter om.", + "siteRestarted": "Område startet på nytt", + "siteRestartedDescription": "WireGuard-tunnelen er startet på nytt.", + "siteErrorRestart": "Kan ikke starte område på nytt", + "siteErrorRestartDescription": "En feil oppstod ved omstart av området.", "siteSettingDescription": "Konfigurere innstillingene på nettstedet", "siteResourcesTab": "Ressurser", "siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.", @@ -1401,6 +1411,7 @@ "actionApplyBlueprint": "Bruk blåkopi", "actionListBlueprints": "List opp blåkopier", "actionGetBlueprint": "Hent blåkopi", + "actionCreateOrgWideLauncherView": "Opprett lanseringsvisning for hele organisasjonen", "setupToken": "Oppsetttoken", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", @@ -2077,6 +2088,7 @@ "subnetPlaceholder": "Subnett", "addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.", "selectSites": "Velg områder", + "selectLabels": "Velg etiketter", "sitesDescription": "Klienten vil ha tilkobling til de valgte områdene", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt", @@ -2304,6 +2316,7 @@ "createInternalResourceDialogSite": "Område", "selectSite": "Velg område...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", + "labelsSelectorLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}", "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", @@ -2378,6 +2391,21 @@ "sidebarRemoteExitNodes": "Eksterne Noder", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sikkerhetsnøkkel", + "remoteExitNodeNetworkingTitle": "Nettverksinnstillinger", + "remoteExitNodeNetworkingDescription": "Konfigurer hvordan denne fjerne utgangsnoden ruter trafikk og hvilke områder som foretrekker å koble gjennom den. Avanserte funksjoner for å brukes med bakhalstilkoplingskonfigurasjoner.", + "remoteExitNodeNetworkingSave": "Lagre innstillinger", + "remoteExitNodeNetworkingSaveSuccessTitle": "Nettverksinnstillinger lagret", + "remoteExitNodeNetworkingSaveSuccessDescription": "Nettverksinnstillingene er oppdatert.", + "remoteExitNodeNetworkingSaveError": "Klarte ikke å lagre nettverksinnstillinger", + "remoteExitNodeNetworkingSubnetsTitle": "Fjern-subnett", + "remoteExitNodeNetworkingSubnetsDescription": "Definer CIDR-områdene som denne fjernutgangsnoden vil rute trafikk til. Skriv inn en gyldig CIDR (f.eks. 10.0.0.0/8) og trykk Enter for å legge til.", + "remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)", + "remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett", + "remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter", + "remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.", + "remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...", + "remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...", + "remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter", "remoteExitNodeCreate": { "title": "Opprett ekstern node", "description": "Opprett en ny egendrift ekstern relé- og proxyservernode", @@ -2556,6 +2584,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnett", + "utilitySubnet": "Nyttesubnett", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", "customDomain": "Egendefinert domene", "authPage": "Autentiseringssider", @@ -3541,6 +3570,55 @@ "memberPortalEmailWhitelist": "E-post-hviteliste", "memberPortalResourceDisabled": "Ressurs deaktivert", "memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser", + "resourceLauncherTitle": "Ressurslansering", + "resourceLauncherDescription": "Vis ressursdetaljer og start dem fra ett sted", + "resourceLauncherSearchPlaceholder": "Søk i alle områder...", + "resourceLauncherDefaultView": "Standard", + "resourceLauncherSaveView": "Lagre visning", + "resourceLauncherSaveToCurrentView": "Lagre til nåværende visning", + "resourceLauncherResetView": "Tilbakestill visning", + "resourceLauncherSaveAsNewView": "Lagre som ny visning", + "resourceLauncherSaveAsNewViewDescription": "Gi denne visningen et navn for å lagre dine nåværende filtre og oppsett.", + "resourceLauncherSaveForEveryone": "Lagre for alle", + "resourceLauncherSaveForEveryoneDescription": "Del denne visningen med alle organisasjonsmedlemmer. Når avkrysset, er visningen synlig bare for deg.", + "resourceLauncherMakePersonal": "Gjør personlig", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sorter", + "resourceLauncherSortAscending": "Sorter stigende", + "resourceLauncherSortDescending": "Sorter synkende", + "resourceLauncherSettings": "Innstillinger", + "resourceLauncherGroupBy": "Grupper etter", + "resourceLauncherGroupBySite": "Område", + "resourceLauncherGroupByLabel": "Etikett", + "resourceLauncherLayout": "Oppsett", + "resourceLauncherLayoutGrid": "Rutenett", + "resourceLauncherLayoutList": "Liste", + "resourceLauncherShowLabels": "Vis etiketter", + "resourceLauncherShowSiteTags": "Vis områdestikkord", + "resourceLauncherShowRecents": "Vis nylige", + "resourceLauncherDeleteView": "Slett visning", + "resourceLauncherViewAsAdmin": "Vis som administrator", + "resourceLauncherResourceDetailsDescription": "Vis detaljer for denne ressursen.", + "resourceLauncherUnlabeled": "Umerket", + "resourceLauncherNoSite": "Ingen område", + "resourceLauncherNoResourcesInGroup": "Ingen ressurser i denne gruppen", + "resourceLauncherEmptyStateTitle": "Ingen tilgjengelige ressurser", + "resourceLauncherEmptyStateDescription": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å be om tilgang.", + "resourceLauncherEmptyStateNoResultsTitle": "Ingen ressurser funnet", + "resourceLauncherEmptyStateNoResultsDescription": "Ingen ressurser matcher dine nåværende søk eller filtre. Prøv å justere dem for å finne det du leter etter.", + "resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søket eller fjern filtrene for å se alle ressursene.", + "resourceLauncherCopiedToClipboard": "Kopiert til utklippstavlen", + "resourceLauncherCopiedAccessDescription": "Ressurstilgang er kopiert til utklippstavlen din.", + "resourceLauncherViewNamePlaceholder": "Visningsnavn", + "resourceLauncherViewNameLabel": "Visningsnavn", + "resourceLauncherViewSaved": "Visning lagret", + "resourceLauncherViewSavedDescription": "Lanseringsvisningen din er lagret.", + "resourceLauncherViewSaveFailed": "Feilet å lagre visning", + "resourceLauncherViewSaveFailedDescription": "Kunne ikke lagre lanseringsvisningen. Vennligst prøv igjen.", + "resourceLauncherViewDeleted": "Visning slettet", + "resourceLauncherViewDeletedDescription": "Lanseringsvisningen er slettet.", + "resourceLauncherViewDeleteFailed": "Klarte ikke å slette visning", + "resourceLauncherViewDeleteFailedDescription": "Kunne ikke slette lanseringsvisningen. Vennligst prøv igjen.", "memberPortalPrevious": "Forrige", "memberPortalNext": "Neste", "httpSettings": "HTTP Innstillinger", @@ -3576,7 +3654,8 @@ "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----", "sshPrivateKeyRequired": "Privat nøkkel er påkrevd", "vncTitle": "VNC", - "vncSignInDescription": "Skriv inn VNC-passordet for å koble til", + "vncSignInDescription": "Skriv inn VNC-kredentialene dine for å koble til", + "vncUsernameOptional": "Brukernavn (valgfritt)", "vncPasswordOptional": "Passord (valgfritt)", "vncNoResourceTarget": "Ingen ressursemål tilgjengelig", "vncFailedToLoadNovnc": "Klarte ikke å laste noVNC", From 005f050a81c5899956545361ae60bb025e3ef65d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 21:36:23 -0400 Subject: [PATCH 77/79] improve pagination --- src/app/[orgId]/page.tsx | 1 - .../resource-launcher/LauncherGroupList.tsx | 29 ++++------- .../resource-launcher/ResourceLauncher.tsx | 6 +-- src/lib/launcherServerData.ts | 48 +------------------ 4 files changed, 12 insertions(+), 72 deletions(-) diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index b76b77b7b..7abb1b317 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -87,7 +87,6 @@ export default async function OrgPage(props: OrgPageProps) { savedConfig={launcherData.savedConfig} groups={launcherData.groups} groupsPagination={launcherData.groupsPagination} - resourcesByGroupKey={launcherData.resourcesByGroupKey} /> ) : null} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index bb3418f8d..c7fe6b905 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -1,7 +1,6 @@ "use client"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; -import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, @@ -24,7 +23,6 @@ type LauncherGroupListProps = { page: number; pageSize: number; }; - resourcesByGroupKey: Record; onClearFilters?: () => void; onResourceSelect?: (resource: LauncherResource) => void; }; @@ -43,7 +41,6 @@ export function LauncherGroupList({ config, initialGroups, groupsPagination, - resourcesByGroupKey, onClearFilters, onResourceSelect }: LauncherGroupListProps) { @@ -128,22 +125,16 @@ export function LauncherGroupList({ return (
- {groups.map((group) => { - const groupResources = resourcesByGroupKey[group.groupKey]; - - return ( - - ); - })} + {groups.map((group) => ( + + ))}
{isFetchingNextPage ? (
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 6503de169..be5620d25 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -20,7 +20,6 @@ import { writeLauncherLastView, type LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; -import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { buildLauncherPath, getLauncherUrlBaseConfig, @@ -73,7 +72,6 @@ type ResourceLauncherProps = { page: number; pageSize: number; }; - resourcesByGroupKey: Record; }; export default function ResourceLauncher({ @@ -84,8 +82,7 @@ export default function ResourceLauncher({ config, savedConfig, groups, - groupsPagination, - resourcesByGroupKey + groupsPagination }: ResourceLauncherProps) { const t = useTranslations(); const { toast } = useToast(); @@ -511,7 +508,6 @@ export default function ResourceLauncher({ config={config} initialGroups={groups} groupsPagination={groupsPagination} - resourcesByGroupKey={resourcesByGroupKey} onClearFilters={handleClearFilters} /> diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index da8fb1846..2c542c5fc 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -4,24 +4,13 @@ import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, - LauncherResource, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, - ListLauncherResourcesResponse, ListLauncherViewsResponse } from "@server/routers/launcher/types"; import { AxiosResponse } from "axios"; -export type LauncherGroupResources = { - resources: LauncherResource[]; - pagination: { - total: number; - page: number; - pageSize: number; - }; -}; - export type LauncherPageData = { views: LauncherViewRecord[]; activeViewId: LauncherActiveViewId; @@ -33,12 +22,6 @@ export type LauncherPageData = { page: number; pageSize: number; }; - resourcesByGroupKey: Record; -}; - -const emptyResources: LauncherGroupResources = { - resources: [], - pagination: { total: 0, page: 1, pageSize: 20 } }; export async function fetchLauncherPageData( @@ -88,41 +71,12 @@ export async function fetchLauncherPageData( groupsPagination = groupsRes.data.data.pagination; } catch (e) {} - const resourcesByGroupKey: Record = {}; - - await Promise.all( - groups.map(async (group) => { - try { - const sp = buildLauncherSearchParams( - { - ...groupFilters, - groupKey: group.groupKey - }, - 1 - ); - const res = await internal.get< - AxiosResponse - >( - `/org/${orgId}/launcher/resources?${sp.toString()}`, - cookieHeader - ); - resourcesByGroupKey[group.groupKey] = { - resources: res.data.data.resources, - pagination: res.data.data.pagination - }; - } catch (e) { - resourcesByGroupKey[group.groupKey] = emptyResources; - } - }) - ); - return { views, activeViewId, config, savedConfig, groups, - groupsPagination, - resourcesByGroupKey + groupsPagination }; } From 86e6ebc8af1f988fbb3fe7fae8b6b0cc07e37ba8 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 21:39:55 -0400 Subject: [PATCH 78/79] Comment out sync again for now --- server/routers/newt/sync.ts | 84 ++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index 8e93cdf45..6c3ab0a0f 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -9,47 +9,45 @@ import { import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { - const { - tcpTargets, - udpTargets, - validHealthCheckTargets, - browserGatewayTargets, - remoteExitNodeSubnets - } = await buildTargetConfigurationForNewtClient(site.siteId); - - let exitNode: ExitNode | undefined; - if (site.exitNodeId) { - [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); - } - const { peers, targets } = await buildClientConfigurationForNewtClient( - site, - exitNode - ); - - await sendToClient( - newt.newtId, - { - type: "newt/sync", - data: { - proxyTargets: { - udp: udpTargets, - tcp: tcpTargets - }, - healthCheckTargets: validHealthCheckTargets, - peers: peers, - clientTargets: targets, - browserGatewayTargets: browserGatewayTargets, - remoteExitNodeSubnets: remoteExitNodeSubnets - } - }, - { - compress: canCompress(newt.version, "newt") - } - ).catch((error) => { - logger.warn(`Error sending newt sync message:`, error); - }); + // const { + // tcpTargets, + // udpTargets, + // validHealthCheckTargets, + // browserGatewayTargets, + // remoteExitNodeSubnets + // } = await buildTargetConfigurationForNewtClient(site.siteId); + // let exitNode: ExitNode | undefined; + // if (site.exitNodeId) { + // [exitNode] = await db + // .select() + // .from(exitNodes) + // .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + // .limit(1); + // } + // const { peers, targets } = await buildClientConfigurationForNewtClient( + // site, + // exitNode + // ); + // await sendToClient( + // newt.newtId, + // { + // type: "newt/sync", + // data: { + // proxyTargets: { + // udp: udpTargets, + // tcp: tcpTargets + // }, + // healthCheckTargets: validHealthCheckTargets, + // peers: peers, + // clientTargets: targets, + // browserGatewayTargets: browserGatewayTargets, + // remoteExitNodeSubnets: remoteExitNodeSubnets + // } + // }, + // { + // compress: canCompress(newt.version, "newt") + // } + // ).catch((error) => { + // logger.warn(`Error sending newt sync message:`, error); + // }); } From 5a2388a1e6c0e82807e5e8444ec0ff318a9816cf Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 21:43:51 -0400 Subject: [PATCH 79/79] Fix imports --- .../listRemoteExitNodePreferenceLabels.ts | 10 +----- .../listRemoteExitNodeResources.ts | 9 +---- .../setRemoteExitNodePreferenceLabels.ts | 10 +----- .../setRemoteExitNodeResources.ts | 9 +---- server/routers/remoteExitNode/index.ts | 1 + server/routers/remoteExitNode/types.ts | 34 +++++++++++++++++++ .../[remoteExitNodeId]/networking/page.tsx | 8 ++--- 7 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 server/routers/remoteExitNode/index.ts diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts index 47ff11c48..a03e02257 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels.ts @@ -25,21 +25,13 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), remoteExitNodeId: z.string().min(1) }); -export type ListRemoteExitNodePreferenceLabelsResponse = { - labels: { - remoteExitNodePreferenceLabelId: number; - labelId: number; - name: string; - color: string; - }[]; -}; - export async function listRemoteExitNodePreferenceLabels( req: Request, res: Response, diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts index 5f91fd0b3..a51b4bed6 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodeResources.ts @@ -20,20 +20,13 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode/types"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), remoteExitNodeId: z.string().min(1) }); -export type ListRemoteExitNodeResourcesResponse = { - resources: { - remoteExitNodeResourceId: number; - remoteExitNodeId: string; - destination: string; - }[]; -}; - export async function listRemoteExitNodeResources( req: Request, res: Response, diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts index 3185e5a99..4c300d589 100644 --- a/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts +++ b/server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels.ts @@ -25,6 +25,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -37,15 +38,6 @@ const bodySchema = z.strictObject({ export type SetRemoteExitNodePreferenceLabelsBody = z.infer; -export type SetRemoteExitNodePreferenceLabelsResponse = { - labels: { - remoteExitNodePreferenceLabelId: number; - labelId: number; - name: string; - color: string; - }[]; -}; - export async function setRemoteExitNodePreferenceLabels( req: Request, res: Response, diff --git a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts index feea447ed..a5f9c1478 100644 --- a/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts +++ b/server/private/routers/remoteExitNode/setRemoteExitNodeResources.ts @@ -28,6 +28,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { sendToClientsBatch } from "#private/routers/ws"; import { canCompress } from "@server/lib/clientVersionChecks"; +import { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), @@ -45,14 +46,6 @@ const bodySchema = z.strictObject({ export type SetRemoteExitNodeResourcesBody = z.infer; -export type SetRemoteExitNodeResourcesResponse = { - resources: { - remoteExitNodeResourceId: number; - remoteExitNodeId: string; - destination: string; - }[]; -}; - export async function setRemoteExitNodeResources( req: Request, res: Response, diff --git a/server/routers/remoteExitNode/index.ts b/server/routers/remoteExitNode/index.ts new file mode 100644 index 000000000..eea524d65 --- /dev/null +++ b/server/routers/remoteExitNode/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/server/routers/remoteExitNode/types.ts b/server/routers/remoteExitNode/types.ts index 9984b1b4f..3290832a8 100644 --- a/server/routers/remoteExitNode/types.ts +++ b/server/routers/remoteExitNode/types.ts @@ -43,3 +43,37 @@ export type GetRemoteExitNodeResponse = { online: boolean; type: string | null; }; + +export type ListRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export type SetRemoteExitNodeResourcesResponse = { + resources: { + remoteExitNodeResourceId: number; + remoteExitNodeId: string; + destination: string; + }[]; +}; + +export type ListRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; + +export type SetRemoteExitNodePreferenceLabelsResponse = { + labels: { + remoteExitNodePreferenceLabelId: number; + labelId: number; + name: string; + color: string; + }[]; +}; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx index a74f010a6..e0d03f771 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/networking/page.tsx @@ -27,10 +27,10 @@ import type { TagValue } from "@app/components/multi-select/multi-select-content import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useDebounce } from "use-debounce"; -import type { ListRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodeResources"; -import type { SetRemoteExitNodeResourcesResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodeResources"; -import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/listRemoteExitNodePreferenceLabels"; -import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/private/routers/remoteExitNode/setRemoteExitNodePreferenceLabels"; +import type { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; +import type { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode"; +import type { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; +import type { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode"; import { useTranslations } from "next-intl"; import { ExternalLink } from "lucide-react";