From 44c16d69af484377d1772657e20e5b60bca6c077 Mon Sep 17 00:00:00 2001 From: Gerald Pinder Date: Wed, 3 Jun 2026 12:48:34 -0400 Subject: [PATCH 001/264] fix: Add DELETE /idp/{idpId} to integration API --- server/routers/integration.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 2865b4bcb..685f693a6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,7 +16,6 @@ import { verifyApiKey, verifyApiKeyOrgAccess, verifyApiKeyHasAction, - verifyApiKeyCanSetUserOrgRoles, verifyApiKeySiteAccess, verifyApiKeyResourceAccess, verifyApiKeyTargetAccess, @@ -893,6 +892,13 @@ authenticated.get( idp.getIdp ); +authenticated.delete( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + idp.deleteIdp +); + authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, From 89f3f3c8cdcfa06f478bb333820d55f0f178d3a2 Mon Sep 17 00:00:00 2001 From: ivenos Date: Thu, 4 Jun 2026 10:01:35 +0200 Subject: [PATCH 002/264] Rename docker-compose.yml to compose.yaml --- docker-compose.drizzle.yml => compose.drizzle.yaml | 0 docker-compose.example.yml => compose.example.yaml | 0 docker-compose.mailpit.yml => compose.mailpit.yaml | 0 docker-compose.pgr.yml => compose.pgr.yaml | 0 docker-compose.yml => compose.yaml | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename docker-compose.drizzle.yml => compose.drizzle.yaml (100%) rename docker-compose.example.yml => compose.example.yaml (100%) rename docker-compose.mailpit.yml => compose.mailpit.yaml (100%) rename docker-compose.pgr.yml => compose.pgr.yaml (100%) rename docker-compose.yml => compose.yaml (100%) diff --git a/docker-compose.drizzle.yml b/compose.drizzle.yaml similarity index 100% rename from docker-compose.drizzle.yml rename to compose.drizzle.yaml diff --git a/docker-compose.example.yml b/compose.example.yaml similarity index 100% rename from docker-compose.example.yml rename to compose.example.yaml diff --git a/docker-compose.mailpit.yml b/compose.mailpit.yaml similarity index 100% rename from docker-compose.mailpit.yml rename to compose.mailpit.yaml diff --git a/docker-compose.pgr.yml b/compose.pgr.yaml similarity index 100% rename from docker-compose.pgr.yml rename to compose.pgr.yaml diff --git a/docker-compose.yml b/compose.yaml similarity index 100% rename from docker-compose.yml rename to compose.yaml From 13b691fd7d3af4cfe83251a0918b0ee79732c24a Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:24:24 +0530 Subject: [PATCH 003/264] fix: request logs not loading on initial page open in Community Edition (#2867) --- src/app/[orgId]/settings/logs/request/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index ae11da78a..1092ea388 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -18,7 +18,6 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; -import { build } from "@server/build"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; export default function GeneralPage() { @@ -121,8 +120,7 @@ export default function GeneralPage() { ...logQueries.requests({ orgId: orgId as string, filters: queryFilters - }), - enabled: build !== "oss" + }) }); const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []); From 92d611df9a84e7e0657954a3c5040e56acdbfaab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 11:10:53 +0200 Subject: [PATCH 004/264] chore(dependabot): group dependency updates into single PRs per ecosystem --- .github/dependabot.yml | 48 +++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 685be384c..b3dc38d09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,52 +1,42 @@ version: 2 + updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 1 groups: - dev-patch-updates: - dependency-type: "development" - update-types: - - "patch" - dev-minor-updates: - dependency-type: "development" - update-types: - - "minor" - prod-patch-updates: - dependency-type: "production" - update-types: - - "patch" - prod-minor-updates: - dependency-type: "production" - update-types: - - "minor" - + npm-dependencies: + patterns: + - "*" + - package-ecosystem: "docker" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 1 groups: - patch-updates: - update-types: - - "patch" - minor-updates: - update-types: - - "minor" + docker-dependencies: + patterns: + - "*" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 1 + groups: + github-actions-dependencies: + patterns: + - "*" - package-ecosystem: "gomod" directory: "/install" schedule: interval: "daily" + open-pull-requests-limit: 1 groups: - patch-updates: - update-types: - - "patch" - minor-updates: - update-types: - - "minor" + go-install-dependencies: + patterns: + - "*" From 38203e522b4dcb24cd4d7b677c212178aeeb1b65 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 9 Jun 2026 19:29:00 +0200 Subject: [PATCH 005/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20queries?= =?UTF-8?q?=20that=20prefetch=201000=20users/roles=20in=20private=20resour?= =?UTF-8?q?ces=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PrivateResourceForm.tsx | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 4a786954c..b2298f534 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { From ab4d567af9256849671cd736583d8de1e53178af Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 20:56:24 +0200 Subject: [PATCH 006/264] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b1..b8a50a908 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = { From 4cd0b9a0bb2e474f9ec5409cadfd90db1150dedb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:57:55 +0200 Subject: [PATCH 007/264] =?UTF-8?q?=F0=9F=92=84=20Show=20updates=20availab?= =?UTF-8?q?le=20in=20the=20frontend,=20on=20sites=20&=20user=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../clients/user/[niceId]/general/page.tsx | 55 +++++++++++++++++-- src/components/SitesTable.tsx | 23 +++++--- src/components/UserDevicesTable.tsx | 48 ++++++++++++++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 07ab4d6e8..f389374c1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2957,6 +2957,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a996..87c2d0dec 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lte( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a..5b5ac1db1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -55,6 +55,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -113,12 +116,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -333,6 +335,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +353,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -561,7 +568,7 @@ export default function SitesTable({ } return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb87..17a82dfc9 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, From fe55956079ce3d02da3aa771e9fb10308430c2b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:58:42 +0200 Subject: [PATCH 008/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sites=20&=20client?= =?UTF-8?q?s=20should=20not=20get=20latest=20versions=20on=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 25 ----- server/routers/site/listSites.ts | 124 ----------------------- 2 files changed, 149 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 82b52577e..f9af3af78 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -420,31 +420,6 @@ export async function listUserDevices( } ); - // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW - // // Try to get the latest version, but don't block if it fails - // try { - // const latestOlmVersion = await getLatestOlmVersion(); - - // if (latestOlmVersion) { - // olmsWithUpdates.forEach((client) => { - // try { - // client.olmUpdateAvailable = semver.lt( - // client.olmVersion ? client.olmVersion : "", - // latestOlmVersion - // ); - // } catch (error) { - // client.olmUpdateAvailable = false; - // } - // }); - // } - // } catch (error) { - // // Log the error but don't let it block the response - // logger.warn( - // "Failed to check for OLM updates, continuing without update info:", - // error - // ); - // } - return response(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c6abace5f..f72e3c19a 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -29,97 +29,6 @@ import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} - const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); @@ -446,9 +355,6 @@ export async function listSites( siteListQuery ]); - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -491,36 +397,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); From 1b6e9e8cfe80ce41a67f92ce86cc6a355facfb0f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 19:55:48 +0200 Subject: [PATCH 009/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`lt`=20instead=20o?= =?UTF-8?q?f=20`lte`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 87c2d0dec..8573e8199 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -195,7 +195,7 @@ export default function GeneralPage() { if (agent in latestPlatformVersions) { const agentVersion = latestPlatformVersions[agent]; - updateAvailable = semver.lte( + updateAvailable = semver.lt( client.olmVersion, agentVersion.latestVersion ); From 4b703b5c11b47042c7225fc78598aea30ab6392d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 20:58:23 +0200 Subject: [PATCH 010/264] =?UTF-8?q?=F0=9F=92=84=20Show=20the=20latest=20ne?= =?UTF-8?q?w=20update=20in=20machine=20client=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c9762..3094348e3 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } From b82b41ed26aa100002f21a67f456ab7022b8f3c5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 11 Jun 2026 15:02:29 -0700 Subject: [PATCH 011/264] fix migration --- .dockerignore | 3 ++- server/setup/scriptsSqlite/1.19.0.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4db9a0bb0..0a0e2e238 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,4 +34,5 @@ build.ts tsconfig.json Dockerfile* drizzle.config.ts -allowedDevOrigins.json \ No newline at end of file +allowedDevOrigins.json +scratch/ diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index 809340d94..c69356cae 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -228,7 +228,7 @@ export default async function migration() { ).run(); db.prepare( ` - UPDATE 'siteResources' SET 'destination2' = 'destination'; + UPDATE 'siteResources' SET "destination2" = "destination"; ` ).run(); db.prepare( @@ -349,9 +349,9 @@ export default async function migration() { db.prepare( ` UPDATE 'targets' - SET 'mode' = ( - SELECT 'mode' FROM 'resources' - WHERE 'resources'.'resourceId' = 'targets'.'resourceId' + SET "mode" = ( + SELECT "mode" FROM 'resources' + WHERE "resources"."resourceId" = "targets"."resourceId" ); ` ).run(); From b0fdc10e060da6d595e75b06f10599793db666f2 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Jun 2026 16:01:32 -0700 Subject: [PATCH 012/264] Properly hide things with disable enterprise flag --- .../public/[niceId]/general/page.tsx | 2 +- .../[orgId]/settings/sites/create/page.tsx | 11 +--- src/app/navigation.tsx | 9 ++-- src/components/newt-install-commands.tsx | 50 +++++++++++-------- 4 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index 70370235b..d3d9b817e 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -455,7 +455,7 @@ export default function GeneralForm() { )} { !["tcp", "udp"].includes( resource.mode - ) && ( + ) && !env.flags.disableEnterpriseFeatures && ( <> diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 5ea6496fd..e081f2149 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -23,7 +23,7 @@ import { } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { createElement, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; @@ -37,15 +37,6 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import { - FaApple, - FaCubes, - FaDocker, - FaFreebsd, - FaWindows -} from "react-icons/fa"; -import { SiNixos, SiKubernetes } from "react-icons/si"; -import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { generateKeypair } from "../[niceId]/wireguardConfig"; import { createApiClient, formatAxiosError } from "@app/lib/api"; diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 43323ba2f..853a2df98 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -156,10 +156,11 @@ export const orgNavSections = ( ] : []), // PaidFeaturesAlert - ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || - build === "saas" || - env?.app.identityProviderMode === "org" || - (env?.app.identityProviderMode === undefined && build !== "oss") + ...(!env?.flags.disableEnterpriseFeatures && + (build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && + build !== "oss")) ? [ { title: "sidebarIdentityProviders", diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index f2b3d0ca3..23dd072ed 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -20,6 +20,7 @@ import { } from "react-icons/fa"; import { ExternalLink } from "lucide-react"; import { SiKubernetes, SiNixos } from "react-icons/si"; +import { useEnvContext } from "@app/hooks/useEnvContext"; export type CommandItem = string | { title: string; command: string }; @@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({ version = "latest" }: NewtSiteInstallCommandsProps) { const t = useTranslations(); + const { env } = useEnvContext(); const [acceptClients, setAcceptClients] = useState(true); - const [allowPangolinSsh, setAllowPangolinSsh] = useState(true); + const [allowPangolinSsh, setAllowPangolinSsh] = useState( + !env.flags.disableEnterpriseFeatures + ); const [platform, setPlatform] = useState("linux"); const [architecture, setArchitecture] = useState( () => getArchitectures(platform)[0] @@ -306,27 +310,29 @@ WantedBy=default.target` > {t("siteAcceptClientConnectionsDescription")}

- {supportsSshOption && ( - <> -
- { - const value = checked as boolean; - setAllowPangolinSsh(value); - }} - label="Allow Pangolin SSH" - /> -
-

- {t("sitePangolinSshDescription")} -

- - )} + {supportsSshOption && + !env.flags.disableEnterpriseFeatures && ( + <> +
+ { + const value = + checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+

+ {t("sitePangolinSshDescription")} +

+ + )} )} From 820f66e58fba6d72c7ba36cc707cb6674dd8ef14 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Jun 2026 16:01:32 -0700 Subject: [PATCH 013/264] Properly hide things with disable enterprise flag --- src/app/[orgId]/settings/general/page.tsx | 68 ++++---- .../settings/sites/[niceId]/general/page.tsx | 150 +++++++++--------- src/components/newt-install-commands.tsx | 6 +- 3 files changed, 119 insertions(+), 105 deletions(-) diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index e20523250..375592fc5 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { ExternalLink } from "lucide-react"; +import { env } from "process"; // Schema for general organization settings const GeneralFormSchema = z.object({ @@ -165,6 +166,7 @@ function DeleteForm({ org }: SectionFormProps) { function GeneralSectionForm({ org }: SectionFormProps) { const { updateOrg } = useOrgContext(); + const { env } = useEnvContext(); const form = useForm({ resolver: zodResolver( GeneralFormSchema.pick({ @@ -265,36 +267,42 @@ function GeneralSectionForm({ org }: SectionFormProps) { - ( - - - - - - {t("newtAutoUpdateDescription")}{" "} - - {t("learnMore")} - - - - - - )} - /> + {!env.flags.disableEnterpriseFeatures && ( + ( + + + + + + {t("newtAutoUpdateDescription")}{" "} + + {t("learnMore")} + + + + + + )} + /> + )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 8d5a654c9..fdbfc387d 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -253,85 +253,87 @@ export default function GeneralPage() { - {site && site.type === "newt" && ( - { - const isOverriding = form.watch( - "autoUpdateOverrideOrg" - ); - return ( - - -
- { - field.onChange( + {site && + site.type === "newt" && + !env.flags.disableEnterpriseFeatures && ( + { + const isOverriding = form.watch( + "autoUpdateOverrideOrg" + ); + return ( + + +
+ - {isOverriding && ( - { + ) => { + field.onChange( + checked + ); form.setValue( "autoUpdateOverrideOrg", - false - ); - form.setValue( - "autoUpdateEnabled", - orgAutoUpdate + true ); }} - > - {t( - "siteAutoUpdateResetToOrg" - )} - - )} -
-
- - {t( - "siteAutoUpdateDescription" - )}{" "} - - {t("learnMore")} - - - - -
- ); - }} - /> - )} + disabled={ + !hasAutoUpdateFeature + } + /> + {isOverriding && ( + { + form.setValue( + "autoUpdateOverrideOrg", + false + ); + form.setValue( + "autoUpdateEnabled", + orgAutoUpdate + ); + }} + > + {t( + "siteAutoUpdateResetToOrg" + )} + + )} +
+
+ + {t( + "siteAutoUpdateDescription" + )}{" "} + + {t("learnMore")} + + + + +
+ ); + }} + /> + )} diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index 23dd072ed..badc174cc 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -75,7 +75,11 @@ export function NewtSiteInstallCommands({ : ""; const disableSshFlag = - supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : ""; + supportsSshOption && + !allowPangolinSsh && + !env.flags.disableEnterpriseFeatures + ? " --disable-ssh" + : ""; const runAsRootPrefix = supportsSshOption && allowPangolinSsh ? "sudo " : ""; From 3fcfd3304fd68fdca0f65d1aae1926b4234a90e5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 11 Jun 2026 18:34:22 -0700 Subject: [PATCH 014/264] fix address input width --- src/app/[orgId]/settings/sites/create/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index e081f2149..bf74b91d7 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -561,7 +561,7 @@ export default function Page() {
{showAdvancedSettings && ( - + Date: Thu, 11 Jun 2026 22:00:54 -0700 Subject: [PATCH 015/264] Adjust 1.19 and add 1.19.1 to ensure sso not null --- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsSqlite/1.19.0.ts | 42 +++++++++++--------- server/setup/scriptsSqlite/1.19.1.ts | 59 ++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 server/setup/scriptsSqlite/1.19.1.ts diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 03dae4c81..bc68ba3aa 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -44,6 +44,7 @@ import m38 from "./scriptsSqlite/1.18.0"; 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -85,7 +86,8 @@ const migrations = [ { version: "1.18.0", run: m38 }, { version: "1.18.3", run: m39 }, { version: "1.18.4", run: m40 }, - { version: "1.19.0", run: m41 } + { version: "1.19.0", run: m41 }, + { version: "1.19.1", run: m42 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index c69356cae..e27332482 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -680,25 +680,6 @@ export default async function migration() { deleteResourceRules.run(resource.resourceId); deleteResourceWhitelist.run(resource.resourceId); } - // remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies - db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run(); - db.prepare( - `ALTER TABLE 'resources' ADD COLUMN 'sso' integer;` - ).run(); - - db.prepare( - `ALTER TABLE 'resources' DROP COLUMN 'applyRules';` - ).run(); - db.prepare( - `ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;` - ).run(); - - db.prepare( - `ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';` - ).run(); - db.prepare( - `ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;` - ).run(); }); migrateInlinePolicies(); @@ -707,6 +688,29 @@ export default async function migration() { ); } + // add one more transaction + db.transaction(() => { + // remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies + db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'sso' integer;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'applyRules';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;` + ).run(); + })(); + console.log("Migrated database"); } catch (e) { console.log("Failed to migrate db:", e); diff --git a/server/setup/scriptsSqlite/1.19.1.ts b/server/setup/scriptsSqlite/1.19.1.ts new file mode 100644 index 000000000..3ac8efd3b --- /dev/null +++ b/server/setup/scriptsSqlite/1.19.1.ts @@ -0,0 +1,59 @@ +import { APP_PATH, __DIRNAME } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.19.1"; + +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.transaction(() => { + // remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'sso2' integer;` + ).run(); + db.prepare(`UPDATE 'resources' SET "sso2" = "sso";`).run(); + db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run(); + db.prepare( + `ALTER TABLE 'resources' RENAME COLUMN 'sso2' TO 'sso';` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'applyRules2' integer;` + ).run(); + db.prepare( + `UPDATE 'resources' SET "applyRules2" = "applyRules";` + ).run(); + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'applyRules';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' RENAME COLUMN 'applyRules2' TO 'applyRules';` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled2' integer;` + ).run(); + db.prepare( + `UPDATE 'resources' SET "emailWhitelistEnabled2" = "emailWhitelistEnabled";` + ).run(); + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' RENAME COLUMN 'emailWhitelistEnabled2' TO 'emailWhitelistEnabled';` + ).run(); + })(); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} From b136bd2246349da061067c3731ba8ff593f8c5c2 Mon Sep 17 00:00:00 2001 From: kshitijshresth Date: Fri, 12 Jun 2026 11:21:21 +0300 Subject: [PATCH 016/264] Escape regex metacharacters in PATH rule wildcard matching isValidUrlGlobPattern accepts characters like ( ) [ ] { } | . + ^ $ in PATH rule values, but isPathAllowed converted wildcard segments to regex without escaping them. A rule value such as /(api* produced an invalid regex and threw on every request to the resource, surfacing as a 500 from verifySession. Literal characters like . and + also changed matching semantics. isPathAllowed is extracted to server/lib/pathMatch.ts as a pure module, metacharacters are escaped before wildcard substitution, compiled segment regexes are cached, and the test suite now imports the real implementation instead of a stale copy, with added coverage for special characters. --- server/lib/pathMatch.ts | 74 ++++++++ server/routers/badger/verifySession.test.ts | 187 ++++++++++++-------- server/routers/badger/verifySession.ts | 139 +-------------- 3 files changed, 193 insertions(+), 207 deletions(-) create mode 100644 server/lib/pathMatch.ts diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts new file mode 100644 index 000000000..a007f9ec3 --- /dev/null +++ b/server/lib/pathMatch.ts @@ -0,0 +1,74 @@ +const MAX_RECURSION_DEPTH = 100; + +const segmentRegexCache = new Map(); + +function getSegmentRegex(patternPart: string): RegExp { + let regex = segmentRegexCache.get(patternPart); + if (!regex) { + const regexPattern = patternPart + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + regex = new RegExp(`^${regexPattern}$`); + segmentRegexCache.set(patternPart, regex); + } + return regex; +} + +export function isPathAllowed(pattern: string, path: string): boolean { + const normalize = (p: string) => p.split("/").filter(Boolean); + const patternParts = normalize(pattern); + const pathParts = normalize(path); + + function matchSegments( + patternIndex: number, + pathIndex: number, + depth: number = 0 + ): boolean { + if (depth > MAX_RECURSION_DEPTH) { + return false; + } + + const currentPatternPart = patternParts[patternIndex]; + const currentPathPart = pathParts[pathIndex]; + + if (patternIndex >= patternParts.length) { + return pathIndex >= pathParts.length; + } + + if (pathIndex >= pathParts.length) { + return patternParts.slice(patternIndex).every((p) => p === "*"); + } + + if (currentPatternPart === "*") { + if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { + return true; + } + if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { + return true; + } + return false; + } + + if (currentPatternPart.includes("*")) { + const regex = getSegmentRegex(currentPatternPart); + + if (regex.test(currentPathPart)) { + return matchSegments( + patternIndex + 1, + pathIndex + 1, + depth + 1 + ); + } + return false; + } + + if (currentPatternPart !== currentPathPart) { + return false; + } + + return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); + } + + return matchSegments(0, 0, 0); +} diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 8333a4578..681717f9c 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -1,5 +1,6 @@ import { assertEquals } from "@test/assert"; import { REGIONS } from "@server/db/regions"; +import { isPathAllowed } from "@server/lib/pathMatch"; function isIpInRegion( ipCountryCode: string | undefined, @@ -33,76 +34,6 @@ function isIpInRegion( return false; } -function isPathAllowed(pattern: string, path: string): boolean { - // Normalize and split paths into segments - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); - - // Recursive function to try different wildcard matches - function matchSegments(patternIndex: number, pathIndex: number): boolean { - const indent = " ".repeat(pathIndex); // Indent based on recursion depth - const currentPatternPart = patternParts[patternIndex]; - const currentPathPart = pathParts[pathIndex]; - - // If we've consumed all pattern parts, we should have consumed all path parts - if (patternIndex >= patternParts.length) { - const result = pathIndex >= pathParts.length; - return result; - } - - // If we've consumed all path parts but still have pattern parts - if (pathIndex >= pathParts.length) { - // The only way this can match is if all remaining pattern parts are wildcards - const remainingPattern = patternParts.slice(patternIndex); - const result = remainingPattern.every((p) => p === "*"); - return result; - } - - // For full segment wildcards, try consuming different numbers of path segments - if (currentPatternPart === "*") { - // Try consuming 0 segments (skip the wildcard) - if (matchSegments(patternIndex + 1, pathIndex)) { - return true; - } - - // Try consuming current segment and recursively try rest - if (matchSegments(patternIndex, pathIndex + 1)) { - return true; - } - - return false; - } - - // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix") - if (currentPatternPart.includes("*")) { - // Convert the pattern segment to a regex pattern - const regexPattern = currentPatternPart - .replace(/\*/g, ".*") // Replace * with .* for regex wildcard - .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed - - const regex = new RegExp(`^${regexPattern}$`); - - if (regex.test(currentPathPart)) { - return matchSegments(patternIndex + 1, pathIndex + 1); - } - - return false; - } - - // For regular segments, they must match exactly - if (currentPatternPart !== currentPathPart) { - return false; - } - - // Move to next segments in both pattern and path - return matchSegments(patternIndex + 1, pathIndex + 1); - } - - const result = matchSegments(0, 0); - return result; -} - function runTests() { console.log("Running path matching tests..."); @@ -308,6 +239,121 @@ function runTests() { console.log("All path matching tests passed!"); } +function runSpecialCharacterTests() { + console.log("\nRunning special character tests..."); + + let threw = false; + try { + isPathAllowed("(api*", "anything"); + isPathAllowed("a(b*", "a(bc"); + isPathAllowed("c[d*", "c[de"); + isPathAllowed("x{2}*", "x{2}y"); + isPathAllowed("a|b*", "a|bc"); + isPathAllowed("back\\slash*", "back\\slashed"); + } catch (e) { + threw = true; + console.error( + "Patterns accepted by isValidUrlGlobPattern crashed the matcher:", + e instanceof Error ? e.message : e + ); + } + assertEquals( + threw, + false, + "Patterns with regex metacharacters must not throw" + ); + + assertEquals( + isPathAllowed("(api*", "(api-v1"), + true, + "Parenthesis should be treated as a literal character" + ); + assertEquals( + isPathAllowed("(api*", "xapi-v1"), + false, + "Parenthesis should not match other characters" + ); + assertEquals( + isPathAllowed("a(b)*", "a(b)c"), + true, + "Parentheses pair should be treated as literal characters" + ); + + assertEquals( + isPathAllowed("*.png", "image.png"), + true, + "Dot should match a literal dot" + ); + assertEquals( + isPathAllowed("*.png", "imageXpng"), + false, + "Dot should not act as a regex wildcard" + ); + assertEquals( + isPathAllowed("v1.0*", "v1.0.1"), + true, + "Version-like literal should match itself" + ); + assertEquals( + isPathAllowed("v1.0*", "v1x0-beta"), + false, + "Version-like literal should not match arbitrary characters" + ); + + assertEquals( + isPathAllowed("a+b*", "a+bc"), + true, + "Plus should be treated as a literal character" + ); + assertEquals( + isPathAllowed("a+b*", "aaabc"), + false, + "Plus should not act as a regex quantifier" + ); + + assertEquals( + isPathAllowed("$ref*", "$refs"), + true, + "Dollar sign should be treated as a literal character" + ); + assertEquals( + isPathAllowed("price$*", "price$100"), + true, + "Dollar sign mid-pattern should be treated as a literal character" + ); + + assertEquals( + isPathAllowed("^start*", "^started"), + true, + "Caret should be treated as a literal character" + ); + + assertEquals( + isPathAllowed("a|b*", "a|bc"), + true, + "Pipe should be treated as a literal character" + ); + assertEquals( + isPathAllowed("a|b*", "a"), + false, + "Pipe should not act as regex alternation" + ); + + assertEquals( + isPathAllowed("file?*", "fileX"), + true, + "Question mark should still act as a single-character wildcard" + ); + + assertEquals( + isPathAllowed("api/*", "api/" + "x/".repeat(50)), + true, + "Deeply nested paths should still match" + ); + + console.log("All special character tests passed!"); +} + function runRegionTests() { console.log("\nRunning isIpInRegion tests..."); @@ -367,6 +413,7 @@ function runRegionTests() { // Run all tests try { runTests(); + runSpecialCharacterTests(); runRegionTests(); console.log("\n✅ All tests passed!"); } catch (error) { diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 677fa281d..d3076ec4d 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -25,6 +25,7 @@ import { } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; +import { isPathAllowed } from "@server/lib/pathMatch"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -1090,143 +1091,7 @@ async function checkRules( return; } -export function isPathAllowed(pattern: string, path: string): boolean { - logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); - - // Normalize and split paths into segments - const normalize = (p: string) => p.split("/").filter(Boolean); - const patternParts = normalize(pattern); - const pathParts = normalize(path); - - logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); - logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); - - // Maximum recursion depth to prevent stack overflow and memory issues - const MAX_RECURSION_DEPTH = 100; - - // Recursive function to try different wildcard matches - function matchSegments( - patternIndex: number, - pathIndex: number, - depth: number = 0 - ): boolean { - // Check recursion depth limit - if (depth > MAX_RECURSION_DEPTH) { - logger.warn( - `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"` - ); - return false; - } - - const indent = " ".repeat(depth); // Indent based on recursion depth - const currentPatternPart = patternParts[patternIndex]; - const currentPathPart = pathParts[pathIndex]; - - logger.debug( - `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]` - ); - - // If we've consumed all pattern parts, we should have consumed all path parts - if (patternIndex >= patternParts.length) { - const result = pathIndex >= pathParts.length; - logger.debug( - `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}` - ); - return result; - } - - // If we've consumed all path parts but still have pattern parts - if (pathIndex >= pathParts.length) { - // The only way this can match is if all remaining pattern parts are wildcards - const remainingPattern = patternParts.slice(patternIndex); - const result = remainingPattern.every((p) => p === "*"); - logger.debug( - `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}` - ); - return result; - } - - // For full segment wildcards, try consuming different numbers of path segments - if (currentPatternPart === "*") { - logger.debug( - `${indent}Found wildcard at pattern index ${patternIndex}` - ); - - // Try consuming 0 segments (skip the wildcard) - logger.debug( - `${indent}Trying to skip wildcard (consume 0 segments)` - ); - if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { - logger.debug( - `${indent}Successfully matched by skipping wildcard` - ); - return true; - } - - // Try consuming current segment and recursively try rest - logger.debug( - `${indent}Trying to consume segment "${currentPathPart}" for wildcard` - ); - if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { - logger.debug( - `${indent}Successfully matched by consuming segment for wildcard` - ); - return true; - } - - logger.debug(`${indent}Failed to match wildcard`); - return false; - } - - // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix") - if (currentPatternPart.includes("*")) { - logger.debug( - `${indent}Found in-segment wildcard in "${currentPatternPart}"` - ); - - // Convert the pattern segment to a regex pattern - const regexPattern = currentPatternPart - .replace(/\*/g, ".*") // Replace * with .* for regex wildcard - .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed - - const regex = new RegExp(`^${regexPattern}$`); - - if (regex.test(currentPathPart)) { - logger.debug( - `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"` - ); - return matchSegments( - patternIndex + 1, - pathIndex + 1, - depth + 1 - ); - } - - logger.debug( - `${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"` - ); - return false; - } - - // For regular segments, they must match exactly - if (currentPatternPart !== currentPathPart) { - logger.debug( - `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"` - ); - return false; - } - - logger.debug( - `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` - ); - // Move to next segments in both pattern and path - return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); - } - - const result = matchSegments(0, 0, 0); - logger.debug(`Final result: ${result}`); - return result; -} +export { isPathAllowed }; async function isIpInGeoIP( ipCountryCode: string | undefined, From a8ca28acb2c29240b6e9758999bac5d190f47017 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 12 Jun 2026 11:29:16 -0700 Subject: [PATCH 017/264] Add default for target type Fixes #3247 --- server/setup/scriptsSqlite/1.19.0.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index e27332482..85fa5063f 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -349,10 +349,10 @@ export default async function migration() { db.prepare( ` UPDATE 'targets' - SET "mode" = ( + SET "mode" = COALESCE(( SELECT "mode" FROM 'resources' WHERE "resources"."resourceId" = "targets"."resourceId" - ); + ), 'http'); ` ).run(); db.prepare( From afaf5f976e64951dbd7b724b251471bbc9611a3b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:38 -0700 Subject: [PATCH 018/264] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 230 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 187 insertions(+), 43 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 98ebf6e7c..c2abdbe02 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Voir les ressources privées", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Faites fonctionner Newt sur votre système", + "siteInstallKubernetesDocsDescription": "Pour plus d'informations à jour sur l'installation de Kubernetes, consultez docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Pour les instructions d'installation du modem Advantech, voir docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Configuration WireGuard", "WgConfigurationDescription": "Utilisez la configuration suivante pour vous connecter au réseau", "operatingSystem": "Système d'exploitation", @@ -156,8 +158,8 @@ "shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien", "shareDeleted": "Lien supprimé", "shareDeletedDescription": "Le lien a été supprimé", - "shareDelete": "Supprimer le lien de partage", - "shareDeleteConfirm": "Confirmer la suppression du lien de partage", + "shareDelete": "Supprimer le lien partageable", + "shareDeleteConfirm": "Confirmer la suppression du lien partageable", "shareQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?", "shareMessageRemove": "Une fois supprimé, le lien ne fonctionnera plus et toute personne l'utilisant perdra l'accès à la ressource.", "shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.", @@ -177,6 +179,7 @@ "shareCreateDescription": "N'importe qui avec ce lien peut accéder à la ressource", "shareTitleOptional": "Titre (facultatif)", "sharePathOptional": "Chemin (optionnel)", + "sharePathDescription": "Le lien redirigera les utilisateurs vers ce chemin après l'authentification.", "expireIn": "Expire dans", "neverExpire": "N'expire jamais", "shareExpireDescription": "Le délai d'expiration correspond à la période pendant laquelle le lien sera utilisable et permettra d'accéder à la ressource. Passé ce délai, le lien ne fonctionnera plus et les utilisateurs qui l'ont utilisé perdront l'accès à la ressource.", @@ -201,7 +204,7 @@ "proxyResourceTitle": "Gérer les ressources publiques", "proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web", "publicResourcesBannerTitle": "Accès public basé sur le Web", - "publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.", + "publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS accessibles à quiconque sur Internet via un navigateur Web. Contrairement aux ressources privées, elles ne nécessitent pas de logiciel côté client et peuvent inclure des politiques d'accès fondées sur l'identité et le contexte.", "clientResourceTitle": "Gérer les ressources privées", "clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté", "privateResourcesBannerTitle": "Accès privé sans confiance", @@ -209,15 +212,19 @@ "resourcesSearch": "Chercher des ressources...", "resourceAdd": "Ajouter une ressource", "resourceErrorDelte": "Erreur lors de la de suppression de la ressource", - "resourcePoliciesTitle": "Gérer les politiques de ressource", - "resourcePoliciesAttachedResourcesColumnTitle": "Ressources attachées", + "resourcePoliciesBannerTitle": "Réutiliser les règles d'authentification et d'accès", + "resourcePoliciesBannerDescription": "Les politiques de ressources partagées vous permettent de définir des méthodes d'authentification et des règles d'accès une fois, puis de les attacher à plusieurs ressources publiques. Lorsque vous mettez à jour une politique, chaque ressource liée hérite automatiquement des changements.", + "resourcePoliciesBannerButtonText": "En Savoir Plus", + "resourcePoliciesTitle": "Gérer les politiques de ressources publiques", + "resourcePoliciesAttachedResourcesColumnTitle": "Ressources", "resourcePoliciesAttachedResources": "{count} ressource(s)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ressource} other {# ressources}}", "resourcePoliciesAttachedResourcesEmpty": "pas de ressources", - "resourcePoliciesDescription": "Créer et gérer des politiques d'authentification pour contrôler l'accès à vos ressources", + "resourcePoliciesDescription": "Créez et gérer les politiques d'authentification pour contrôler l'accès à vos ressources publiques", "resourcePoliciesSearch": "Chercher des politiques...", "resourcePoliciesAdd": "Ajouter une politique", "resourcePoliciesDefaultBadgeText": "Politique par défaut", - "resourcePoliciesCreate": "Créer une politique de ressource", + "resourcePoliciesCreate": "Créer une politique de ressource publique", "resourcePoliciesCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle politique", "resourcePolicyName": "Nom de la politique", "resourcePolicyNameDescription": "Donnez à cette politique un nom pour l'identifier parmi vos ressources", @@ -274,7 +281,7 @@ "back": "Précédent", "cancel": "Abandonner", "resourceConfig": "Snippets de configuration", - "resourceConfigDescription": "Copiez et collez ces extraits de configuration pour configurer la ressource TCP/UDP", + "resourceConfigDescription": "Copiez et collez ces extraits de configuration pour configurer la ressource TCP/UDP.", "resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée", "resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose", "resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Supprimer Étiquette", "labelAdd": "Ajouter Étiquette", "labelCreateSuccessMessage": "Étiquette créée avec succès", + "labelDuplicateError": "Étiquette en double", + "labelDuplicateErrorDescription": "Une étiquette avec ce nom existe déjà.", "labelEditSuccessMessage": "Étiquette modifiée avec succès", "labelNameField": "Nom de l'étiquette", "labelColorField": "Couleur de l'étiquette", @@ -311,7 +320,7 @@ "rules": "Règles", "resourceSettingDescription": "Configurer les paramètres de la ressource", "resourceSetting": "Réglages de {resourceName}", - "resourcePolicySettingDescription": "Configurer les paramètres de la politique de ressource", + "resourcePolicySettingDescription": "Configurez les paramètres de cette politique de ressource publique", "resourcePolicySetting": "Paramètres de {policyName}", "alwaysAllow": "Outrepasser l'authentification", "alwaysDeny": "Bloquer l'accès", @@ -719,7 +728,7 @@ "targetSubmit": "Ajouter une cible", "targetNoOne": "Cette ressource n'a aucune cible. Ajoutez une cible pour configurer où envoyer des requêtes à l'arrière-plan.", "targetNoOneDescription": "L'ajout de plus d'une cible ci-dessus activera l'équilibrage de charge.", - "targetsSubmit": "Enregistrer les cibles", + "targetsSubmit": "Enregistrer les paramètres", "addTarget": "Ajouter une cible", "proxyMultiSiteRoundRobinNodeHelp": "Le routage en tourniquet n'opérera pas entre des sites qui ne sont pas connectés au même nœud, mais le basculement fonctionnera.", "targetErrorInvalidIp": "Adresse IP invalide", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Règle en double", "rulesErrorDuplicateDescription": "Une règle avec ces paramètres existe déjà", "rulesErrorInvalidIpAddressRange": "CIDR invalide", - "rulesErrorInvalidIpAddressRangeDescription": "Veuillez entrer une valeur CIDR valide", - "rulesErrorInvalidUrl": "Chemin URL invalide", - "rulesErrorInvalidUrlDescription": "Veuillez entrer un chemin URL valide", - "rulesErrorInvalidIpAddress": "IP invalide", - "rulesErrorInvalidIpAddressDescription": "Veuillez entrer une adresse IP valide", + "rulesErrorInvalidIpAddressRangeDescription": "Entrez une plage CIDR valide (par ex., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Chemin non valide", + "rulesErrorInvalidUrlDescription": "Entrez un chemin URL valide ou un modèle (par exemple, /api/*).", + "rulesErrorInvalidIpAddress": "Adresse IP invalide", + "rulesErrorInvalidIpAddressDescription": "Entrez une adresse IPv4 ou IPv6 valide.", "rulesErrorUpdate": "Échec de la mise à jour des règles", "rulesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des règles", "rulesUpdated": "Activer les règles", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Entrez une adresse IP (ex: 103.21.244.12)", "rulesMatchUrl": "Entrez un chemin URL ou un motif (ex: /api/v1/todos ou /api/v1/*)", "rulesErrorInvalidPriority": "Priorité invalide", - "rulesErrorInvalidPriorityDescription": "Veuillez entrer une priorité valide", + "rulesErrorInvalidPriorityDescription": "Entrez un nombre entier de 1 ou plus.", "rulesErrorDuplicatePriority": "Priorités en double", - "rulesErrorDuplicatePriorityDescription": "Veuillez entrer des priorités uniques", + "rulesErrorDuplicatePriorityDescription": "Chaque règle doit avoir un numéro de priorité unique.", + "rulesErrorValidation": "Règles invalides", + "rulesErrorValidationRuleDescription": "Règle {ruleNumber} : {message}", + "rulesErrorInvalidMatchTypeDescription": "Sélectionnez un type de correspondance valide (chemin, IP, CIDR, pays, région ou ASN).", + "rulesErrorValueRequired": "Entrez une valeur pour cette règle.", + "rulesErrorInvalidCountry": "Pays invalide", + "rulesErrorInvalidCountryDescription": "Sélectionnez un pays valide.", + "rulesErrorInvalidAsn": "ASN invalide", + "rulesErrorInvalidAsnDescription": "Entrez un ASN valide (par exemple, AS15169).", "ruleUpdated": "Règles mises à jour", "ruleUpdatedDescription": "Règles mises à jour avec succès", "ruleErrorUpdate": "L'opération a échoué", "ruleErrorUpdateDescription": "Une erreur s'est produite lors de l'enregistrement", "rulesPriority": "Priorité", + "rulesReorderDragHandle": "Faites glisser pour réorganiser la priorité des règles", "rulesAction": "Action", "rulesMatchType": "Type de correspondance", "value": "Valeur", @@ -792,7 +810,7 @@ "rulesResource": "Configuration des règles de ressource", "rulesResourceDescription": "Configurer les règles pour contrôler l'accès à la ressource", "ruleSubmit": "Ajouter une règle", - "rulesNoOne": "Aucune règle. Ajoutez une règle en utilisant le formulaire.", + "rulesNoOne": "Aucune règle pour le moment.", "rulesOrder": "Les règles sont évaluées par priorité dans l'ordre croissant.", "rulesSubmit": "Enregistrer les règles", "policyErrorCreate": "Erreur lors de la création de la politique", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Une erreur inattendue s'est produite", "policyCreatedSuccess": "Politique de ressource créée avec succès", "policyUpdatedSuccess": "Politique de ressource mise à jour avec succès", - "authMethodsSave": "Enregistrer les méthodes d'authentification", + "authMethodsSave": "Enregistrer les paramètres", + "policyAuthStackTitle": "Authentification", + "policyAuthStackDescription": "Contrôlez quelles méthodes d'authentification sont nécessaires pour accéder à cette ressource", + "policyAuthOrLogicTitle": "Plusieurs méthodes d'authentification actives", + "policyAuthOrLogicBanner": "Les visiteurs peuvent s'authentifier en utilisant l'une des méthodes actives ci-dessous. Ils n'ont pas besoin de toutes les compléter.", + "policyAuthMethodActive": "Actif", + "policyAuthMethodOff": "Éteint", + "policyAuthSsoTitle": "SSO de la plateforme", + "policyAuthSsoDescription": "Exigez une connexion via le fournisseur d'identité de votre organisation", + "policyAuthSsoSummary": "{idp} · {users} utilisateurs, {roles} rôles", + "policyAuthSsoDefaultIdp": "Fournisseur par défaut", + "policyAuthAddDefaultIdentityProvider": "Ajouter un fournisseur d'identité par défaut", + "policyAuthOtherMethodsTitle": "Autres méthodes", + "policyAuthOtherMethodsDescription": "Des méthodes facultatives que les visiteurs peuvent utiliser à la place de ou en parallèle avec la SSO de la plateforme", + "policyAuthPasscodeTitle": "Code confidentiel", + "policyAuthPasscodeDescription": "Exiger un code confidentiel alphanumérique partagé pour accéder à la ressource", + "policyAuthPasscodeSummary": "Code confidentiel établi", + "policyAuthPincodeTitle": "Code PIN", + "policyAuthPincodeDescription": "Un code numérique court requis pour accéder à la ressource", + "policyAuthPincodeSummary": "Code PIN à 6 chiffres établi", + "policyAuthEmailTitle": "Liste blanche des e-mails", + "policyAuthEmailDescription": "Autorisez les adresses e-mail listées avec des mots de passe à usage unique", + "policyAuthEmailSummary": "{count} adresses autorisées", + "policyAuthEmailOtpCallout": "Activer la liste blanche des e-mails envoie un mot de passe à usage unique à l'e-mail du visiteur lors de la connexion.", + "policyAuthHeaderAuthTitle": "Authentification de l'en-tête de base", + "policyAuthHeaderAuthDescription": "Validez un nom et une valeur d'en-tête HTTP personnalisé à chaque requête", + "policyAuthHeaderAuthSummary": "En-tête configuré", + "policyAuthHeaderName": "Nom de l'en-tête", + "policyAuthHeaderValue": "Valeur attendue", + "policyAuthSetPasscode": "Définir le code confidentiel", + "policyAuthSetPincode": "Définir le code PIN", + "policyAuthSetEmailWhitelist": "Définir la liste blanche des e-mails", + "policyAuthSetHeaderAuth": "Configurer l'authentification des en-têtes de base", + "policyAccessRulesTitle": "Règles d'accès", + "policyAccessRulesEnableDescription": "Lorsqu'elles sont activées, les règles sont évaluées dans l'ordre décroissant jusqu'à ce que l'une soit évaluée comme vraie.", + "policyAccessRulesFirstMatch": "Les règles sont évaluées de haut en bas. La première règle correspondante décide du résultat.", + "policyAccessRulesHowItWorks": "Les règles correspondent aux demandes par chemin, adresse IP, emplacement, ou d'autres critères. Chaque règle applique une action : contourner l'authentification, bloquer l'accès, ou passer à l'authentification. Si aucune règle ne correspond, le trafic continue jusqu'à l'authentification.", + "policyAccessRulesFallthroughOff": "Lorsque les règles sont désactivées, tout le trafic passe par l'authentification.", + "policyAccessRulesFallthroughOn": "Lorsqu'aucune règle ne correspond, le trafic passe par l'authentification.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Enregistrer les règles", "resourceErrorCreate": "Erreur lors de la création de la ressource", "resourceErrorCreateDescription": "Une erreur s'est produite lors de la création de la ressource", @@ -826,7 +885,7 @@ "accessControl": "Contrôle d'accès", "shareLink": "Lien de partage {resource}", "resourceSelect": "Sélectionner une ressource", - "shareLinks": "Liens de partage", + "shareLinks": "Liens partageables", "share": "Liens partageables", "shareDescription2": "Créez des liens partageables vers des ressources. Les liens fournissent un accès temporaire ou illimité à votre ressource. Vous pouvez configurer la durée d'expiration du lien lorsque vous en créez un.", "shareEasyCreate": "Facile à créer et à partager", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Les administrateurs peuvent toujours accéder à cette ressource.", "resourcePolicySelectTitle": "Politique d'accès à la ressource", "resourcePolicySelectDescription": "Sélectionner le type de politique de ressource pour l'authentification", + "resourcePolicyTypeLabel": "Type de politique", + "resourcePolicyLabel": "Politique de ressource", "resourcePolicyInline": "Politique de ressource en ligne", "resourcePolicyInlineDescription": "Politique d'accès limitée uniquement à cette ressource", "resourcePolicyShared": "Politique de ressource partagée", - "resourcePolicySharedDescription": "Cette ressource utilise une politique partagée. Les paramètres de niveau politique (méthodes d'authentification, liste blanche email) sont verrouillés. Vous pouvez ajouter des règles spécifiques à la ressource, rôles et utilisateurs ci-dessous.", + "resourcePolicySharedDescription": "Cette ressource utilise une politique partagée.", + "sharedPolicy": "Politique partagée", + "sharedPolicyNoneDescription": "Cette ressource a sa propre politique.", + "resourceSharedPolicyOwnDescription": "Cette ressource a ses propres contrôles de règles d'authentification et d'accès.", + "resourceSharedPolicyInheritedDescription": "Cette ressource hérite de {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Cette ressource utilise une politique partagée. Certains paramètres d'authentification peuvent être modifiés sur cette ressource pour ajouter à la politique. Pour changer la politique sous-jacente, vous devez éditer à {policyName}.", + "resourceSharedPolicyRulesNotice": "Cette ressource utilise une politique partagée. Certaines règles d'accès peuvent être modifiées sur cette ressource. Pour changer la politique sous-jacente, vous devez éditer {policyName}.", "resourceUsersRoles": "Contrôles d'accès", "resourceUsersRolesDescription": "Configurer quels utilisateurs et rôles peuvent visiter cette ressource", "resourceUsersRolesSubmit": "Enregistrer les contrôles d'accès", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Visibilité", "resourceVisibilityTitleDescription": "Activer ou désactiver complètement la visibilité de la ressource", "resourceGeneral": "Paramètres généraux", - "resourceGeneralDescription": "Configurer les paramètres généraux de cette ressource", + "resourceGeneralDescription": "Configurer le nom, l'adresse et la politique d'accès pour cette ressource.", + "resourceGeneralDetailsSubsection": "Détails de la ressource", + "resourceGeneralDetailsSubsectionDescription": "Définir le nom d'affichage, l'identifiant et le domaine accessible publiquement pour cette ressource.", + "resourceGeneralDetailsSubsectionPortDescription": "Définir le nom d'affichage, l'identifiant et le port public pour cette ressource.", + "resourceGeneralPublicAddressSubsection": "Adresse publique", + "resourceGeneralPublicAddressSubsectionDescription": "Configurez comment les utilisateurs accèdent à cette ressource.", + "resourceGeneralAuthenticationAccessSubsection": "Authentification & Accès", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Choisissez si cette ressource utilise sa propre politique ou hérite d'une politique partagée.", "resourceEnable": "Activer la ressource", "resourceTransfer": "Transférer la ressource", "resourceTransferDescription": "Transférer cette ressource vers un autre site", @@ -1220,11 +1294,14 @@ "addLabels": "Ajouter des étiquettes", "siteLabelsTab": "Étiquettes", "siteLabelsDescription": "Gérer les étiquettes associées à ce site.", - "labelsNotFound": "Étiquettes introuvables", + "labelsNotFound": "Aucune étiquette trouvée.", + "labelsEmptyCreateHint": "Commencez à taper ci-dessus pour créer une étiquette.", "labelSearch": "Chercher des étiquettes", + "labelSearchOrCreate": "Recherchez ou créez une étiquette", "accessLabelFilterCount": "{count, plural, one {# étiquette} other {# étiquettes}}", "labelOverflowCount": "+{count, plural, one {# étiquette} other {# étiquettes}}", "accessLabelFilterClear": "Effacer les filtres d'étiquette", + "accessFilterClear": "Effacer les filtres", "selectColor": "Sélectionner la couleur", "createNewLabel": "Créer une nouvelle étiquette d'organisation \"{label}\"", "inviteInvalidDescription": "Le lien d'invitation n'est pas valide.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Ressource", "sidebarProxyResources": "Publique", "sidebarClientResources": "Privé", - "sidebarPolicies": "Politiques", - "sidebarResourcePolicies": "Ressources", + "sidebarPolicies": "Politiques partagées", + "sidebarResourcePolicies": "Ressources publiques", "sidebarAccessControl": "Contrôle d'accès", "sidebarLogsAndAnalytics": "Journaux & Analytiques", "sidebarTeam": "Equipe", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Administrateur", "sidebarInvitations": "Invitations", "sidebarRoles": "Rôles", - "sidebarShareableLinks": "Liens", + "sidebarShareableLinks": "Liens partageables", "sidebarApiKeys": "Clés API", "sidebarProvisioning": "Mise en place", "sidebarSettings": "Réglages", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Configs", "blueprintsLog": "Journal des plans", - "blueprintsDescription": "Voir les applications passées des plans et leurs résultats", + "blueprintsDescription": "Consultez les applications et leurs résultats de planches à dessin passées ou appliquez une nouvelle planche à dessin", "blueprintAdd": "Ajouter une Config", "blueprintGoBack": "Voir toutes les Configs", "blueprintCreate": "Créer une Config", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Activer la Config Docker", "enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin du socket doit être fourni au connecteur du site. Lisez plus à ce sujet dans la documentation.", "newtAutoUpdate": "Activer la mise à jour automatique du site", - "newtAutoUpdateDescription": "Lorsqu'il est activé, les connecteurs de site se mettront automatiquement à jour vers la dernière version lorsqu'une nouvelle version sera disponible.", + "newtAutoUpdateDescription": "Lorsqu'il est activé, les connecteurs de site téléchargeront automatiquement la dernière version et redémarreront eux-mêmes. Cela peut être contourné sur une base par site.", "siteAutoUpdate": "Mise à jour automatique du site", "siteAutoUpdateLabel": "Activer la mise à jour automatique", - "siteAutoUpdateDescription": "Contrôler si le connecteur de ce site télécharge automatiquement la dernière version.", + "siteAutoUpdateDescription": "Lorsqu'il est activé, le connecteur de ce site téléchargera automatiquement la dernière version et se redémarrera.", "siteAutoUpdateOrgDefault": "Valeur par défaut de l'organisation : {state}", "siteAutoUpdateOverriding": "Substitution des paramètres de l'organisation", "siteAutoUpdateResetToOrg": "Réinitialiser à la valeur par défaut de l'organisation", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Configuration du compte terminée! Bienvenue chez Pangolin !", "documentation": "Documentation", "saveAllSettings": "Enregistrer tous les paramètres", - "saveResourceTargets": "Enregistrer les cibles", - "saveResourceHttp": "Enregistrer les paramètres de proxy", - "saveProxyProtocol": "Enregistrer les paramètres du protocole proxy", + "saveResourceTargets": "Enregistrer les paramètres", + "saveResourceHttp": "Enregistrer les paramètres", + "saveProxyProtocol": "Enregistrer les paramètres", "settingsUpdated": "Paramètres mis à jour", "settingsUpdatedDescription": "Paramètres mis à jour avec succès", "settingsErrorUpdate": "Échec de la mise à jour des paramètres", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Inconnu", "healthCheck": "Vérification de l'état de santé", "configureHealthCheck": "Configurer la vérification de l'état de santé", - "configureHealthCheckDescription": "Configurer la surveillance de la santé pour {target}", + "configureHealthCheckDescription": "Configurez la surveillance de votre ressource pour vous assurer qu'elle est toujours disponible", "enableHealthChecks": "Activer les vérifications de santé", "healthCheckDisabledStateDescription": "Lorsqu'il est désactivé, le site ne procédera pas aux vérifications de santé et l'état sera considéré comme inconnu.", "enableHealthChecksDescription": "Surveiller la vie de cette cible. Vous pouvez surveiller un point de terminaison différent de la cible si nécessaire.", "healthScheme": "Méthode", "healthSelectScheme": "Sélectionnez la méthode", - "healthCheckPortInvalid": "Le port du bilan de santé doit être compris entre 1 et 65535", + "healthCheckPortInvalid": "Le port doit être compris entre 1 et 65535", "healthCheckPath": "Chemin d'accès", "healthHostname": "IP / Hôte", "healthPort": "Port", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Exiger les autorisations de l'appareil", "requireDeviceApprovalDescription": "Les utilisateurs ayant ce rôle ont besoin de nouveaux périphériques approuvés par un administrateur avant de pouvoir se connecter et accéder aux ressources.", "sshSettings": "Paramètres SSH", + "sshAccess": "Accès SSH", "rdpSettings": "Paramètres RDP", "vncSettings": "Paramètres VNC", "sshServer": "Serveur SSH", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Assurez-vous que votre hôte cible est correctement configuré pour exécuter le daemon auth avant de terminer cette configuration, ou l'approvisionnement échouera.", "sshDaemonPort": "Port du Démon", "sshServerDestination": "Destination du Serveur", - "sshServerDestinationDescription": "Configurer la destination et le port du serveur SSH", + "sshServerDestinationDescription": "Configurez la destination du serveur SSH", "destination": "Destination", + "destinationRequired": "La destination est requise.", + "domainRequired": "Le domaine est requis.", + "proxyPortRequired": "Le port est requis.", + "invalidPathConfiguration": "Configuration de chemin invalide.", + "invalidRewritePathConfiguration": "Configuration de réécriture de chemin invalide.", "bgTargetMultiSiteDisclaimer": "La sélection de plusieurs sites permet un routage résilient et une bascule pour une haute disponibilité.", "roleAllowSsh": "Autoriser SSH", "roleAllowSshAllow": "Autoriser", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.", "sshSudo": "Autoriser sudo", "sshSudoCommands": "Commandes Sudo", - "sshSudoCommandsDescription": "Liste de commandes séparées par des virgules que l'utilisateur est autorisé à exécuter avec sudo. Des chemins absolus doivent être utilisés.", + "sshSudoCommandsDescription": "Liste des commandes que l'utilisateur est autorisé à exécuter avec sudo, séparées par des virgules, des espaces ou des nouvelles lignes. Les chemins absolus doivent être utilisés.", "sshCreateHomeDir": "Créer un répertoire personnel", "sshUnixGroups": "Groupes Unix", - "sshUnixGroupsDescription": "Groupes Unix séparés par des virgules pour ajouter l'utilisateur sur l'hôte cible.", + "sshUnixGroupsDescription": "Groupes Unix auxquels ajouter l'utilisateur sur l'hôte cible, séparés par des virgules, des espaces, ou des nouvelles lignes.", + "roleTextFieldPlaceholder": "Entrez des valeurs, ou déposez un fichier .txt ou .csv", + "roleTextImportTitle": "Importer depuis un fichier", + "roleTextImportDescription": "Importation de {fileName} dans {fieldLabel}.", + "roleTextImportSkipHeader": "Ignorer la première ligne (en-tête)", + "roleTextImportOverride": "Remplacer l'existant", + "roleTextImportAppend": "Ajouter à l'existant", + "roleTextImportMode": "Mode d'importation", + "roleTextImportPreview": "Aperçu", + "roleTextImportItemCount": "{count, plural, =0 {Aucun élément à importer} one {1 élément à importer} other {# éléments à importer}}", + "roleTextImportTotalCount": "{existing} existant + {imported} importé = {total} total", + "roleTextImportConfirm": "Importer", + "roleTextImportInvalidFile": "Type de fichier non pris en charge", + "roleTextImportInvalidFileDescription": "Seuls les fichiers .txt et .csv sont pris en charge.", + "roleTextImportEmpty": "Aucun élément trouvé dans le fichier", + "roleTextImportEmptyDescription": "Le fichier ne contient aucun élément importable.", "retryAttempts": "Tentatives de réessai", "expectedResponseCodes": "Codes de réponse attendus", "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Activer le protocole Proxy", "proxyProtocolInfo": "Conserver les adresses IP du client pour les backends TCP", "proxyProtocolVersion": "Version du protocole proxy", - "version1": " Version 1 (Recommandé)", + "version1": "Version 1 (Recommandée)", "version2": "Version 2", - "versionDescription": "La version 1 est basée sur du texte et est largement supportée. La version 2 est binaire et plus efficace mais moins compatible.", + "version1Description": "Basé sur texte et largement pris en charge. Assurez-vous que le transport des serveurs est ajouté à la configuration dynamique.", + "version2Description": "Binaire et plus efficace mais moins compatible. Assurez-vous que le transport des serveurs est ajouté à la configuration dynamique.", "warning": "Avertissement", "proxyProtocolWarning": "L'application backend doit être configurée pour accepter les connexions Proxy Protocol. Si votre backend ne prend pas en charge le protocole Proxy, l'activation de cette option va perturber toutes les connexions, donc n'activez cette option que si vous savez ce que vous faites. Assurez-vous de configurer votre backend pour faire confiance aux en-têtes du protocole Proxy de Traefik.", "restarting": "Redémarrage...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Entrez la confirmation", "blueprintViewDetails": "Détails", "defaultIdentityProvider": "Fournisseur d'identité par défaut", - "defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.", + "defaultIdentityProviderDescription": "L'utilisateur sera automatiquement redirigé vers ce fournisseur d'identité pour l'authentification.", "editInternalResourceDialogNetworkSettings": "Paramètres réseau", "editInternalResourceDialogAccessPolicy": "Politique d'accès", "editInternalResourceDialogAddRoles": "Ajouter des rôles", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Type de mode de maintenance", "showMaintenancePage": "Afficher une page de maintenance aux visiteurs", "enableMaintenanceMode": "Activer le mode de maintenance", + "enableMaintenanceModeDescription": "Lorsqu'il est activé, les visiteurs verront une page de maintenance au lieu de votre ressource.", "automatic": "Automatique", "automaticModeDescription": "Afficher la page de maintenance uniquement lorsque toutes les cibles backend sont en panne ou dégradées. Votre ressource continue à fonctionner normalement tant qu'au moins une cible est en bonne santé.", "forced": "Forcé", @@ -3082,6 +3182,8 @@ "warning:": "Attention :", "forcedeModeWarning": "Tout le trafic sera dirigé vers la page de maintenance. Vos ressources backend ne recevront aucune demande.", "pageTitle": "Titre de la page", + "maintenancePageContentSubsection": "Contenu de la page", + "maintenancePageContentSubsectionDescription": "Personnalisez le contenu affiché sur la page de maintenance", "pageTitleDescription": "Le titre principal affiché sur la page de maintenance", "maintenancePageMessage": "Message de maintenance", "maintenancePageMessagePlaceholder": "Nous serons bientôt de retour ! Notre site est actuellement en maintenance planifiée.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Êtes-vous sûr de vouloir dissocier ce fournisseur d'identités de cette organisation?", "idpUnassociateDescription": "Tous les utilisateurs associés à ce fournisseur d'identités seront retirés de cette organisation, mais le fournisseur d'identités continuera d'exister pour d'autres organisations associées.", "idpUnassociateConfirm": "Confirmer la dissociation du fournisseur d'identités", + "idpConfirmDeleteAndRemoveMeFromOrg": "SUPPRIMER ET ME RETIRER DE L'ORG", + "idpUnassociateAndRemoveMeFromOrg": "DÉ-ASSOCIER ET ME RETIRER DE L'ORG", "idpUnassociateWarning": "Cela ne peut pas être annulé pour cette organisation.", "idpUnassociatedDescription": "Fournisseur d'identités dissocié de cette organisation avec succès", "idpUnassociateMenu": "Dissocier", @@ -3439,18 +3543,58 @@ "sshConnecting": "Connexion…", "sshInitializing": "Initialisation…", "sshSignInTitle": "Se connecter à SSH", - "sshSignInDescription": "Entrez vos identifiants SSH", + "sshSignInDescription": "Entrez vos identifiants SSH pour vous connecter", "sshPasswordTab": "Mot de passe", "sshPrivateKeyTab": "Clé Privée", "sshPrivateKeyField": "Clé Privée", "sshPrivateKeyDisclaimer": "Votre clé privée n'est pas stockée ou visible par Pangolin. Alternativement, vous pouvez utiliser des certificats de courte durée pour une authentification transparente utilisant votre identité Pangolin existante.", "sshLearnMore": "En savoir plus", "sshPrivateKeyFile": "Fichier de Clé Privée", - "sshAuthenticate": "Authentifier", + "sshAuthenticate": "Connecter", "sshTerminate": "Terminer", "sshPoweredBy": "Propulsé par", "sshErrorNoTarget": "Aucune cible spécifiée", "sshErrorWebSocket": "Échec de la connexion WebSocket", "sshErrorAuthFailed": "Échec de l'authentification", - "sshErrorConnectionClosed": "Connexion fermée avant que l'authentification soit terminée" + "sshErrorConnectionClosed": "Connexion fermée avant que l'authentification soit terminée", + "sitePangolinSshDescription": "Autoriser l'accès SSH aux ressources sur ce site. Cela peut être modifié plus tard.", + "browserGatewayNoResourceForDomain": "Aucune ressource trouvée pour ce domaine", + "browserGatewayNoTarget": "Aucune cible", + "browserGatewayConnect": "Connecter", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Suppr", + "sshErrorSignKeyFailed": "Échec de la signature de la clé SSH pour l'authentification Push PAM. Vous êtes-vous connecté en tant qu'utilisateur ?", + "sshTerminalError": "Erreur : {error}", + "sshConnectionClosedCode": "Connexion fermée (code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Une clé privée est requise", + "vncTitle": "VNC", + "vncSignInDescription": "Entrez votre mot de passe VNC pour vous connecter", + "vncPasswordOptional": "Mot de passe (facultatif)", + "vncNoResourceTarget": "Aucune cible de ressource disponible", + "vncFailedToLoadNovnc": "Échec du chargement de noVNC", + "vncAuthFailedStatus": "Statut {status}", + "vncPasteClipboard": "Coller le presse-papiers", + "rdpTitle": "RDP", + "rdpSignInTitle": "Se connecter au Bureau à distance", + "rdpSignInDescription": "Entrez vos identifiants Windows pour vous connecter", + "rdpLoadingModule": "Chargement du module...", + "rdpFailedToLoadModule": "Échec du chargement du module RDP", + "rdpNotReady": "Pas prêt", + "rdpModuleInitializing": "Le module RDP est encore en cours d'initialisation", + "rdpDownloadingFiles": "Téléchargement de {count} fichier(s) depuis le site distant…", + "rdpDownloadFailed": "Échec du téléchargement : {fileName}", + "rdpUploaded": "Téléchargé : {fileName}", + "rdpNoConnectionTarget": "Aucune cible de connexion disponible", + "rdpConnectionFailed": "Échec de la connexion", + "rdpFit": "Ajuster", + "rdpFull": "Plein", + "rdpReal": "Réel", + "rdpMeta": "Méta", + "rdpUploadFiles": "Télécharger des fichiers", + "rdpFilesReadyToPaste": "Fichiers prêts à coller", + "rdpFilesReadyToPasteDescription": "{count} fichier(s) copié(s) vers le presse-papier distant — appuyez sur Ctrl+V sur le bureau distant pour coller.", + "rdpUploadFailed": "Échec du téléchargement", + "rdpUnicodeKeyboardMode": "Mode clavier Unicode", + "sessionToolbarShow": "Afficher la barre d'outils", + "sessionToolbarHide": "Masquer la barre d'outils" } From fc982232d629a8c9acc467d3859eb0d8c3ebae1f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:40 -0700 Subject: [PATCH 019/264] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 242 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 49 deletions(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index a43ccbc4c..19a6ab6e1 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Ver Recursos Privados", "siteInstallNewt": "Instalar Newt", "siteInstallNewtDescription": "Recibe Newt corriendo en tu sistema", + "siteInstallKubernetesDocsDescription": "Para información de instalación de Kubernetes más reciente, consulta docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Para instrucciones de instalación del módem Advantech, consulta docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Configuración de Wirex Guard", "WgConfigurationDescription": "Utilice la siguiente configuración para conectarse a la red", "operatingSystem": "Sistema operativo", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", "siteInfo": "Información del sitio", "status": "Estado", - "shareTitle": "Administrar Enlaces de Compartir", + "shareTitle": "Gestionar Enlaces Compartibles", "shareDescription": "Crear enlaces compartidos para conceder acceso temporal o permanente a recursos proxy", - "shareSearch": "Buscar enlaces compartidos...", - "shareCreate": "Crear enlace Compartir", + "shareSearch": "Buscar enlaces compartibles...", + "shareCreate": "Crear Enlace Compartible", "shareErrorDelete": "Error al eliminar el enlace", "shareErrorDeleteMessage": "Se ha producido un error al eliminar el enlace", "shareDeleted": "Enlace eliminado", "shareDeletedDescription": "El enlace ha sido eliminado", - "shareDelete": "Borrar Enlace Compartido", - "shareDeleteConfirm": "Confirmar Borrado del Enlace Compartido", + "shareDelete": "Eliminar Enlace Compartible", + "shareDeleteConfirm": "Confirmar Eliminación de Enlace Compartible", "shareQuestionRemove": "¿Está seguro de que desea borrar este enlace compartido?", "shareMessageRemove": "Una vez borrado, el enlace dejará de funcionar y cualquier persona que lo use perderá acceso al recurso.", "shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Cualquiera con este enlace puede acceder al recurso", "shareTitleOptional": "Título (opcional)", "sharePathOptional": "Ruta (opcional)", + "sharePathDescription": "El enlace redirigirá a los usuarios a esta ruta tras la autenticación.", "expireIn": "Caduca en", "neverExpire": "Nunca expirar", "shareExpireDescription": "El tiempo de caducidad es cuánto tiempo el enlace será utilizable y proporcionará acceso al recurso. Después de este tiempo, el enlace ya no funcionará, y los usuarios que usaron este enlace perderán el acceso al recurso.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Por favor, seleccione un recurso", "proxyResourceTitle": "Administrar recursos públicos", "proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web", - "publicResourcesBannerTitle": "Acceso público basado en web", - "publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.", + "publicResourcesBannerTitle": "Acceso Público basado en Web", + "publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS accesibles para cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluyen políticas de acceso basadas en identidad y contexto.", "clientResourceTitle": "Administrar recursos privados", "clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado", "privateResourcesBannerTitle": "Acceso privado de confianza cero", @@ -209,15 +212,19 @@ "resourcesSearch": "Buscar recursos...", "resourceAdd": "Añadir Recurso", "resourceErrorDelte": "Error al eliminar el recurso", - "resourcePoliciesTitle": "Administrar Políticas de Recursos", - "resourcePoliciesAttachedResourcesColumnTitle": "Recursos Adjuntos", + "resourcePoliciesBannerTitle": "Reutilizar Reglas de Autenticación y Acceso", + "resourcePoliciesBannerDescription": "Las políticas de recursos compartidos te permiten definir métodos de autenticación y reglas de acceso una vez, y luego adjuntarlas a múltiples recursos públicos. Al actualizar una política, cada recurso vinculado hereda automáticamente el cambio.", + "resourcePoliciesBannerButtonText": "Saber más", + "resourcePoliciesTitle": "Gestionar Políticas de Recursos Públicos", + "resourcePoliciesAttachedResourcesColumnTitle": "Recursos", "resourcePoliciesAttachedResources": "{count} recurso/s", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# recurso} other {# recursos}}", "resourcePoliciesAttachedResourcesEmpty": "sin recursos", - "resourcePoliciesDescription": "Cree y administre políticas de autenticación para controlar el acceso a sus recursos", + "resourcePoliciesDescription": "Crear y gestionar políticas de autenticación para controlar el acceso a tus recursos públicos", "resourcePoliciesSearch": "Buscar políticas...", "resourcePoliciesAdd": "Agregar Política", "resourcePoliciesDefaultBadgeText": "Política predeterminada", - "resourcePoliciesCreate": "Crear Política de Recursos", + "resourcePoliciesCreate": "Crear Política de Recursos Públicos", "resourcePoliciesCreateDescription": "Siga los pasos a continuación para crear una nueva política", "resourcePolicyName": "Nombre de la política", "resourcePolicyNameDescription": "Déle a esta política un nombre para identificarla en sus recursos", @@ -274,7 +281,7 @@ "back": "Atrás", "cancel": "Cancelar", "resourceConfig": "Fragmentos de configuración", - "resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP", + "resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP.", "resourceAddEntrypoints": "Traefik: Añadir puntos de entrada", "resourceExposePorts": "Gerbil: Exponer puertos en Docker Compose", "resourceLearnRaw": "Aprende cómo configurar los recursos TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Eliminar etiqueta", "labelAdd": "Agregar etiqueta", "labelCreateSuccessMessage": "Etiqueta creada correctamente", + "labelDuplicateError": "Etiqueta Duplicada", + "labelDuplicateErrorDescription": "Una etiqueta con este nombre ya existe.", "labelEditSuccessMessage": "Etiqueta modificada correctamente", "labelNameField": "Nombre de la etiqueta", "labelColorField": "Color de la etiqueta", @@ -311,7 +320,7 @@ "rules": "Reglas", "resourceSettingDescription": "Configurar la configuración del recurso", "resourceSetting": "Ajustes {resourceName}", - "resourcePolicySettingDescription": "Configure la configuración en la política de recursos", + "resourcePolicySettingDescription": "Configura los ajustes de esta política de recursos públicos", "resourcePolicySetting": "Configuración {policyName}", "alwaysAllow": "Autorización Bypass", "alwaysDeny": "Bloquear acceso", @@ -719,7 +728,7 @@ "targetSubmit": "Añadir destino", "targetNoOne": "Este recurso no tiene ningún objetivo. Agrega un objetivo para configurar dónde enviar peticiones al backend.", "targetNoOneDescription": "Si se añade más de un objetivo anterior se activará el balance de carga.", - "targetsSubmit": "Guardar objetivos", + "targetsSubmit": "Guardar ajustes", "addTarget": "Añadir destino", "proxyMultiSiteRoundRobinNodeHelp": "El enrutamiento de turnos no funcionará entre sitios que no están conectados al mismo nodo, pero el failover funcionará.", "targetErrorInvalidIp": "Dirección IP inválida", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Duplicar regla", "rulesErrorDuplicateDescription": "Ya existe una regla con estos ajustes", "rulesErrorInvalidIpAddressRange": "CIDR inválido", - "rulesErrorInvalidIpAddressRangeDescription": "Por favor, introduzca un valor CIDR válido", - "rulesErrorInvalidUrl": "Ruta URL inválida", - "rulesErrorInvalidUrlDescription": "Por favor, introduzca un valor de ruta de URL válido", - "rulesErrorInvalidIpAddress": "IP inválida", - "rulesErrorInvalidIpAddressDescription": "Por favor, introduzca una dirección IP válida", + "rulesErrorInvalidIpAddressRangeDescription": "Introduce un rango CIDR válido (por ejemplo, 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Ruta no válida", + "rulesErrorInvalidUrlDescription": "Introduce una ruta URL o patrón válido (por ejemplo, /api/*).", + "rulesErrorInvalidIpAddress": "Dirección IP no válida", + "rulesErrorInvalidIpAddressDescription": "Introduce una dirección IPv4 o IPv6 válida.", "rulesErrorUpdate": "Error al actualizar las reglas", "rulesErrorUpdateDescription": "Se ha producido un error al actualizar las reglas", "rulesUpdated": "Activar Reglas", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "Introduzca una dirección en formato CIDR (por ejemplo, 103.21.244.0/22)", "rulesMatchIpAddress": "Introduzca una dirección IP (por ejemplo, 103.21.244.12)", "rulesMatchUrl": "Introduzca una ruta URL o patrón (por ej., /api/v1/todos o /api/v1/*)", - "rulesErrorInvalidPriority": "Prioridad inválida", - "rulesErrorInvalidPriorityDescription": "Por favor, introduzca una prioridad válida", + "rulesErrorInvalidPriority": "Prioridad no válida", + "rulesErrorInvalidPriorityDescription": "Introduce un número entero de 1 o mayor.", "rulesErrorDuplicatePriority": "Prioridades duplicadas", - "rulesErrorDuplicatePriorityDescription": "Por favor, introduzca prioridades únicas", + "rulesErrorDuplicatePriorityDescription": "Cada regla debe tener un número de prioridad único.", + "rulesErrorValidation": "Reglas no válidas", + "rulesErrorValidationRuleDescription": "Regla {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Selecciona un tipo de coincidencia válido (ruta, IP, CIDR, país, región o ASN).", + "rulesErrorValueRequired": "Introduce un valor para esta regla.", + "rulesErrorInvalidCountry": "País no válido", + "rulesErrorInvalidCountryDescription": "Selecciona un país válido.", + "rulesErrorInvalidAsn": "ASN no válido", + "rulesErrorInvalidAsnDescription": "Introduce un ASN válido (por ejemplo, AS15169).", "ruleUpdated": "Reglas actualizadas", "ruleUpdatedDescription": "Reglas actualizadas correctamente", "ruleErrorUpdate": "Operación fallida", "ruleErrorUpdateDescription": "Se ha producido un error durante la operación de guardado", "rulesPriority": "Prioridad", + "rulesReorderDragHandle": "Arrastra para reordenar la prioridad de reglas", "rulesAction": "Accin", "rulesMatchType": "Tipo de partida", "value": "Valor", @@ -792,7 +810,7 @@ "rulesResource": "Configuración de reglas de recursos", "rulesResourceDescription": "Configurar reglas para controlar el acceso al recurso", "ruleSubmit": "Añadir Regla", - "rulesNoOne": "No hay reglas. Agregue una regla usando el formulario.", + "rulesNoOne": "Aún no hay reglas.", "rulesOrder": "Las reglas son evaluadas por prioridad en orden ascendente.", "rulesSubmit": "Guardar Reglas", "policyErrorCreate": "Error al crear la política", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Se ha producido un error inesperado", "policyCreatedSuccess": "Política de recursos creada con éxito", "policyUpdatedSuccess": "Política de recursos actualizada con éxito", - "authMethodsSave": "Guardar métodos de autenticación", + "authMethodsSave": "Guardar ajustes", + "policyAuthStackTitle": "Autenticación", + "policyAuthStackDescription": "Controla qué métodos de autenticación son necesarios para acceder a este recurso", + "policyAuthOrLogicTitle": "Múltiples métodos de autenticación activos", + "policyAuthOrLogicBanner": "Los visitantes pueden autenticarse utilizando cualquier método activo a continuación. No necesitan completar todos ellos.", + "policyAuthMethodActive": "Activo", + "policyAuthMethodOff": "Apagado", + "policyAuthSsoTitle": "SSO de Plataforma", + "policyAuthSsoDescription": "Requiere iniciar sesión a través del proveedor de identidad de tu organización", + "policyAuthSsoSummary": "{idp} · {users} usuarios, {roles} roles", + "policyAuthSsoDefaultIdp": "Proveedor por defecto", + "policyAuthAddDefaultIdentityProvider": "Añadir Proveedor de Identidad Predeterminado", + "policyAuthOtherMethodsTitle": "Otros Métodos", + "policyAuthOtherMethodsDescription": "Métodos opcionales que los visitantes pueden utilizar en lugar de o junto con el SSO de plataforma", + "policyAuthPasscodeTitle": "Código de Acceso", + "policyAuthPasscodeDescription": "Requiere un código alfanumérico compartido para acceder al recurso", + "policyAuthPasscodeSummary": "Código de acceso establecido", + "policyAuthPincodeTitle": "Código PIN", + "policyAuthPincodeDescription": "Un código numérico corto necesario para acceder al recurso", + "policyAuthPincodeSummary": "Código PIN de 6 dígitos establecido", + "policyAuthEmailTitle": "Lista Blanca de Correo", + "policyAuthEmailDescription": "Permitir direcciones de correo listadas con contraseñas de un solo uso", + "policyAuthEmailSummary": "{count} direcciones permitidas", + "policyAuthEmailOtpCallout": "Habilitar la lista blanca de correos envía una contraseña de un solo uso al correo del visitante al iniciar sesión.", + "policyAuthHeaderAuthTitle": "Autenticación Básica del Encabezado", + "policyAuthHeaderAuthDescription": "Valida un nombre y valor de encabezado HTTP personalizado en cada petición", + "policyAuthHeaderAuthSummary": "Encabezado configurado", + "policyAuthHeaderName": "Nombre del encabezado", + "policyAuthHeaderValue": "Valor esperado", + "policyAuthSetPasscode": "Establecer Código de Acceso", + "policyAuthSetPincode": "Establecer Código PIN", + "policyAuthSetEmailWhitelist": "Establecer Lista Blanca de Correo", + "policyAuthSetHeaderAuth": "Establecer Autenticación Básica del Encabezado", + "policyAccessRulesTitle": "Reglas de Acceso", + "policyAccessRulesEnableDescription": "Cuando está habilitado, las reglas se evalúan en orden descendente hasta que una se evalúa como verdadera.", + "policyAccessRulesFirstMatch": "Las reglas se evalúan de arriba a abajo. La primera regla coincidente decide el resultado.", + "policyAccessRulesHowItWorks": "Las reglas coinciden con las solicitudes por ruta, dirección IP, ubicación u otros criterios. Cada regla aplica una acción: omitir autenticación, bloquear acceso o pasar a autenticación. Si ninguna regla coincide, el tráfico sigue a la autenticación.", + "policyAccessRulesFallthroughOff": "Cuando las reglas están deshabilitadas, todo el tráfico pasa a autenticación.", + "policyAccessRulesFallthroughOn": "Cuando no coincide ninguna regla, el tráfico pasa a autenticación.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Guardar reglas", "resourceErrorCreate": "Error al crear recurso", "resourceErrorCreateDescription": "Se ha producido un error al crear el recurso", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso", "access": "Acceder", "accessControl": "Control de acceso", - "shareLink": "{resource} Compartir Enlace", + "shareLink": "Enlace Compartible de {resource}", "resourceSelect": "Seleccionar recurso", - "shareLinks": "Compartir enlaces", + "shareLinks": "Enlaces Compartibles", "share": "Enlaces compartibles", "shareDescription2": "Crea enlaces compartidos a recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puede configurar la duración de caducidad del enlace cuando cree uno.", "shareEasyCreate": "Fácil de crear y compartir", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Los administradores siempre pueden acceder a este recurso.", "resourcePolicySelectTitle": "Política de Acceso a Recursos", "resourcePolicySelectDescription": "Seleccione el tipo de política de recursos para la autenticación", + "resourcePolicyTypeLabel": "Tipo de política", + "resourcePolicyLabel": "Política de recurso", "resourcePolicyInline": "Política de Recursos Integrada", "resourcePolicyInlineDescription": "Política de Acceso solo destinada a este recurso", "resourcePolicyShared": "Política de Recursos Compartida", - "resourcePolicySharedDescription": "Este recurso utiliza una política compartida. Las configuraciones a nivel de política (métodos de autenticación, lista blanca de correos electrónicos) están bloqueadas. Puede agregar reglas específicas de recursos, roles y usuarios más abajo.", + "resourcePolicySharedDescription": "Este recurso utiliza una política compartida.", + "sharedPolicy": "Política Compartida", + "sharedPolicyNoneDescription": "Este recurso tiene su propia política.", + "resourceSharedPolicyOwnDescription": "Este recurso tiene sus propios controles de autenticación y reglas de acceso.", + "resourceSharedPolicyInheritedDescription": "Este recurso hereda de {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Este recurso está usando una política compartida. Algunas configuraciones de autenticación se pueden editar en este recurso para añadirse a la política. Para cambiar la política subyacente, debes editar a {policyName}.", + "resourceSharedPolicyRulesNotice": "Este recurso está utilizando una política compartida. Algunas reglas de acceso se pueden editar en este recurso. Para cambiar la política subyacente, debes editar {policyName}.", "resourceUsersRoles": "Controles de acceso", "resourceUsersRolesDescription": "Configurar qué usuarios y roles pueden visitar este recurso", "resourceUsersRolesSubmit": "Guardar controles de acceso", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Visibilidad", "resourceVisibilityTitleDescription": "Activar o desactivar completamente la visibilidad de los recursos", "resourceGeneral": "Configuración General", - "resourceGeneralDescription": "Configurar la configuración general de este recurso", + "resourceGeneralDescription": "Configurar nombre, dirección y política de acceso para este recurso.", + "resourceGeneralDetailsSubsection": "Detalles del Recurso", + "resourceGeneralDetailsSubsectionDescription": "Establecer el nombre de visualización, identificador y dominio públicamente accesible para este recurso.", + "resourceGeneralDetailsSubsectionPortDescription": "Establecer el nombre de visualización, identificador y puerto público para este recurso.", + "resourceGeneralPublicAddressSubsection": "Dirección Pública", + "resourceGeneralPublicAddressSubsectionDescription": "Configura cómo los usuarios acceden a este recurso.", + "resourceGeneralAuthenticationAccessSubsection": "Autenticación y Acceso", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Elige si este recurso utiliza su propia política o hereda de una política compartida.", "resourceEnable": "Activar recurso", "resourceTransfer": "Transferir recursos", "resourceTransferDescription": "Transferir este recurso a un sitio diferente", @@ -1220,11 +1294,14 @@ "addLabels": "Agregar etiquetas", "siteLabelsTab": "Etiquetas", "siteLabelsDescription": "Administrar las etiquetas asociadas con este sitio.", - "labelsNotFound": "Etiquetas no encontradas", + "labelsNotFound": "No se encontraron etiquetas.", + "labelsEmptyCreateHint": "Empieza a escribir arriba para crear una etiqueta.", "labelSearch": "Buscar etiquetas", + "labelSearchOrCreate": "Buscar o crear una etiqueta", "accessLabelFilterCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "labelOverflowCount": "+{count, plural, one {# etiqueta} other {# etiquetas}}", "accessLabelFilterClear": "Borrar filtros de etiquetas", + "accessFilterClear": "Limpiar filtros", "selectColor": "Seleccionar color", "createNewLabel": "Crear nueva etiqueta de organización \"{label}\"", "inviteInvalidDescription": "El enlace de invitación no es válido.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", - "sidebarPolicies": "Políticas", - "sidebarResourcePolicies": "Recursos", + "sidebarPolicies": "Políticas Compartidas", + "sidebarResourcePolicies": "Recursos Públicos", "sidebarAccessControl": "Control de acceso", "sidebarLogsAndAnalytics": "Registros y análisis", "sidebarTeam": "Equipo", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Invitaciones", "sidebarRoles": "Roles", - "sidebarShareableLinks": "Enlaces", + "sidebarShareableLinks": "Enlaces Compartibles", "sidebarApiKeys": "Claves API", "sidebarProvisioning": "Aprovisionamiento", "sidebarSettings": "Ajustes", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Planos", "blueprintsLog": "Registro de planos", - "blueprintsDescription": "Ver aplicaciones de plano anteriores y sus resultados", + "blueprintsDescription": "Ver aplicaciones de planos anteriores y sus resultados o aplicar un nuevo plano", "blueprintAdd": "Añadir plano", "blueprintGoBack": "Ver todos los Planos", "blueprintCreate": "Crear Plano", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Habilitar Plano Docker", "enableDockerSocketDescription": "Activar el raspado de etiquetas del socket Docker para etiquetas de planos. La ruta del socket debe proporcionarse al conector del sitio. Lea sobre cómo funciona esto en la documentación.", "newtAutoUpdate": "Habilitar actualización automática del sitio", - "newtAutoUpdateDescription": "Cuando está habilitado, los conectores del sitio se actualizarán automáticamente a la última versión cuando haya disponible una nueva versión.", + "newtAutoUpdateDescription": "Cuando está habilitado, los conectores del sitio descargarán automáticamente la última versión y se reiniciarán. Esto se puede anular por sitio.", "siteAutoUpdate": "Actualización automática del sitio", "siteAutoUpdateLabel": "Habilitar actualización automática", - "siteAutoUpdateDescription": "Controlar si el conector de este sitio descarga automáticamente la última versión.", + "siteAutoUpdateDescription": "Cuando está habilitado, el conector de este sitio descargará automáticamente la última versión y se reiniciará.", "siteAutoUpdateOrgDefault": "Predeterminado de la organización: {state}", "siteAutoUpdateOverriding": "Configuración de anulación de la organización", "siteAutoUpdateResetToOrg": "Restablecer al predeterminado de la organización", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "¡Configuración de cuenta completada! ¡Bienvenido a Pangolin!", "documentation": "Documentación", "saveAllSettings": "Guardar todos los ajustes", - "saveResourceTargets": "Guardar objetivos", - "saveResourceHttp": "Guardar ajustes de proxy", - "saveProxyProtocol": "Guardar configuraciones del protocolo de proxy", + "saveResourceTargets": "Guardar ajustes", + "saveResourceHttp": "Guardar ajustes", + "saveProxyProtocol": "Guardar ajustes", "settingsUpdated": "Ajustes actualizados", "settingsUpdatedDescription": "Configuraciones actualizadas correctamente", "settingsErrorUpdate": "Error al actualizar ajustes", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Desconocido", "healthCheck": "Chequeo de salud", "configureHealthCheck": "Configurar Chequeo de Salud", - "configureHealthCheckDescription": "Configura la monitorización de salud para {target}", + "configureHealthCheckDescription": "Configura la monitorización para tu recurso para asegurarte que siempre está disponible", "enableHealthChecks": "Activar Chequeos de Salud", "healthCheckDisabledStateDescription": "Cuando está deshabilitado, el sitio no realizará comprobaciones de salud y el estado se considerará desconocido.", "enableHealthChecksDescription": "Controlar la salud de este objetivo. Puedes supervisar un punto final diferente al objetivo si es necesario.", "healthScheme": "Método", "healthSelectScheme": "Seleccionar método", - "healthCheckPortInvalid": "El puerto de chequeo de salud debe estar entre 1 y 65535", + "healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535", "healthCheckPath": "Ruta", "healthHostname": "IP / Nombre del host", "healthPort": "Puerto", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Requiere aprobaciones del dispositivo", "requireDeviceApprovalDescription": "Los usuarios con este rol necesitan nuevos dispositivos aprobados por un administrador antes de poder conectarse y acceder a los recursos.", "sshSettings": "Configuración SSH", + "sshAccess": "Acceso SSH", "rdpSettings": "Configuración RDP", "vncSettings": "Configuración VNC", "sshServer": "Servidor SSH", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Asegúrese de que su host objetivo esté correctamente configurado para ejecutar el daemon de autenticación antes de completar esta configuración, o la provisión fallará.", "sshDaemonPort": "Puerto del Daemon", "sshServerDestination": "Destino del Servidor", - "sshServerDestinationDescription": "Configure el destino y el puerto del servidor SSH", + "sshServerDestinationDescription": "Configurar el destino del servidor SSH", "destination": "Destino", + "destinationRequired": "Se requiere destino.", + "domainRequired": "Se requiere dominio.", + "proxyPortRequired": "Se requiere puerto.", + "invalidPathConfiguration": "Configuración de ruta no válida.", + "invalidRewritePathConfiguration": "Configuración de ruta de reescritura no válida.", "bgTargetMultiSiteDisclaimer": "Seleccionar múltiples sitios permite el enrutamiento resiliente y el failover para alta disponibilidad.", "roleAllowSsh": "Permitir SSH", "roleAllowSshAllow": "Permitir", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.", "sshSudo": "Permitir sudo", "sshSudoCommands": "Comandos Sudo", - "sshSudoCommandsDescription": "Lista separada por comas de comandos que el usuario puede ejecutar con sudo. Se deben usar rutas absolutas.", + "sshSudoCommandsDescription": "Lista de comandos que el usuario tiene permitido ejecutar con sudo, separados por comas, espacios o nuevas líneas. Se deben usar rutas absolutas.", "sshCreateHomeDir": "Crear directorio principal", "sshUnixGroups": "Grupos Unix", - "sshUnixGroupsDescription": "Grupos Unix separados por comas para agregar el usuario en el host de destino.", + "sshUnixGroupsDescription": "Grupos Unix a los que añadir el usuario en el host de destino, separados por comas, espacios o nuevas líneas.", + "roleTextFieldPlaceholder": "Introduce valores, o suelta un archivo .txt o .csv", + "roleTextImportTitle": "Importar desde Archivo", + "roleTextImportDescription": "Importando {fileName} en {fieldLabel}.", + "roleTextImportSkipHeader": "Omitir Primera Fila (Encabezado)", + "roleTextImportOverride": "Reemplazar Existente", + "roleTextImportAppend": "Añadir al Existente", + "roleTextImportMode": "Modo de Importación", + "roleTextImportPreview": "Previsualizar", + "roleTextImportItemCount": "{count, plural, =0 {No hay elementos para importar} one {1 elemento para importar} other {# elementos para importar}}", + "roleTextImportTotalCount": "{existing} existentes + {imported} importados = {total} total", + "roleTextImportConfirm": "Importar", + "roleTextImportInvalidFile": "Tipo de archivo no soportado", + "roleTextImportInvalidFileDescription": "Sólo se soportan archivos .txt y .csv.", + "roleTextImportEmpty": "No se encontraron elementos en el archivo", + "roleTextImportEmptyDescription": "El archivo no contiene ningún elemento importable.", "retryAttempts": "Intentos de Reintento", "expectedResponseCodes": "Códigos de respuesta esperados", "expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Habilitar protocolo proxy", "proxyProtocolInfo": "Conservar direcciones IP del cliente para backends TCP", "proxyProtocolVersion": "Versión del Protocolo Proxy", - "version1": " Versión 1 (Recomendado)", + "version1": "Versión 1 (Recomendada)", "version2": "Versión 2", - "versionDescription": "La versión 1 está basada en texto y es ampliamente soportada. La versión 2 es binaria y más eficiente pero menos compatible.", + "version1Description": "Basado en texto y ampliamente compatible. Asegúrate de que el transporte de servidores está agregado a la configuración dinámica.", + "version2Description": "Binario y más eficiente, pero menos compatible. Asegúrate de que el transporte de servidores está agregado a la configuración dinámica.", "warning": "Advertencia", "proxyProtocolWarning": "La aplicación backend debe configurarse para aceptar conexiones Proxy Protocol. Si el backend no soporta Proxy Protocol, activarlo romperá todas las conexiones, así que sólo habilítelo si sabe lo que está haciendo. Asegúrese de configurar su backend para que confíe en las cabeceras del protocolo Proxy de Traefik.", "restarting": "Reiniciando...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Ingresar confirmación", "blueprintViewDetails": "Detalles", "defaultIdentityProvider": "Proveedor de identidad predeterminado", - "defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.", + "defaultIdentityProviderDescription": "El usuario será redirigido automáticamente a este proveedor de identidad para autenticación.", "editInternalResourceDialogNetworkSettings": "Configuración de red", "editInternalResourceDialogAccessPolicy": "Política de acceso", "editInternalResourceDialogAddRoles": "Agregar roles", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Tipo de modo de mantenimiento", "showMaintenancePage": "Mostrar página de mantenimiento a los visitantes", "enableMaintenanceMode": "Habilitar modo de mantenimiento", + "enableMaintenanceModeDescription": "Cuando esté habilitado, los visitantes verán una página de mantenimiento en lugar de tu recurso.", "automatic": "Automático", "automaticModeDescription": "Mostrar página de mantenimiento solo cuando todos los objetivos de backend están caídos o no saludables. Su recurso continúa funcionando normalmente siempre que al menos un objetivo esté saludable.", "forced": "Forzado", @@ -3082,6 +3182,8 @@ "warning:": "Advertencia:", "forcedeModeWarning": "Todo el tráfico será dirigido a la página de mantenimiento. Sus recursos de backend no recibirán solicitudes.", "pageTitle": "Título de la página", + "maintenancePageContentSubsection": "Contenido de la Página", + "maintenancePageContentSubsectionDescription": "Personaliza el contenido mostrado en la página de mantenimiento", "pageTitleDescription": "El encabezado principal visible en la página de mantenimiento", "maintenancePageMessage": "Mensaje de mantenimiento", "maintenancePageMessagePlaceholder": "¡Volveremos pronto! Nuestro sitio está actualmente en mantenimiento programado.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "¿Está seguro de que desea desasociar este proveedor de identidad de esta organización?", "idpUnassociateDescription": "Todos los usuarios asociados con este proveedor de identidad serán eliminados de esta organización, pero el proveedor de identidad continuará existiendo para otras organizaciones asociadas.", "idpUnassociateConfirm": "Confirme Desasociar Proveedor de Identidad", + "idpConfirmDeleteAndRemoveMeFromOrg": "ELIMINAR Y QUITARME DE ORG", + "idpUnassociateAndRemoveMeFromOrg": "DESASOCIAR Y QUITARME DE ORG", "idpUnassociateWarning": "Esto no se puede deshacer para esta organización.", "idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito", "idpUnassociateMenu": "Desasociar", @@ -3439,18 +3543,58 @@ "sshConnecting": "Conectando…", "sshInitializing": "Inicializando…", "sshSignInTitle": "Iniciar sesión en SSH", - "sshSignInDescription": "Ingrese sus credenciales SSH", + "sshSignInDescription": "Ingresa tus credenciales SSH para conectar", "sshPasswordTab": "Contraseña", "sshPrivateKeyTab": "Clave Privada", "sshPrivateKeyField": "Clave Privada", "sshPrivateKeyDisclaimer": "Su clave privada no se almacena ni es visible para Pangolin. Alternativamente, puede usar certificados de corta duración para una autenticación sin interrupciones usando su identidad Pangolin existente.", "sshLearnMore": "Más información", "sshPrivateKeyFile": "Archivo de clave privada", - "sshAuthenticate": "Autenticarse", + "sshAuthenticate": "Conectar", "sshTerminate": "Terminar", "sshPoweredBy": "Desarrollado por", "sshErrorNoTarget": "No se especificó el objetivo", "sshErrorWebSocket": "Conexión WebSocket fallida", "sshErrorAuthFailed": "Falló la autenticación", - "sshErrorConnectionClosed": "La conexión se cerró antes de completar la autenticación" + "sshErrorConnectionClosed": "La conexión se cerró antes de completar la autenticación", + "sitePangolinSshDescription": "Permitir acceso SSH a los recursos en este sitio. Esto se puede cambiar más tarde.", + "browserGatewayNoResourceForDomain": "No se encontró un recurso para este dominio", + "browserGatewayNoTarget": "Sin destino", + "browserGatewayConnect": "Conectar", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Error al firmar la clave SSH para autenticación PAM push. ¿Te has iniciado sesión como usuario?", + "sshTerminalError": "Error: {error}", + "sshConnectionClosedCode": "Conexión cerrada (código {code})", + "sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----", + "sshPrivateKeyRequired": "Se requiere clave privada", + "vncTitle": "VNC", + "vncSignInDescription": "Introduce tu contraseña VNC para conectar", + "vncPasswordOptional": "Contraseña (opcional)", + "vncNoResourceTarget": "No hay objetivo de recurso disponible", + "vncFailedToLoadNovnc": "Error al cargar noVNC", + "vncAuthFailedStatus": "Estado {status}", + "vncPasteClipboard": "Pegar portapapeles", + "rdpTitle": "RDP", + "rdpSignInTitle": "Iniciar sesión en el Escritorio Remoto", + "rdpSignInDescription": "Introduce las credenciales de Windows para conectar", + "rdpLoadingModule": "Cargando módulo...", + "rdpFailedToLoadModule": "Error al cargar el módulo RDP", + "rdpNotReady": "No está listo", + "rdpModuleInitializing": "El módulo RDP aún se está iniciando", + "rdpDownloadingFiles": "Descargando {count} archivo(s) del remoto…", + "rdpDownloadFailed": "Error al descargar: {fileName}", + "rdpUploaded": "Subido: {fileName}", + "rdpNoConnectionTarget": "No hay objetivo de conexión disponible", + "rdpConnectionFailed": "Conexión fallida", + "rdpFit": "Ajustar", + "rdpFull": "Completo", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Subir archivos", + "rdpFilesReadyToPaste": "Archivos listos para pegar", + "rdpFilesReadyToPasteDescription": "{count, plural, one {# archivo copiado al portapapeles remoto — pulsa Ctrl+V en el escritorio remoto para pegar.} other {# archivos copiados al portapapeles remoto — pulsa Ctrl+V en el escritorio remoto para pegar.}}", + "rdpUploadFailed": "Error de subida", + "rdpUnicodeKeyboardMode": "Modo teclado Unicode", + "sessionToolbarShow": "Mostrar barra de herramientas", + "sessionToolbarHide": "Ocultar barra de herramientas" } From 07ae57ea7264e84fe6621d64e3ac1b1e5a8fc7e5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:41 -0700 Subject: [PATCH 020/264] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 242 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 49 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 91291d2e3..af2c2eca3 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Вижте частни ресурси", "siteInstallNewt": "Инсталирайте Newt", "siteInstallNewtDescription": "Пуснете Newt на вашата система", + "siteInstallKubernetesDocsDescription": "За повече и актуална информация относно инсталацията на Kubernetes, вижте docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "За инструкции за инсталиране на Advantech модем, вижте docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard конфигурация", "WgConfigurationDescription": "Използвайте следната конфигурация, за да се свържете с мрежата", "operatingSystem": "Операционна система", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Ще можете да виждате това само веднъж. Уверете се да го копирате на сигурно място.", "siteInfo": "Информация за сайта", "status": "Статус", - "shareTitle": "Управление на връзки за споделяне", + "shareTitle": "Управление на споделими връзки", "shareDescription": "Създайте споделими връзки, за да предоставите временен или постоянен достъп до прокси ресурсите", - "shareSearch": "Търсене на връзки за споделяне...", - "shareCreate": "Създайте връзка за споделяне", + "shareSearch": "Търсене на споделими връзки...", + "shareCreate": "Създаване на споделима връзка", "shareErrorDelete": "Неуспешно изтриване на връзката", "shareErrorDeleteMessage": "Възникна грешка при изтриване на връзката", "shareDeleted": "Връзката беше изтрита", "shareDeletedDescription": "Връзката беше премахната", - "shareDelete": "Изтрийте споделената връзка", - "shareDeleteConfirm": "Потвърдете изтриването на споделената връзка", + "shareDelete": "Изтриване на споделима връзка", + "shareDeleteConfirm": "Потвърдете изтриването на споделима връзка", "shareQuestionRemove": "Сигурни ли сте, че искате да изтриете тази споделена връзка?", "shareMessageRemove": "След изтриване връзката вече няма да работи и всеки, който я използва, ще загуби достъп до ресурса.", "shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Всеки с тази връзка може да получи достъп до ресурса", "shareTitleOptional": "Заглавие (по избор)", "sharePathOptional": "Път (по избор)", + "sharePathDescription": "След удостоверяване, линкът ще препрати потребителите на този път.", "expireIn": "Изтече", "neverExpire": "Никога не изтича", "shareExpireDescription": "Времето на изтичане е колко дълго връзката ще бъде използваема и ще предоставя достъп до ресурса. След това време, връзката няма да работи и потребителите, които са я използвали, ще загубят достъп до ресурса.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Моля, изберете ресурс", "proxyResourceTitle": "Управление на обществени ресурси", "proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.", - "publicResourcesBannerTitle": "Публичен достъп чрез уеб.", - "publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", + "publicResourcesBannerTitle": "Публичен достъп през Интернет", + "publicResourcesBannerDescription": "Публичните ресурси са HTTPS проксита, достъпни за всеки в Интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват клиентски софтуер и могат да включват издентити и контексто-осъзнати политики за достъп.", "clientResourceTitle": "Управление на частни ресурси", "clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.", "privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.", @@ -209,15 +212,19 @@ "resourcesSearch": "Търсене на ресурси...", "resourceAdd": "Добавете ресурс", "resourceErrorDelte": "Грешка при изтриване на ресурс", - "resourcePoliciesTitle": "Управление на политики за ресурси", - "resourcePoliciesAttachedResourcesColumnTitle": "Свързани ресурси", + "resourcePoliciesBannerTitle": "Повторно използване на Удостоверяване и Правила за Достъп", + "resourcePoliciesBannerDescription": "Споделените ресурсни политики ви позволяват да дефинирате методи за удостоверяване и правила за достъп веднъж, след което ги прикачвате към множество публични ресурси. Когато актуализирате политика, всеки свързан ресурс автоматично унаследява промените.", + "resourcePoliciesBannerButtonText": "Научете Повече", + "resourcePoliciesTitle": "Управление на публични ресурсни политики", + "resourcePoliciesAttachedResourcesColumnTitle": "Ресурси", "resourcePoliciesAttachedResources": "{count} ресурс(а)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ресурс} other {# ресурси}}", "resourcePoliciesAttachedResourcesEmpty": "няма ресурси", - "resourcePoliciesDescription": "Създавай и управлявай политики за автентикация, за да контролирате достъпа до вашите ресурси", + "resourcePoliciesDescription": "Създайте и управлявайте политики за удостоверяване за контролиране на достъпа до вашите публични ресурси", "resourcePoliciesSearch": "Търсене на политики...", "resourcePoliciesAdd": "Добавяне на политика", "resourcePoliciesDefaultBadgeText": "Стандартна политика", - "resourcePoliciesCreate": "Създаване на политика за ресурс", + "resourcePoliciesCreate": "Създаване на публична ресурсна политика", "resourcePoliciesCreateDescription": "Следвайте стъпките по-долу, за да създадете нова политика", "resourcePolicyName": "Име на политика", "resourcePolicyNameDescription": "Дайте на тази политика име, за да я идентифицирате в цялото ви ресурси", @@ -274,7 +281,7 @@ "back": "Назад", "cancel": "Отмяна", "resourceConfig": "Конфигурационни фрагменти", - "resourceConfigDescription": "Копирайте и поставете тези конфигурационни отрязъци, за да настроите TCP/UDP ресурса", + "resourceConfigDescription": "Копирайте и поставете тези фрагменти от конфигурация за настройка на TCP/UDP ресурса.", "resourceAddEntrypoints": "Traefik: Добавете Входни точки", "resourceExposePorts": "Gerbil: Изложете портове в Docker Compose", "resourceLearnRaw": "Научете как да конфигурирате TCP/UDP ресурси", @@ -287,6 +294,8 @@ "labelDelete": "Изтриване на етикета", "labelAdd": "Добавяне на етикет", "labelCreateSuccessMessage": "Етикетът е създаден успешно", + "labelDuplicateError": "Дублиран етикет", + "labelDuplicateErrorDescription": "Етикет с това име вече съществува.", "labelEditSuccessMessage": "Етикетът е променен успешно", "labelNameField": "Име на етикет", "labelColorField": "Цвят на етикет", @@ -311,7 +320,7 @@ "rules": "Правила", "resourceSettingDescription": "Конфигурирайте настройките на ресурса", "resourceSetting": "Настройки на {resourceName}", - "resourcePolicySettingDescription": "Конфигурирайте настройките на политиката за ресурс", + "resourcePolicySettingDescription": "Конфигурирайте настройките на тази публична ресурсна политика", "resourcePolicySetting": "Настройки за {policyName}", "alwaysAllow": "Заобикаляне на Ауторизацията", "alwaysDeny": "Блокиране на Достъпа", @@ -719,7 +728,7 @@ "targetSubmit": "Добавяне на цел", "targetNoOne": "Този ресурс няма цели. Добавете цел, за да конфигурирате къде да се изпращат заявките към бекенда.", "targetNoOneDescription": "Добавянето на повече от една цел ще активира натоварването на баланса.", - "targetsSubmit": "Запазване на целите", + "targetsSubmit": "Запази Настройки", "addTarget": "Добавете цел", "proxyMultiSiteRoundRobinNodeHelp": "Роунд Робин маршрутизирането няма да работи между сайтове, които не са свързани към един и същ възел, но автоматичното превключване ще работи.", "targetErrorInvalidIp": "Невалиден IP адрес", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Дубликат на правило", "rulesErrorDuplicateDescription": "Правило с тези настройки вече съществува", "rulesErrorInvalidIpAddressRange": "Невалиден CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Моля, въведете валидна стойност на CIDR", - "rulesErrorInvalidUrl": "Невалиден URL път", - "rulesErrorInvalidUrlDescription": "Моля, въведете валидна стойност за URL път", - "rulesErrorInvalidIpAddress": "Невалиден IP", - "rulesErrorInvalidIpAddressDescription": "Моля, въведете валиден IP адрес", + "rulesErrorInvalidIpAddressRangeDescription": "Въведете валиден CIDR диапазон (напр., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Невалиден път", + "rulesErrorInvalidUrlDescription": "Въведете валиден път на URL или шаблон (напр., /api/*).", + "rulesErrorInvalidIpAddress": "Невалиден IP адрес", + "rulesErrorInvalidIpAddressDescription": "Въведете валиден IPv4 или IPv6 адрес.", "rulesErrorUpdate": "Неуспешно актуализиране на правилата", "rulesErrorUpdateDescription": "Възникна грешка при актуализиране на правилата", "rulesUpdated": "Активиране на правилата", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Въведете IP адрес (напр. 103.21.244.12)", "rulesMatchUrl": "Въведете URL път или модел (напр. /api/v1/todos или /api/v1/*)", "rulesErrorInvalidPriority": "Невалиден приоритет", - "rulesErrorInvalidPriorityDescription": "Моля, въведете валиден приоритет", - "rulesErrorDuplicatePriority": "Дублирани приоритети", - "rulesErrorDuplicatePriorityDescription": "Моля, въведете уникални приоритети", + "rulesErrorInvalidPriorityDescription": "Въведете цяло число 1 или по-голямо.", + "rulesErrorDuplicatePriority": "Дублирания на приоритети", + "rulesErrorDuplicatePriorityDescription": "Всяко правило трябва да има уникален номер на приоритет.", + "rulesErrorValidation": "Невалидни правила", + "rulesErrorValidationRuleDescription": "Правило {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Изберете валиден тип съвпадение (път, IP, CIDR, държава, регион или ASN).", + "rulesErrorValueRequired": "Въведете стойност за това правило.", + "rulesErrorInvalidCountry": "Невалидна държава", + "rulesErrorInvalidCountryDescription": "Изберете валидна държава.", + "rulesErrorInvalidAsn": "Невалиден ASN", + "rulesErrorInvalidAsnDescription": "Въведете валиден ASN (напр., AS15169).", "ruleUpdated": "Правилата са актуализирани", "ruleUpdatedDescription": "Правилата бяха успешно актуализирани", "ruleErrorUpdate": "Операцията не бе успешна", "ruleErrorUpdateDescription": "Възникна грешка по време на операцията за запис", "rulesPriority": "Приоритет", + "rulesReorderDragHandle": "Плъзнете за преаранжиране на приоритети на правилата", "rulesAction": "Действие", "rulesMatchType": "Тип на съвпадение", "value": "Стойност", @@ -792,7 +810,7 @@ "rulesResource": "Конфигурация на правилата за ресурси", "rulesResourceDescription": "Конфигурирайте правила за контролиране на достъпа до ресурса", "ruleSubmit": "Добави правило", - "rulesNoOne": "Няма правила. Добавете правило чрез формуляра.", + "rulesNoOne": "Все още няма правила.", "rulesOrder": "Правилата се оценяват по приоритет в нарастващ ред.", "rulesSubmit": "Запазване на правилата", "policyErrorCreate": "Грешка при създаване на политика", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Възникна неочаквана грешка", "policyCreatedSuccess": "Политиката за ресурс е създадена успешно", "policyUpdatedSuccess": "Политиката за ресурс е актуализирана успешно", - "authMethodsSave": "Запазете методите за идентификация", + "authMethodsSave": "Запази Настройки", + "policyAuthStackTitle": "Удостоверяване", + "policyAuthStackDescription": "Контролирайте кои методи за удостоверяване са необходими за достъп до този ресурс", + "policyAuthOrLogicTitle": "Множество активни методи за удостоверяване", + "policyAuthOrLogicBanner": "Посетителите могат да се удостоверяват чрез който и да е от активните методи по-долу. Не е необходимо да преминават през всичките.", + "policyAuthMethodActive": "Активен", + "policyAuthMethodOff": "Изключено", + "policyAuthSsoTitle": "Платформено SSO", + "policyAuthSsoDescription": "Изисква вход чрез удостоверител на вашата организация", + "policyAuthSsoSummary": "{idp} · {users} потребители, {roles} роли", + "policyAuthSsoDefaultIdp": "По подразбиране удостоверител", + "policyAuthAddDefaultIdentityProvider": "Добавете удостоверител по подразбиране", + "policyAuthOtherMethodsTitle": "Други Методи", + "policyAuthOtherMethodsDescription": "Опционални методи, които посетителите могат да използват вместо или заедно с платформния SSO", + "policyAuthPasscodeTitle": "Парола", + "policyAuthPasscodeDescription": "Изисква споделена алфанумерична парола за достъп до ресурса", + "policyAuthPasscodeSummary": "Зададена парола", + "policyAuthPincodeTitle": "ПИН код", + "policyAuthPincodeDescription": "Кратък цифров код, необходим за достъп до ресурса", + "policyAuthPincodeSummary": "Зададен 6-цифрен ПИН код", + "policyAuthEmailTitle": "Списък с имейл адреси", + "policyAuthEmailDescription": "Позволете изброените имейл адреси с еднократни пароли", + "policyAuthEmailSummary": "{count} адреси са позволени", + "policyAuthEmailOtpCallout": "Активирането на списъка с имейл адреси изпраща еднократна парола на имейла на посетителя при влизане.", + "policyAuthHeaderAuthTitle": "Базово удостоверяване чрез заглавие", + "policyAuthHeaderAuthDescription": "Валидирайте собствено HTTP заглавие и стойност при всяка заявка", + "policyAuthHeaderAuthSummary": "Конфигурирано заглавие", + "policyAuthHeaderName": "Име на заглавието", + "policyAuthHeaderValue": "Очаквана стойност", + "policyAuthSetPasscode": "Задайте парола", + "policyAuthSetPincode": "Задайте ПИН код", + "policyAuthSetEmailWhitelist": "Задайте списък с имейли", + "policyAuthSetHeaderAuth": "Задайте базово удостоверяване чрез заглавие", + "policyAccessRulesTitle": "Правила за достъп", + "policyAccessRulesEnableDescription": "Когато е включено, правилата се оценяват в низходящ ред, докато едно не се оцени като вярно.", + "policyAccessRulesFirstMatch": "Правилата се оценяват от горе надолу. Първото съвпадащо правило определя резултата.", + "policyAccessRulesHowItWorks": "Правилата съпоставят заявки по път, IP адрес, местоположение или друг критерий. Всяко правило прилага действие: заобикаля удостоверяване, блокира достъп или преминава към удостоверяване. Ако няма съвпадение, трафикът продължава към удостоверяване.", + "policyAccessRulesFallthroughOff": "Когато правилата са изключени, целият трафик преминава към удостоверяване.", + "policyAccessRulesFallthroughOn": "Когато няма съвпадение, трафикът преминава към удостоверяване.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Запазете правилата", "resourceErrorCreate": "Грешка при създаване на ресурс", "resourceErrorCreateDescription": "Възникна грешка при създаването на ресурса", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса", "access": "Достъп", "accessControl": "Контрол на достъпа", - "shareLink": "{resource} Сподели връзка", + "shareLink": "{resource} Споделима връзка", "resourceSelect": "Изберете ресурс", - "shareLinks": "Споделени връзки", + "shareLinks": "Споделими връзки", "share": "Споделени връзки", "shareDescription2": "Създайте връзки за достъп до ресурси. Връзките предоставят временен или неограничен достъп до вашия ресурс. Можете да конфигурирате продължителността на изтичане на връзката, когато я създавате.", "shareEasyCreate": "Лесно за създаване и споделяне", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Администраторите винаги могат да имат достъп до този ресурс.", "resourcePolicySelectTitle": "Политика за достъп до ресурс", "resourcePolicySelectDescription": "Изберете типа на политиката за ресурс за идентификация", + "resourcePolicyTypeLabel": "Тип политика", + "resourcePolicyLabel": "Ресурсна политика", "resourcePolicyInline": "Инлайн Политика за Ресурс", "resourcePolicyInlineDescription": "Политика за достъп, ограничена само до този ресурс", "resourcePolicyShared": "Споделена Политика за Ресурс", - "resourcePolicySharedDescription": "Този ресурс използва споделена политика. Настройки на ниво политика (методи за идентификация, бял списък на имейли) са заключени. Можете да добавите правила за ресурса, роли и потребители по-долу.", + "resourcePolicySharedDescription": "Този ресурс използва споделена политика.", + "sharedPolicy": "Споделена политика", + "sharedPolicyNoneDescription": "Този ресурс има своя собствена политика.", + "resourceSharedPolicyOwnDescription": "Този ресурс има свои собствени контроли за удостоверяване и правила за достъп.", + "resourceSharedPolicyInheritedDescription": "Този ресурс наследява от {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Този ресурс използва споделена политика. Някои настройки на удостоверяване могат да се редактират на този ресурс, за да се добавят към политиката. За да промените основната политика, трябва да редактирате {policyName}.", + "resourceSharedPolicyRulesNotice": "Този ресурс използва споделена политика. Някои правила за достъп могат да се редактират на този ресурс. За да промените основната политика, трябва да редактирате {policyName}.", "resourceUsersRoles": "Контроли за достъп", "resourceUsersRolesDescription": "Конфигурирайте кои потребители и роли могат да посещават този ресурс", "resourceUsersRolesSubmit": "Запазване на управлението на достъп.", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Видимост", "resourceVisibilityTitleDescription": "Напълно активирайте или деактивирайте видимостта на ресурса", "resourceGeneral": "Общи настройки", - "resourceGeneralDescription": "Конфигурирайте общите настройки за този ресурс", + "resourceGeneralDescription": "Конфигурирайте име, адрес и политика за достъп за този ресурс.", + "resourceGeneralDetailsSubsection": "Подробности за ресурса", + "resourceGeneralDetailsSubsectionDescription": "Задайте показвано име, идентификатор и публично достъпен домейн за този ресурс.", + "resourceGeneralDetailsSubsectionPortDescription": "Задайте показвано име, идентификатор и публичен порт за този ресурс.", + "resourceGeneralPublicAddressSubsection": "Публичен Адрес", + "resourceGeneralPublicAddressSubsectionDescription": "Конфигурирайте как потребителите достигат до този ресурс.", + "resourceGeneralAuthenticationAccessSubsection": "Удостоверяване и Достъп", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Изберете дали този ресурс използва собствена политика или наследява от споделена политика.", "resourceEnable": "Активирайте ресурс", "resourceTransfer": "Прехвърлете ресурс", "resourceTransferDescription": "Прехвърлете този ресурс към различен сайт", @@ -1220,11 +1294,14 @@ "addLabels": "Добавяне на етикети", "siteLabelsTab": "Етикети", "siteLabelsDescription": "Управление на етикети, свързани с този сайт.", - "labelsNotFound": "Етикети не са намерени", + "labelsNotFound": "Не са намерени етикети.", + "labelsEmptyCreateHint": "Започнете да пишете горе, за да създадете етикет.", "labelSearch": "Търсене на етикети", + "labelSearchOrCreate": "Търсене или създаване на етикет", "accessLabelFilterCount": "{count, plural, one {# етикет} other {# етикети}}", "labelOverflowCount": "+{count, plural, one {# етикет} other {# етикети}}", "accessLabelFilterClear": "Изчисти филтрите за етикети", + "accessFilterClear": "Изчистване на филтри", "selectColor": "Изберете цвят", "createNewLabel": "Създайте нов организационен етикет \"{label}\"", "inviteInvalidDescription": "Линкът към поканата е невалиден.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Ресурси", "sidebarProxyResources": "Публично", "sidebarClientResources": "Частно", - "sidebarPolicies": "Политики", - "sidebarResourcePolicies": "Ресурси", + "sidebarPolicies": "Споделени политики", + "sidebarResourcePolicies": "Публични ресурси", "sidebarAccessControl": "Контрол на достъпа", "sidebarLogsAndAnalytics": "Дневници и анализи", "sidebarTeam": "Екип", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Администратор", "sidebarInvitations": "Покани", "sidebarRoles": "Роли", - "sidebarShareableLinks": "Връзки", + "sidebarShareableLinks": "Споделими връзки", "sidebarApiKeys": "API ключове", "sidebarProvisioning": "Осигуряване", "sidebarSettings": "Настройки", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsLog": "Регистър на скицописи", - "blueprintsDescription": "Вижте предишни приложения и техните резултати", + "blueprintsDescription": "Прегледайте съществуващите blueprint приложения и резултатите им или приложете нов blueprint", "blueprintAdd": "Добави Чертеж", "blueprintGoBack": "Виж всички Чертежи", "blueprintCreate": "Създай Чертеж", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Активиране на Docker Чернова", "enableDockerSocketDescription": "Активирайте изтегляне с етикети на Docker Socket за скицописи. Пътят на гнездото трябва да бъде предоставен на конектора на сайта. Прочетете как работи това в документацията.", "newtAutoUpdate": "Активиране на автоматично обновяване на сайта", - "newtAutoUpdateDescription": "Когато е активно, конекторите на сайта автоматично ще се актуализират до най-новата версия при наличието на ново издание.", + "newtAutoUpdateDescription": "Когато е активирана, свързочният възел на сайта автоматично ще изтегли последната версия и ще се рестартира сам. Това може да бъде преодоляно на ниво сайт.", "siteAutoUpdate": "Автоматично обновяване на сайта", "siteAutoUpdateLabel": "Активиране на автоматично обновяване", - "siteAutoUpdateDescription": "Управлявайте дали конекторът за този сайт автоматично изтегля последната версия.", + "siteAutoUpdateDescription": "Когато е активирана, конекторът на този сайт автоматично ще изтегли последната версия и ще се рестартира сам.", "siteAutoUpdateOrgDefault": "По подразбиране за организацията: {state}", "siteAutoUpdateOverriding": "Преодоляване на настройката на организацията", "siteAutoUpdateResetToOrg": "Възстановяване към организацията по подразбиране", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Настройката на профила завърши успешно! Добре дошли в Pangolin!", "documentation": "Документация", "saveAllSettings": "Запазване на всички настройки", - "saveResourceTargets": "Запазване на целеви ресурси.", - "saveResourceHttp": "Запазване на прокси настройките.", - "saveProxyProtocol": "Запазване на настройките на прокси протокола.", + "saveResourceTargets": "Запази Настройки", + "saveResourceHttp": "Запази Настройки", + "saveProxyProtocol": "Запази Настройки", "settingsUpdated": "Настройките са обновени", "settingsUpdatedDescription": "Настройките са успешно актуализирани.", "settingsErrorUpdate": "Неуспешно обновяване на настройките", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Неизвестен", "healthCheck": "Проверка на здравето", "configureHealthCheck": "Конфигуриране на проверка на здравето", - "configureHealthCheckDescription": "Настройте мониторинг на здравето за {target}", + "configureHealthCheckDescription": "Настройте мониторинг за вашия ресурс, за да се уверите, че винаги е на разположение", "enableHealthChecks": "Разрешаване на проверки на здравето", "healthCheckDisabledStateDescription": "Когато е деактивиран, сайтът не изпълнява проверки и състоянието се счита за неизвестно.", "enableHealthChecksDescription": "Мониторинг на здравето на тази цел. Можете да наблюдавате различен краен пункт от целта, ако е необходимо.", "healthScheme": "Метод", "healthSelectScheme": "Избор на метод", - "healthCheckPortInvalid": "Портът за проверка на състоянието трябва да е между 1 и 65535", + "healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535", "healthCheckPath": "Път", "healthHostname": "IP / Хост", "healthPort": "Порт", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Изискват одобрение на устройства", "requireDeviceApprovalDescription": "Потребители с тази роля трябва да имат нови устройства одобрени от администратор преди да могат да се свържат и да имат достъп до ресурси.", "sshSettings": "Настройки за SSH", + "sshAccess": "SSH Достъп", "rdpSettings": "Настройки за RDP", "vncSettings": "Настройки за VNC", "sshServer": "SSH сървър", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Уверете се, че вашата целева хост машина е правилно конфигурирана за изпълнение на демона за идентификация преди завършване на тази настройка, в противен случай осигуряването ще се провали.", "sshDaemonPort": "Порт на демона", "sshServerDestination": "Дестинация на сървъра", - "sshServerDestinationDescription": "Конфигуриране на дестинацията и порта на SSH сървъра", + "sshServerDestinationDescription": "Конфигурирайте дестинацията на SSH сървъра", "destination": "Дестинация", + "destinationRequired": "Дестинацията е необходима.", + "domainRequired": "Домейнът е необходим.", + "proxyPortRequired": "Портът е необходим.", + "invalidPathConfiguration": "Невалидна конфигурация на пътя.", + "invalidRewritePathConfiguration": "Невалидна конфигурация на пренаписване на пътя.", "bgTargetMultiSiteDisclaimer": "Избиране на множество сайтове позволява устойчиво маршрутизиране и сокетно превключване за висока наличност.", "roleAllowSsh": "Разреши SSH", "roleAllowSshAllow": "Разреши", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.", "sshSudo": "Разреши sudo", "sshSudoCommands": "Sudo команди", - "sshSudoCommandsDescription": "Списък с командите, разрешени да се изпълняват от потребителя с sudo. Трябва да се използват абсолютни пътища.", + "sshSudoCommandsDescription": "Списък с команди, които потребителят има право да изпълнява със sudo, разделени със запетаи, интервали или нови редове. Необходимо е да се използват абсолютни пътища.", "sshCreateHomeDir": "Създай начална директория", "sshUnixGroups": "Unix групи", - "sshUnixGroupsDescription": "Списък, разделен със запетаи, с Unix групи, към които да се добави потребителят на целевия хост.", + "sshUnixGroupsDescription": "Unix групи, за добавяне на потребителя на целевия хост, разделени със запетаи, интервали или нови редове.", + "roleTextFieldPlaceholder": "Въведете стойности или пуснете .txt или .csv файл", + "roleTextImportTitle": "Импортиране от файл", + "roleTextImportDescription": "Импортиране на {fileName} в {fieldLabel}.", + "roleTextImportSkipHeader": "Пропускане на първи ред (заглавие)", + "roleTextImportOverride": "Заместване на съществуващите", + "roleTextImportAppend": "Добавяне към съществуващите", + "roleTextImportMode": "Режим на импортиране", + "roleTextImportPreview": "Преглед", + "roleTextImportItemCount": "{count, plural, =0 {Няма артикули за импортиране} one {1 артикул за импортиране} other {# артикули за импортиране}}", + "roleTextImportTotalCount": "{existing} съществуващи + {imported} импортирани = {total} общо", + "roleTextImportConfirm": "Импортиране", + "roleTextImportInvalidFile": "Неподдържан тип файл", + "roleTextImportInvalidFileDescription": "Само .txt и .csv файлове са поддържани.", + "roleTextImportEmpty": "Няма намерени елементи във файла", + "roleTextImportEmptyDescription": "Файлът не съдържа подлежащи на импортиране елементи.", "retryAttempts": "Опити за повторно", "expectedResponseCodes": "Очаквани кодове за отговор", "expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Активирайте прокси протокола", "proxyProtocolInfo": "Запазете IP адресите на клиентите за TCP бекендове", "proxyProtocolVersion": "Версия на прокси протокола", - "version1": "Версия 1 (Препоръчително)", + "version1": "Версия 1 (Препоръчана)", "version2": "Версия 2", - "versionDescription": "Версия 1 е текстово-базирана и широко поддържана. Версия 2 е бърна и по-ефективна, но по-малко съвместима.", + "version1Description": "Текстово-базирана и широко поддържана. Уверете се, че транспортът на сървърите е добавен към динамичната конфигурация.", + "version2Description": "Двоична и по-ефективна, но по-малко съвместима. Уверете се, че транспортът на сървърите е добавен към динамичната конфигурация.", "warning": "Предупреждение", "proxyProtocolWarning": "Вашето бекенд приложение трябва да бъде конфигурирано да приема прокси протоколни връзки. Ако вашият бекенд не поддържа прокси протокол, активирането му ще прекъсне всички връзки. Уверете се, че сте конфигурирали вашия бекенд да се доверява на заглавията на прокси протокола от Traefik.", "restarting": "Рестартиране...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Въведете потвърждение.", "blueprintViewDetails": "Подробности.", "defaultIdentityProvider": "По подразбиране доставчик на идентичност.", - "defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.", + "defaultIdentityProviderDescription": "Потребителят автоматично ще бъде пренасочен към този удостоверител за удостоверяване.", "editInternalResourceDialogNetworkSettings": "Мрежови настройки.", "editInternalResourceDialogAccessPolicy": "Политика за достъп.", "editInternalResourceDialogAddRoles": "Добавяне на роли.", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Тип режим на поддръжка.", "showMaintenancePage": "Показване на страницата за поддръжка на посетители.", "enableMaintenanceMode": "Активиране на режим на поддръжка.", + "enableMaintenanceModeDescription": "При включване, посетителите ще виждат страница за поддръжка вместо вашия ресурс.", "automatic": "Автоматично.", "automaticModeDescription": "Показване на страницата за поддръжка само когато всички целеви подсистеми са неработоспособни или в лошо състояние. Вашият ресурс продължава да работи нормално, докато поне един целеви подсистемен елемент е в здравия диапазон.", "forced": "Наложително.", @@ -3082,6 +3182,8 @@ "warning:": "Предупреждение:", "forcedeModeWarning": "Целият трафик ще бъде пренасочен към страницата за поддръжка. Вашите подсистемни ресурси няма да получат никакви заявки.", "pageTitle": "Заглавие на страницата.", + "maintenancePageContentSubsection": "Съдържание на страницата", + "maintenancePageContentSubsectionDescription": "Персонализирайте съдържанието, показвано на страницата за поддръжка", "pageTitleDescription": "Основното заглавие, показвано на страницата за поддръжка.", "maintenancePageMessage": "Съобщение за поддръжка.", "maintenancePageMessagePlaceholder": "Ще се върнем скоро! Нашият сайт понастоящем е в процес на планирана поддръжка.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Сигурни ли сте, че искате да отвържете този доставчик на самоличност от тази организация?", "idpUnassociateDescription": "Всички потребители, свързани с този доставчик на самоличност, ще бъдат премахнати от тази организация, но доставчика на самоличност ще продължи да съществува за други свързани организации.", "idpUnassociateConfirm": "Потвърдете отвързване на доставчика на самоличност", + "idpConfirmDeleteAndRemoveMeFromOrg": "ИЗТРИВАНЕ И ПРЕМАХВАНЕ МЕ ОТ ОРГ", + "idpUnassociateAndRemoveMeFromOrg": "ОДЕЛЯНЕ И ПРЕМАХВАНЕ МЕ ОТ ОРГ", "idpUnassociateWarning": "Това не може да бъде отменено за тази организация.", "idpUnassociatedDescription": "Доставчика на самоличност е успешно отвързан от тази организация", "idpUnassociateMenu": "Отвързване", @@ -3439,18 +3543,58 @@ "sshConnecting": "Свързване…", "sshInitializing": "Инициализиране…", "sshSignInTitle": "Вход в SSH", - "sshSignInDescription": "Въведете данните за SSH", + "sshSignInDescription": "Въведете вашите SSH данни за свързване", "sshPasswordTab": "Парола", "sshPrivateKeyTab": "Частен ключ", "sshPrivateKeyField": "Частен ключ", "sshPrivateKeyDisclaimer": "Частният ви ключ не се съхранява или видима за Панголиин. Като алтернатива, можете да използвате краткотрайни сертификати за безпроблемна автентикация с вашата съществуваща идентичност в Панголиин.", "sshLearnMore": "Научете повече", "sshPrivateKeyFile": "Файл с частен ключ", - "sshAuthenticate": "Идентичност", + "sshAuthenticate": "Свързване", "sshTerminate": "Прекратяване", "sshPoweredBy": "Подпомогнато от", "sshErrorNoTarget": "Няма посочена цел", "sshErrorWebSocket": "Неуспешно създаване на WebSocket връзка", "sshErrorAuthFailed": "Неуспешна идентификация", - "sshErrorConnectionClosed": "Връзката е затворена преди завършване на идентификацията" + "sshErrorConnectionClosed": "Връзката е затворена преди завършване на идентификацията", + "sitePangolinSshDescription": "Позволете SSH достъп до ресурси на този сайт. Това може да бъде променено по-късно.", + "browserGatewayNoResourceForDomain": "Не е намерен ресурс за този домейн", + "browserGatewayNoTarget": "Няма цел", + "browserGatewayConnect": "Свързване", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Неуспешно подписване на SSH ключ за PAM push удостоверяване. Вписахте ли се като потребител?", + "sshTerminalError": "Грешка: {error}", + "sshConnectionClosedCode": "Връзката е затворена (код {code})", + "sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----", + "sshPrivateKeyRequired": "Изисква се частен ключ", + "vncTitle": "VNC", + "vncSignInDescription": "Въведете вашата VNC парола за свързване", + "vncPasswordOptional": "Парола (по избор)", + "vncNoResourceTarget": "Не е налична цел за ресурса", + "vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC", + "vncAuthFailedStatus": "Статус {status}", + "vncPasteClipboard": "Поставяне на клепач", + "rdpTitle": "RDP", + "rdpSignInTitle": "Вписване в Отдалечен Работен Плот", + "rdpSignInDescription": "Въведете данните за вашата Windows, за да се свържете", + "rdpLoadingModule": "Зареждане на модул...", + "rdpFailedToLoadModule": "Неуспешно зареждане на RDP модул", + "rdpNotReady": "Не е готов", + "rdpModuleInitializing": "RDP модулът все още се инициализира", + "rdpDownloadingFiles": "Изтегляне на {count} файл/файлове от отдалечено...", + "rdpDownloadFailed": "Изтеглянето е неуспешно: {fileName}", + "rdpUploaded": "Качено: {fileName}", + "rdpNoConnectionTarget": "Няма налична цел за свързване", + "rdpConnectionFailed": "Връзката неуспешна", + "rdpFit": "Напасване", + "rdpFull": "Пълен", + "rdpReal": "Реален", + "rdpMeta": "Мета", + "rdpUploadFiles": "Качване на файловете", + "rdpFilesReadyToPaste": "Файлове готови за поставяне", + "rdpFilesReadyToPasteDescription": "{count} файл(ове) копирани в отдалечения клипборд — натиснете Ctrl+V на отдалечения работен плот за поставяне.", + "rdpUploadFailed": "Качването неуспешно", + "rdpUnicodeKeyboardMode": "Режим на unicode клавиатура", + "sessionToolbarShow": "Показване на лентата с инструменти", + "sessionToolbarHide": "Скриване на лентата с инструменти" } From 1f0361e687866359db633233b0e97859180c2172 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:43 -0700 Subject: [PATCH 021/264] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 242 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 49 deletions(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 891bc58a4..581f37762 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Zobrazit soukromé zdroje", "siteInstallNewt": "Nainstalovat Newt", "siteInstallNewtDescription": "Spustit Newt na vašem systému", + "siteInstallKubernetesDocsDescription": "Pro více aktuálních informací o instalaci Kubernetes navštivte docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Pro pokyny k instalaci modemu Advantech navštivte docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Konfigurace WireGuard", "WgConfigurationDescription": "K připojení k síti použijte následující konfiguraci", "operatingSystem": "Operační systém", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Toto nastavení uvidíte pouze jednou. Ujistěte se, že jej zkopírujete na bezpečné místo.", "siteInfo": "Údaje o lokalitě", "status": "Stav", - "shareTitle": "Spravovat sdílení odkazů", + "shareTitle": "Spravovat sdílné odkazy", "shareDescription": "Vytvořit sdílitelné odkazy pro udělení dočasného nebo trvalého přístupu ke zdrojům proxy", - "shareSearch": "Hledat sdílené odkazy...", - "shareCreate": "Vytvořit odkaz", + "shareSearch": "Hledat sdílné odkazy...", + "shareCreate": "Vytvořit sdílný odkaz", "shareErrorDelete": "Nepodařilo se odstranit odkaz", "shareErrorDeleteMessage": "Došlo k chybě při odstraňování odkazu", "shareDeleted": "Odkaz odstraněn", "shareDeletedDescription": "Odkaz byl odstraněn", - "shareDelete": "Smazat odkaz ke sdílení", - "shareDeleteConfirm": "Potvrdit smazání odkazu ke sdílení", + "shareDelete": "Odstranit sdílný odkaz", + "shareDeleteConfirm": "Potvrdit odstranění sdílného odkazu", "shareQuestionRemove": "Jste si jisti, že chcete smazat tento odkaz ke sdílení?", "shareMessageRemove": "Jakmile bude smazán, odkaz přestane fungovat a všichni, kdo jej používají, ztratí přístup k prostředku.", "shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v záhlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Kdokoliv s tímto odkazem může přistupovat ke zdroji", "shareTitleOptional": "Název (volitelné)", "sharePathOptional": "Cesta (volitelně)", + "sharePathDescription": "Odkaz přesměruje uživatele na tuto cestu po autentikaci.", "expireIn": "Platnost vyprší za", "neverExpire": "Nikdy nevyprší", "shareExpireDescription": "Doba platnosti určuje, jak dlouho bude odkaz použitelný a bude poskytovat přístup ke zdroji. Po této době odkaz již nebude fungovat a uživatelé kteří tento odkaz používali ztratí přístup ke zdroji.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Zvolte prosím zdroj", "proxyResourceTitle": "Spravovat veřejné zdroje", "proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče", - "publicResourcesBannerTitle": "Veřejný přístup založený na webu", - "publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", + "publicResourcesBannerTitle": "Webové Veřejné Přístupy", + "publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS proxy přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", "clientResourceTitle": "Spravovat soukromé zdroje", "clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta", "privateResourcesBannerTitle": "Zero-Trust soukromý přístup", @@ -209,15 +212,19 @@ "resourcesSearch": "Prohledat zdroje...", "resourceAdd": "Přidat zdroj", "resourceErrorDelte": "Chyba při odstraňování zdroje", - "resourcePoliciesTitle": "Spravovat zásady zdrojů", - "resourcePoliciesAttachedResourcesColumnTitle": "Připojené zdroje", + "resourcePoliciesBannerTitle": "Opětovné použití pravidel pro autentifikaci a přístup", + "resourcePoliciesBannerDescription": "Sdílené politiky zdrojů vám umožňují definovat metody autentifikace a přístupová pravidla jednou, poté je připojit k více veřejným zdrojům. Při aktualizaci politiky každý propojený zdroj automaticky dědí změnu.", + "resourcePoliciesBannerButtonText": "Zjistit více", + "resourcePoliciesTitle": "Správa Veřejných Zásad Zdrojů", + "resourcePoliciesAttachedResourcesColumnTitle": "Zdroje", "resourcePoliciesAttachedResources": "{count} zdroj(e/ů)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# zdroj} few {# zdroje} many {# zdrojů} other {# zdrojů}}", "resourcePoliciesAttachedResourcesEmpty": "žádné zdroje", - "resourcePoliciesDescription": "Vytvářejte a spravujte zásady ověřování k řízení přístupu ke svým zdrojům", + "resourcePoliciesDescription": "Vytvořte a spravujte zásady autentifikace pro řízení přístupu k vašim veřejným zdrojům", "resourcePoliciesSearch": "Hledat zásady...", "resourcePoliciesAdd": "Přidat zásadu", "resourcePoliciesDefaultBadgeText": "Výchozí zásada", - "resourcePoliciesCreate": "Vytvořit zásadu zdroje", + "resourcePoliciesCreate": "Vytvořit Veřejnou Zásadu Zdroje", "resourcePoliciesCreateDescription": "Postupujte podle následujících kroků k vytvoření nové zásady", "resourcePolicyName": "Název zásady", "resourcePolicyNameDescription": "Pojmenujte tuto zásadu, aby byla rozpoznatelná napříč vašimi zdroji", @@ -274,7 +281,7 @@ "back": "Zpět", "cancel": "Zrušit", "resourceConfig": "Konfigurační snippety", - "resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační textové bloky pro nastavení TCP/UDP zdroje", + "resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační úryvky pro nastavení TCP/UDP zdroje.", "resourceAddEntrypoints": "Traefik: Přidat vstupní body", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceLearnRaw": "Naučte se konfigurovat zdroje TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Smazat štítek", "labelAdd": "Přidat štítek", "labelCreateSuccessMessage": "Štítek byl úspěšně vytvořen", + "labelDuplicateError": "Duplikátní štítek", + "labelDuplicateErrorDescription": "Štítek s tímto názvem již existuje.", "labelEditSuccessMessage": "Štítek byl úspěšně změněn", "labelNameField": "Název štítku", "labelColorField": "Barva štítku", @@ -311,7 +320,7 @@ "rules": "Pravidla", "resourceSettingDescription": "Konfigurace nastavení na zdroji", "resourceSetting": "Nastavení {resourceName}", - "resourcePolicySettingDescription": "Nakonfigurujte nastavení na zásadě zdroje", + "resourcePolicySettingDescription": "Konfigurujte nastavení této veřejné zásady zdrojů", "resourcePolicySetting": "Nastavení {policyName}", "alwaysAllow": "Obejít Auth", "alwaysDeny": "Blokovat přístup", @@ -719,7 +728,7 @@ "targetSubmit": "Add Target", "targetNoOne": "Tento zdroj nemá žádné cíle. Přidejte cíl pro konfiguraci kam poslat žádosti na backend.", "targetNoOneDescription": "Přidáním více než jednoho cíle se umožní vyvážení zatížení.", - "targetsSubmit": "Uložit cíle", + "targetsSubmit": "Uložit Nastavení", "addTarget": "Add Target", "proxyMultiSiteRoundRobinNodeHelp": "Round robin routing nebude fungovat mezi lokalitami, které nejsou připojeny ke stejnému uzlu, ale failover bude fungovat.", "targetErrorInvalidIp": "Neplatná IP adresa", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Duplikovat pravidlo", "rulesErrorDuplicateDescription": "Pravidlo s těmito nastaveními již existuje", "rulesErrorInvalidIpAddressRange": "Neplatný CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Zadejte prosím platnou hodnotu CIDR", - "rulesErrorInvalidUrl": "Neplatná URL cesta", - "rulesErrorInvalidUrlDescription": "Zadejte platnou hodnotu URL cesty", + "rulesErrorInvalidIpAddressRangeDescription": "Zadejte platný rozsah CIDR (např. 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Neplatná cesta", + "rulesErrorInvalidUrlDescription": "Zadejte platnou URL cestu nebo vzor (např. /api/*).", "rulesErrorInvalidIpAddress": "Neplatná IP adresa", - "rulesErrorInvalidIpAddressDescription": "Zadejte prosím platnou IP adresu", + "rulesErrorInvalidIpAddressDescription": "Zadejte platnou IPv4 nebo IPv6 adresu.", "rulesErrorUpdate": "Aktualizace pravidel se nezdařila", "rulesErrorUpdateDescription": "Při aktualizaci pravidel došlo k chybě", "rulesUpdated": "Povolit pravidla", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "Zadejte adresu ve formátu CIDR (např. 103.21.244.0/22)", "rulesMatchIpAddress": "Zadejte IP adresu (např. 103.21.244.12)", "rulesMatchUrl": "Zadejte URL cestu nebo vzor (např. /api/v1/todos nebo /api/v1/*)", - "rulesErrorInvalidPriority": "Neplatná Priorita", - "rulesErrorInvalidPriorityDescription": "Zadejte prosím platnou prioritu", - "rulesErrorDuplicatePriority": "Duplikovat priority", - "rulesErrorDuplicatePriorityDescription": "Zadejte prosím unikátní priority", + "rulesErrorInvalidPriority": "Neplatná priorita", + "rulesErrorInvalidPriorityDescription": "Zadejte celé číslo 1 nebo vyšší.", + "rulesErrorDuplicatePriority": "Duplicitní priority", + "rulesErrorDuplicatePriorityDescription": "Každé pravidlo musí mít unikátní číslo priority.", + "rulesErrorValidation": "Neplatná pravidla", + "rulesErrorValidationRuleDescription": "Pravidlo {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Vyberte platný typ shody (cesta, IP, CIDR, země, oblast nebo ASN).", + "rulesErrorValueRequired": "Zadejte hodnotu pro toto pravidlo.", + "rulesErrorInvalidCountry": "Neplatná země", + "rulesErrorInvalidCountryDescription": "Vyberte platnou zemi.", + "rulesErrorInvalidAsn": "Neplatný ASN", + "rulesErrorInvalidAsnDescription": "Zadejte platný ASN (např. AS15169).", "ruleUpdated": "Pravidla byla aktualizována", "ruleUpdatedDescription": "Pravidla byla úspěšně aktualizována", "ruleErrorUpdate": "Operace selhala", "ruleErrorUpdateDescription": "Při ukládání došlo k chybě", "rulesPriority": "Priorita", + "rulesReorderDragHandle": "Přetažením změňte prioritu pravidel", "rulesAction": "Akce", "rulesMatchType": "Typ shody", "value": "Hodnota", @@ -792,7 +810,7 @@ "rulesResource": "Konfigurace pravidel zdroje", "rulesResourceDescription": "Nastavit pravidla pro kontrolu přístupu ke zdroji", "ruleSubmit": "Přidat pravidlo", - "rulesNoOne": "Žádná pravidla. Přidejte pravidlo pomocí formuláře.", + "rulesNoOne": "Žádná pravidla zatím nejsou.", "rulesOrder": "Pravidla jsou hodnocena podle priority vzestupně.", "rulesSubmit": "Uložit pravidla", "policyErrorCreate": "Chyba při vytváření zásady", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Došlo k neočekávané chybě", "policyCreatedSuccess": "Zásada zdroje byla úspěšně vytvořena", "policyUpdatedSuccess": "Zásada zdroje byla úspěšně aktualizována", - "authMethodsSave": "Uložit metody ověřování", + "authMethodsSave": "Uložit nastavení", + "policyAuthStackTitle": "Autentifikace", + "policyAuthStackDescription": "Určete, které metody autentifikace jsou požadovány pro přístup k tomuto zdroji", + "policyAuthOrLogicTitle": "Více metod autentifikace je aktivních", + "policyAuthOrLogicBanner": "Návštěvníci mohou použít jakoukoli aktivní metodu uvedenou níže. Nemusí splnit všechny z nich.", + "policyAuthMethodActive": "Aktivní", + "policyAuthMethodOff": "Vypnuto", + "policyAuthSsoTitle": "Platformové SSO", + "policyAuthSsoDescription": "Požadujte přihlášení prostřednictvím identifikačního poskytovatele vaší organizace", + "policyAuthSsoSummary": "{idp} · {users} uživatelé, {roles} role", + "policyAuthSsoDefaultIdp": "Výchozí poskytovatel", + "policyAuthAddDefaultIdentityProvider": "Přidat výchozího identifikačního poskytovatele", + "policyAuthOtherMethodsTitle": "Ostatní metody", + "policyAuthOtherMethodsDescription": "Volitelné metody, které návštěvníci mohou použít místo nebo vedle platformového SSO", + "policyAuthPasscodeTitle": "Heslo", + "policyAuthPasscodeDescription": "Vyžadovat sdílené alfanumerické heslo pro přístup ke zdroji", + "policyAuthPasscodeSummary": "Sada hesel", + "policyAuthPincodeTitle": "PIN Kód", + "policyAuthPincodeDescription": "Krátký číselný kód vyžadován pro přístup ke zdroji", + "policyAuthPincodeSummary": "Nastaven 6místný PIN", + "policyAuthEmailTitle": "Email Whitelist", + "policyAuthEmailDescription": "Povolit vybraným emailovým adresám s jednorázovými hesly", + "policyAuthEmailSummary": "Povoleno {count} adres(y)", + "policyAuthEmailOtpCallout": "Povolení seznamu povolených e-mailů odešle jednorázové heslo na e-mail návštěvníka při přihlášení.", + "policyAuthHeaderAuthTitle": "Základní Ověření Záhlaví", + "policyAuthHeaderAuthDescription": "Ověřit vlastní HTTP hlavičku názvu a hodnoty při každém požadavku", + "policyAuthHeaderAuthSummary": "Nastaveno hlavička", + "policyAuthHeaderName": "Název hlavičky", + "policyAuthHeaderValue": "Očekávaná hodnota", + "policyAuthSetPasscode": "Nastavit přístupový kód", + "policyAuthSetPincode": "Nastavit PIN kód", + "policyAuthSetEmailWhitelist": "Nastavit e-mailový whitelist", + "policyAuthSetHeaderAuth": "Nastavit základní autentizaci hlavičkou", + "policyAccessRulesTitle": "Pravidla Přístupu", + "policyAccessRulesEnableDescription": "Když je povoleno, pravidla jsou hodnocena sestupně, dokud jedno není vyhodnoceno jako pravda.", + "policyAccessRulesFirstMatch": "Pravidla jsou vyhodnocována shora dolů. První odpovídající pravidlo určuje výsledek.", + "policyAccessRulesHowItWorks": "Pravidla odpovídají požadavkům podle cesty, IP adresy, lokace nebo jiného kritéria. Každé pravidlo aplikuje akci: obejít autentizaci, zablokovat přístup nebo předat k autentizaci. Pokud žádné neodpovídá, provoz pokračuje k autentizaci.", + "policyAccessRulesFallthroughOff": "Když jsou pravidla zakázána, veškerý provoz přechází k autentizaci.", + "policyAccessRulesFallthroughOn": "Když žádné pravidlo neodpovídá, provoz přechází k autentizaci.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Uložit pravidla", "resourceErrorCreate": "Chyba při vytváření zdroje", "resourceErrorCreateDescription": "Při vytváření zdroje došlo k chybě", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje", "access": "Přístup", "accessControl": "Kontrola přístupu", - "shareLink": "{resource} Sdílet odkaz", + "shareLink": "{resource} Sdílný odkaz", "resourceSelect": "Vyberte zdroj", - "shareLinks": "Sdílet odkazy", + "shareLinks": "Sdíletelné odkazy", "share": "Sdílené odkazy", "shareDescription2": "Vytvořte sdílitelné odkazy na zdroje. Odkazy poskytují dočasný nebo neomezený přístup k vašemu zdroji. Můžete nakonfigurovat dobu vypršení platnosti odkazu při jeho vytvoření.", "shareEasyCreate": "Snadné vytváření a sdílení", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Administrátoři mají vždy přístup k tomuto zdroji.", "resourcePolicySelectTitle": "Zásada přístupu ke zdrojům", "resourcePolicySelectDescription": "Vyberte typ zásady zdroje ověřování", + "resourcePolicyTypeLabel": "Typ zásady zdroje", + "resourcePolicyLabel": "Zásada zdroje", "resourcePolicyInline": "Inline Zásada Zdroje", "resourcePolicyInlineDescription": "Zásada přístupu se zaměřením pouze na tento zdroj", "resourcePolicyShared": "Sdílená Zásada Zdroje", - "resourcePolicySharedDescription": "Tento zdroj používá sdílenou zásadu. Nastavení na úrovni zásady (metody ověřování, seznam povolených emailů) jsou uzamčena. Níže můžete přidat pravidla, role a uživatele specifické pro zdroj.", + "resourcePolicySharedDescription": "Tento zdroj používá sdílenou zásadu.", + "sharedPolicy": "Sdílená Zásada", + "sharedPolicyNoneDescription": "Tento zdroj má vlastní zásadu.", + "resourceSharedPolicyOwnDescription": "Tento zdroj má vlastní ovládání autentifikace a přístupových pravidel.", + "resourceSharedPolicyInheritedDescription": "Tento zdroj dědí ze {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Tento zdroj používá sdílenou politiku. Některá nastavení autentizace lze upravit na tomto zdroji k doplnění politiky. Pro úpravu základní politiky musíte upravit {policyName}.", + "resourceSharedPolicyRulesNotice": "Tento zdroj používá sdílenou politiku. Některá přístupová pravidla lze upravit na tomto zdroji. Chcete-li změnit základní politiku, musíte upravit {policyName}.", "resourceUsersRoles": "Kontrola přístupu", "resourceUsersRolesDescription": "Nastavení, kteří uživatelé a role mohou navštívit tento zdroj", "resourceUsersRolesSubmit": "Uložit přístupové řízení", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Viditelnost", "resourceVisibilityTitleDescription": "Zcela povolit nebo zakázat viditelnost zdrojů", "resourceGeneral": "Obecná nastavení", - "resourceGeneralDescription": "Konfigurace obecných nastavení tohoto zdroje", + "resourceGeneralDescription": "Nakonfigurujte název, adresu a přístupovou politiku pro tento zdroj.", + "resourceGeneralDetailsSubsection": "Detaily zdroje", + "resourceGeneralDetailsSubsectionDescription": "Nastavte zobrazovaný název, identifikátor a veřejně dostupnou doménu pro tento zdroj.", + "resourceGeneralDetailsSubsectionPortDescription": "Nastavte zobrazovaný název, identifikátor a veřejný port pro tento zdroj.", + "resourceGeneralPublicAddressSubsection": "Veřejná Adresa", + "resourceGeneralPublicAddressSubsectionDescription": "Nakonfigurujte, jak uživatelé dosáhnou tento zdroj.", + "resourceGeneralAuthenticationAccessSubsection": "Autentizace & Přístup", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Vyberte, zda tento zdroj používá vlastní politiku, nebo dědí od sdílené politiky.", "resourceEnable": "Povolit dokument", "resourceTransfer": "Přenos zdroje", "resourceTransferDescription": "Přenést tento zdroj na jiný web", @@ -1220,11 +1294,14 @@ "addLabels": "Přidat štítky", "siteLabelsTab": "Štítky", "siteLabelsDescription": "Spravujte štítky přiřazené k této lokalitě.", - "labelsNotFound": "Štítky nenalezeny", + "labelsNotFound": "Nebyly nalezeny žádné štítky.", + "labelsEmptyCreateHint": "Začněte psát výše k vytvoření štítku.", "labelSearch": "Hledat štítky", + "labelSearchOrCreate": "Hledání nebo vytvoření štítku", "accessLabelFilterCount": "{count, plural, one {# štítek} few {# štítky} other {# štítků}}", "labelOverflowCount": "+{count, plural, one {# štítek} few {# štítky} other {# štítků}}", "accessLabelFilterClear": "Vymazat filtry štítků", + "accessFilterClear": "Vymazat filtry", "selectColor": "Vybrat barvu", "createNewLabel": "Vytvořit nový štítek organizace \"{label}\"", "inviteInvalidDescription": "Odkaz pro pozvání je neplatný.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Zdroje", "sidebarProxyResources": "Veřejnost", "sidebarClientResources": "Soukromé", - "sidebarPolicies": "Zásady", - "sidebarResourcePolicies": "Zdroje", + "sidebarPolicies": "Sdílené Odkazy", + "sidebarResourcePolicies": "Veřejné Zdroje", "sidebarAccessControl": "Kontrola přístupu", "sidebarLogsAndAnalytics": "Logy & Analytika", "sidebarTeam": "Tým", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Pozvánky", "sidebarRoles": "Role", - "sidebarShareableLinks": "Odkazy", + "sidebarShareableLinks": "Sdílené Odkazy", "sidebarApiKeys": "API klíče", "sidebarProvisioning": "Zajištění", "sidebarSettings": "Nastavení", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Zdroj {id}", "blueprints": "Plány", "blueprintsLog": "Protokol plánů", - "blueprintsDescription": "Prohlédněte si aplikace předchozích plánů a jejich výsledky", + "blueprintsDescription": "Zobrazit předchozí aplikace modrotisku a jejich výsledky nebo aplikovat nový modrotisk", "blueprintAdd": "Přidat plán", "blueprintGoBack": "Zobrazit všechny plány", "blueprintCreate": "Vytvořit plán", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Povolit Docker plán", "enableDockerSocketDescription": "Povolte seškrábání štítků pro Docker Socket pro štítky plánů. Před připojením na lokalitní konektor musí být uvedena cesta k soketu. Přečtěte si, jak to funguje v dokumentaci.", "newtAutoUpdate": "Povolit automatickou aktualizaci stránek", - "newtAutoUpdateDescription": "Když je zapnuto, konektory lokality se automaticky aktualizují na nejnovější verzi, když je k dispozici nové vydání.", + "newtAutoUpdateDescription": "Když je povoleno, konektory stránek automaticky stáhnou nejnovější verzi a restartují se. To lze přepsat na základě jednotlivých míst.", "siteAutoUpdate": "Automatická aktualizace stránek", "siteAutoUpdateLabel": "Povolte automatickou aktualizaci", - "siteAutoUpdateDescription": "Ovládněte, zda bude konektor tohoto webu automaticky stahovat nejnovější verzi.", + "siteAutoUpdateDescription": "Když je povoleno, konektor této stránky automaticky stáhne nejnovější verzi a restartuje se sám.", "siteAutoUpdateOrgDefault": "Výchozí organizace: {state}", "siteAutoUpdateOverriding": "Přepsání nastavení organizace", "siteAutoUpdateResetToOrg": "Obnovit na výchozí organizaci", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Nastavení účtu dokončeno! Vítejte v Pangolinu!", "documentation": "Dokumentace", "saveAllSettings": "Uložit všechna nastavení", - "saveResourceTargets": "Uložit cíle", - "saveResourceHttp": "Uložit nastavení proxy", - "saveProxyProtocol": "Uložit nastavení proxy protokolu", + "saveResourceTargets": "Uložit Nastavení", + "saveResourceHttp": "Uložit Nastavení", + "saveProxyProtocol": "Uložit Nastavení", "settingsUpdated": "Nastavení aktualizováno", "settingsUpdatedDescription": "Nastavení úspěšně aktualizována", "settingsErrorUpdate": "Aktualizace nastavení se nezdařila", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Neznámý", "healthCheck": "Kontrola stavu", "configureHealthCheck": "Konfigurace kontroly stavu", - "configureHealthCheckDescription": "Nastavit sledování zdravotního stavu pro {target}", + "configureHealthCheckDescription": "Nastavte monitorování vašeho zdroje, abyste zajistili, že je vždy dostupný", "enableHealthChecks": "Povolit kontrolu stavu", "healthCheckDisabledStateDescription": "Pokud je zakázáno, web nebude provádět zdravotní kontroly a stav bude považován za neznámý.", "enableHealthChecksDescription": "Sledujte zdraví tohoto cíle. V případě potřeby můžete sledovat jiný cílový bod, než je cíl.", "healthScheme": "Způsob", "healthSelectScheme": "Vybrat metodu", - "healthCheckPortInvalid": "Přístav kontroly stavu musí být mezi 1 a 65535", + "healthCheckPortInvalid": "Port musí být mezi 1 a 65535", "healthCheckPath": "Cesta", "healthHostname": "IP / Hostitel", "healthPort": "Přístav", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Vyžadovat schválení zařízení", "requireDeviceApprovalDescription": "Uživatelé s touto rolí potřebují nová zařízení schválená správcem, než se mohou připojit a přistupovat ke zdrojům.", "sshSettings": "Nastavení SSH", + "sshAccess": "SSH Přístup", "rdpSettings": "Nastavení RDP", "vncSettings": "Nastavení VNC", "sshServer": "SSH server", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Ujistěte se, že váš cílový hostitel je správně nakonfigurován k přímu spuštění ověřovacího démona, jinak zřizování selže.", "sshDaemonPort": "Port démona", "sshServerDestination": "Cíl serveru", - "sshServerDestinationDescription": "Nakonfigurujte cíl a port SSH serveru", + "sshServerDestinationDescription": "Nakonfigurujte cíl serveru SSH", "destination": "Cíl", + "destinationRequired": "Destinace je vyžadována.", + "domainRequired": "Doména je vyžadována.", + "proxyPortRequired": "Port je vyžadován.", + "invalidPathConfiguration": "Neplatná konfigurace cesty.", + "invalidRewritePathConfiguration": "Neplatná konfigurace přepsat cesty.", "bgTargetMultiSiteDisclaimer": "Výběr více lokalit umožňuje odolné směrování a převzetí služeb při selhání pro vysokou dostupnost.", "roleAllowSsh": "Povolit SSH", "roleAllowSshAllow": "Povolit", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.", "sshSudo": "Povolit sudo", "sshSudoCommands": "Sudo příkazy", - "sshSudoCommandsDescription": "Čárkami oddělený seznam příkazů, které je uživatel povolen spustit s sudo. Musí být použity absolutní cesty.", + "sshSudoCommandsDescription": "Seznam příkazů, které je uživateli povoleno spouštět se sudo, oddělený čárkami, mezerami, nebo novými řádky. Je třeba používat absolutní cesty.", "sshCreateHomeDir": "Vytvořit domovský adresář", "sshUnixGroups": "Unixové skupiny", - "sshUnixGroupsDescription": "Čárkou oddělené skupiny Unix přidají uživatele do cílového hostitele.", + "sshUnixGroupsDescription": "Unixové skupiny, do kterých má být uživatel přidán na cílovém hostu, oddělené čárkami, mezerami, nebo novými řádky.", + "roleTextFieldPlaceholder": "Zadejte hodnoty nebo přetáhněte soubor .txt nebo .csv", + "roleTextImportTitle": "Importovat ze souboru", + "roleTextImportDescription": "Importuje se {fileName} do {fieldLabel}.", + "roleTextImportSkipHeader": "Přeskočit první řádek (záhlaví)", + "roleTextImportOverride": "Nahradit existující", + "roleTextImportAppend": "Přidat k existujícímu", + "roleTextImportMode": "Režim importu", + "roleTextImportPreview": "Náhled", + "roleTextImportItemCount": "{count, plural, =0 {Žádné položky k importu} one {1 položka k importu} few {# položky k importu} many {# položek k importu} other {# položek k importu}}", + "roleTextImportTotalCount": "{existing} existující + {imported} importované = {total} celkem", + "roleTextImportConfirm": "Importovat", + "roleTextImportInvalidFile": "Nepodporovaný typ souboru", + "roleTextImportInvalidFileDescription": "Podporovány jsou pouze soubory .txt a .csv.", + "roleTextImportEmpty": "V souboru nebyly nalezeny žádné položky", + "roleTextImportEmptyDescription": "Soubor neobsahuje žádné položky k importu.", "retryAttempts": "Opakovat pokusy", "expectedResponseCodes": "Očekávané kódy odezvy", "expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Povolit Proxy protokol", "proxyProtocolInfo": "Zachovat IP adresu klienta pro TCP zálohy", "proxyProtocolVersion": "Verze proxy protokolu", - "version1": " Verze 1 (doporučeno)", + "version1": "Verze 1 (Doporučeno)", "version2": "Verze 2", - "versionDescription": "Verze 1 je textová a široce podporovaná. Verze 2 je binární a efektivnější, ale méně kompatibilní.", + "version1Description": "Textově založený a široce podporovaný. Ujistěte se, že je transport serveru přidán do dynamické konfigurace.", + "version2Description": "Binární a efektivnější, ale méně kompatibilní. Ujistěte se, že je transport serveru přidán do dynamické konfigurace.", "warning": "Varování", "proxyProtocolWarning": "Aplikace backend musí být nakonfigurována, aby mohla přijímat připojení k Proxy protokolu. Pokud vaše backend nepodporuje Proxy protokol, povolením tohoto protokolu dojde k přerušení všech připojení, takže toto povolíte pouze pokud víte, co děláte. Ujistěte se, že nastavíte svou backend a důvěřujte hlavičkám Proxy protokolu z Traefik.", "restarting": "Restartování...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Zadejte potvrzení", "blueprintViewDetails": "Detaily", "defaultIdentityProvider": "Výchozí poskytovatel identity", - "defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.", + "defaultIdentityProviderDescription": "Uživatel bude automaticky přesměrován na tohoto identifikačního poskytovatele pro autentifikaci.", "editInternalResourceDialogNetworkSettings": "Nastavení sítě", "editInternalResourceDialogAccessPolicy": "Přístupová politika", "editInternalResourceDialogAddRoles": "Přidat role", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Typ režimu údržby", "showMaintenancePage": "Zobrazit stránku údržby návštěvníkům", "enableMaintenanceMode": "Povolit režim údržby", + "enableMaintenanceModeDescription": "Když je povoleno, návštěvníci uvidí údržbu místo vašeho zdroje.", "automatic": "Automatické", "automaticModeDescription": "Zobrazte stránku údržby pouze, když jsou všechny cílové servery uživatele nebo prostředku nefunkční nebo nezdravé. Vaše prostředky budou nadále fungovat normálně, pokud je alespoň jeden cíl v pořádku.", "forced": "Nucené", @@ -3082,6 +3182,8 @@ "warning:": "Varování:", "forcedeModeWarning": "Veškerý provoz bude směrován na stránku údržby. Vaše prostředky backendu neobdrží žádné žádosti.", "pageTitle": "Název stránky", + "maintenancePageContentSubsection": "Obsah Stránky", + "maintenancePageContentSubsectionDescription": "Přizpůsobte obsah zobrazovaný na stránce údržby", "pageTitleDescription": "Hlavní titulek zobrazovaný na stránce údržby", "maintenancePageMessage": "Zpráva údržby", "maintenancePageMessagePlaceholder": "Vrátíme se brzy! Naše stránka právě prochází plánovanou údrbou.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Opravdu chcete odpojit tohoto poskytovatele identity od této organizace?", "idpUnassociateDescription": "Všichni uživatelé spojení s tímto poskytovatelem identity budou odstraněni z této organizace, ale poskytovatel identity zůstane nadále existovat pro ostatní přidružené organizace.", "idpUnassociateConfirm": "Potvrdit odpojení poskytovatele identity", + "idpConfirmDeleteAndRemoveMeFromOrg": "SMAZAT A ODSTRANIT MĚ Z ORGANIZACE", + "idpUnassociateAndRemoveMeFromOrg": "ODPOJIT A ODSTRANIT MĚ Z ORGANIZACE", "idpUnassociateWarning": "Toto nelze pro tuto organizaci vrátit.", "idpUnassociatedDescription": "Poskytovatel identity byl úspěšně odpojen od této organizace", "idpUnassociateMenu": "Odpojit", @@ -3439,18 +3543,58 @@ "sshConnecting": "Připojení…", "sshInitializing": "Inicializace…", "sshSignInTitle": "Přihlášení do SSH", - "sshSignInDescription": "Zadejte své SSH přihlašovací údaje", + "sshSignInDescription": "Zadejte své údaje SSH pro připojení", "sshPasswordTab": "Heslo", "sshPrivateKeyTab": "Soukromý klíč", "sshPrivateKeyField": "Soukromý klíč", "sshPrivateKeyDisclaimer": "Váš soukromý klíč není ukládán ani viditelný pro Pangolin. Alternativně můžete použít krátkodobé certifikáty pro bezproblémové ověřování pomocí vaší stávající identity Pangolin.", "sshLearnMore": "Přečtěte si více", "sshPrivateKeyFile": "Soubor soukromého klíče", - "sshAuthenticate": "Ověřit", + "sshAuthenticate": "Připojit", "sshTerminate": "Ukončit", "sshPoweredBy": "Vytváří", "sshErrorNoTarget": "Cíl nebyl určen", "sshErrorWebSocket": "Chyba připojení WebSocketu", "sshErrorAuthFailed": "Ověření selhalo", - "sshErrorConnectionClosed": "Připojení bylo uzavřeno před dokončením ověřování" + "sshErrorConnectionClosed": "Připojení bylo uzavřeno před dokončením ověřování", + "sitePangolinSshDescription": "Povolte SSH přístup k zdrojům na tomto místě. Toto lze změnit později.", + "browserGatewayNoResourceForDomain": "Pro tuto doménu nebyl nalezen žádný zdroj", + "browserGatewayNoTarget": "Žádný cíl", + "browserGatewayConnect": "Připojit", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Nepodařilo se podepsat klíč SSH pro ověřování pomocí PAM push. Přihlásili jste se jako uživatel?", + "sshTerminalError": "Chyba: {error}", + "sshConnectionClosedCode": "Připojení bylo uzavřeno (kód {code})", + "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í", + "vncPasswordOptional": "Heslo (nepovinné)", + "vncNoResourceTarget": "Není k dispozici žádný cíl zdroje", + "vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC", + "vncAuthFailedStatus": "Stav {status}", + "vncPasteClipboard": "Vložit schránku", + "rdpTitle": "RDP", + "rdpSignInTitle": "Přihlásit se k Vzdálené ploše", + "rdpSignInDescription": "Zadejte přihlašovací údaje pro Windows k připojení", + "rdpLoadingModule": "Načítá se modul...", + "rdpFailedToLoadModule": "Nepodařilo se načíst modul RDP", + "rdpNotReady": "Není připraveno", + "rdpModuleInitializing": "Modul RDP se stále inicializuje", + "rdpDownloadingFiles": "Stahuje se {count} soubor(y) z dálky...", + "rdpDownloadFailed": "Stažení se nezdařilo: {fileName}", + "rdpUploaded": "Nahráno: {fileName}", + "rdpNoConnectionTarget": "Žádný dostupný cíl připojení", + "rdpConnectionFailed": "Připojení se nezdařilo", + "rdpFit": "Přizpůsobit", + "rdpFull": "Celé", + "rdpReal": "Skutečný", + "rdpMeta": "Meta", + "rdpUploadFiles": "Nahrát soubory", + "rdpFilesReadyToPaste": "Soubory připravené ke vložení", + "rdpFilesReadyToPasteDescription": "{count} soubor(y) zkopírován(y) do vzdálené schránky — stiskněte Ctrl+V na vzdálené ploše pro vložení.", + "rdpUploadFailed": "Nahrání selhalo", + "rdpUnicodeKeyboardMode": "Režim Unicode klávesnice", + "sessionToolbarShow": "Zobrazit panel nástrojů", + "sessionToolbarHide": "Skrýt panel nástrojů" } From ce43e717d56665294ccf4635575cd260dba4073d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:44 -0700 Subject: [PATCH 022/264] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 230 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 187 insertions(+), 43 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index e4afce362..87afc5d68 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Private Ressourcen anzeigen", "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", + "siteInstallKubernetesDocsDescription": "Für aktuelle Installationsinformationen zu Kubernetes, siehe docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Für Installationsanweisungen für Advantech-Modems siehe docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard Konfiguration", "WgConfigurationDescription": "Verwenden Sie folgende Konfiguration, um sich mit dem Netzwerk zu verbinden", "operatingSystem": "Betriebssystem", @@ -157,7 +159,7 @@ "shareDeleted": "Link gelöscht", "shareDeletedDescription": "Der Link wurde gelöscht", "shareDelete": "Freigabelink löschen", - "shareDeleteConfirm": "Löschen des Freigabelinks bestätigen", + "shareDeleteConfirm": "Löschung des Freigabelinks bestätigen", "shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?", "shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.", "shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen", "shareTitleOptional": "Titel (optional)", "sharePathOptional": "Pfad (optional)", + "sharePathDescription": "Der Link leitet Benutzer nach der Authentifizierung zu diesem Pfad weiter.", "expireIn": "Läuft ab in", "neverExpire": "Läuft nie ab", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", @@ -201,7 +204,7 @@ "proxyResourceTitle": "Öffentliche Ressourcen verwalten", "proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind", "publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang", - "publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", + "publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS-Proxys, die über einen Webbrowser für jeden im Internet zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextuelle Zugriffsrichtlinien enthalten.", "clientResourceTitle": "Private Ressourcen verwalten", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", "privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen", @@ -209,15 +212,19 @@ "resourcesSearch": "Suche Ressourcen...", "resourceAdd": "Ressource hinzufügen", "resourceErrorDelte": "Fehler beim Löschen der Ressource", - "resourcePoliciesTitle": "Ressourcenrichtlinien verwalten", - "resourcePoliciesAttachedResourcesColumnTitle": "Angehängte Ressourcen", + "resourcePoliciesBannerTitle": "Authentifizierungs- und Zugriffsregeln wiederverwenden", + "resourcePoliciesBannerDescription": "Freigegebene Ressourcenrichtlinien ermöglichen es Ihnen, Authentifizierungsmethoden und Zugriffsregeln einmal zu definieren und sie dann an mehrere öffentliche Ressourcen zu binden. Wenn Sie eine Richtlinie aktualisieren, übernimmt jede verknüpfte Ressource die Änderung automatisch.", + "resourcePoliciesBannerButtonText": "Mehr erfahren", + "resourcePoliciesTitle": "Öffentliche Ressourcen Richtlinien verwalten", + "resourcePoliciesAttachedResourcesColumnTitle": "Ressourcen", "resourcePoliciesAttachedResources": "{count} Ressource(n)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# Ressource} other {# Ressourcen}}", "resourcePoliciesAttachedResourcesEmpty": "keine Ressourcen", - "resourcePoliciesDescription": "Erstellen und verwalten Sie Authentifizierungsrichtlinien, um den Zugang zu Ihren Ressourcen zu steuern", + "resourcePoliciesDescription": "Erstellen und verwalten Sie Authentifizierungsrichtlinien, um den Zugriff auf Ihre öffentlichen Ressourcen zu steuern", "resourcePoliciesSearch": "Richtlinien suchen...", "resourcePoliciesAdd": "Richtlinie hinzufügen", "resourcePoliciesDefaultBadgeText": "Standardrichtlinie", - "resourcePoliciesCreate": "Ressourcenrichtlinie erstellen", + "resourcePoliciesCreate": "Öffentliche Ressourcen Richtlinie erstellen", "resourcePoliciesCreateDescription": "Befolgen Sie die unten stehenden Schritte, um eine neue Richtlinie zu erstellen", "resourcePolicyName": "Richtlinienname", "resourcePolicyNameDescription": "Geben Sie dieser Richtlinie einen Namen, um sie für Ihre Ressourcen zu identifizieren", @@ -274,7 +281,7 @@ "back": "Zurück", "cancel": "Abbrechen", "resourceConfig": "Konfiguration Snippets", - "resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurations-Snippets ein, um die TCP/UDP Ressource einzurichten", + "resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurationsschnipsel ein, um die TCP/UDP-Ressource einzurichten.", "resourceAddEntrypoints": "Traefik: Einstiegspunkte hinzufügen", "resourceExposePorts": "Gerbil: Ports im Docker Compose freigeben", "resourceLearnRaw": "Lernen Sie, wie Sie TCP/UDP Ressourcen konfigurieren", @@ -287,6 +294,8 @@ "labelDelete": "Etikett löschen", "labelAdd": "Etikett hinzufügen", "labelCreateSuccessMessage": "Etikett erfolgreich erstellt", + "labelDuplicateError": "Doppeltes Label", + "labelDuplicateErrorDescription": "Ein Label mit diesem Namen existiert bereits.", "labelEditSuccessMessage": "Etikett erfolgreich bearbeitet", "labelNameField": "Etikettenname", "labelColorField": "Etikettenfarbe", @@ -311,7 +320,7 @@ "rules": "Regeln", "resourceSettingDescription": "Einstellungen für die Ressource konfigurieren", "resourceSetting": "{resourceName} Einstellungen", - "resourcePolicySettingDescription": "Konfigurieren Sie die Einstellungen der Ressourcenrichtlinie", + "resourcePolicySettingDescription": "Richten Sie die Einstellungen für diese öffentliche Ressourcenrichtlinie ein", "resourcePolicySetting": "{policyName} Einstellungen", "alwaysAllow": "Authentifizierung umgehen", "alwaysDeny": "Zugriff blockieren", @@ -719,7 +728,7 @@ "targetSubmit": "Ziel hinzufügen", "targetNoOne": "Diese Ressource hat keine Ziele. Fügen Sie ein Ziel hinzu, um zu konfigurieren, wo Anfragen an das Backend gesendet werden sollen.", "targetNoOneDescription": "Das Hinzufügen von mehr als einem Ziel aktiviert den Lastausgleich.", - "targetsSubmit": "Ziele speichern", + "targetsSubmit": "Einstellungen speichern", "addTarget": "Ziel hinzufügen", "proxyMultiSiteRoundRobinNodeHelp": "Round-Robin-Routing funktioniert nicht zwischen Standorten, die nicht mit demselben Knoten verbunden sind, aber Failover funktioniert.", "targetErrorInvalidIp": "Ungültige IP-Adresse", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Doppelte Regel", "rulesErrorDuplicateDescription": "Eine Regel mit diesen Einstellungen existiert bereits", "rulesErrorInvalidIpAddressRange": "Ungültiger CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Bitte geben Sie einen gültigen CIDR-Wert ein", - "rulesErrorInvalidUrl": "Ungültiger URL-Pfad", - "rulesErrorInvalidUrlDescription": "Bitte geben Sie einen gültigen URL-Pfad-Wert ein", - "rulesErrorInvalidIpAddress": "Ungültige IP", - "rulesErrorInvalidIpAddressDescription": "Bitte geben Sie eine gültige IP-Adresse ein", + "rulesErrorInvalidIpAddressRangeDescription": "Geben Sie einen gültigen CIDR-Bereich ein (z.B., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Ungültiger Pfad", + "rulesErrorInvalidUrlDescription": "Geben Sie einen gültigen URL-Pfad oder ein gültiges Muster ein (z.B., /api/*).", + "rulesErrorInvalidIpAddress": "Ungültige IP-Adresse", + "rulesErrorInvalidIpAddressDescription": "Geben Sie eine gültige IPv4 oder IPv6 Adresse ein.", "rulesErrorUpdate": "Fehler beim Aktualisieren der Regeln", "rulesErrorUpdateDescription": "Beim Aktualisieren der Regeln ist ein Fehler aufgetreten", "rulesUpdated": "Regeln aktivieren", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Geben Sie eine IP-Adresse ein (z.B. 103.21.244.12)", "rulesMatchUrl": "Geben Sie einen URL-Pfad oder -Muster ein (z.B. /api/v1/todos oder /api/v1/*)", "rulesErrorInvalidPriority": "Ungültige Priorität", - "rulesErrorInvalidPriorityDescription": "Bitte geben Sie eine gültige Priorität ein", + "rulesErrorInvalidPriorityDescription": "Geben Sie eine ganze Zahl von 1 oder höher ein.", "rulesErrorDuplicatePriority": "Doppelte Prioritäten", - "rulesErrorDuplicatePriorityDescription": "Bitte geben Sie eindeutige Prioritäten ein", + "rulesErrorDuplicatePriorityDescription": "Jede Regel muss eine eindeutige Prioritätsnummer haben.", + "rulesErrorValidation": "Ungültige Regeln", + "rulesErrorValidationRuleDescription": "Regel {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Wählen Sie einen gültigen Vergleichstyp (Pfad, IP, CIDR, Land, Region oder ASN).", + "rulesErrorValueRequired": "Geben Sie einen Wert für diese Regel ein.", + "rulesErrorInvalidCountry": "Ungültiges Land", + "rulesErrorInvalidCountryDescription": "Wählen Sie ein gültiges Land aus.", + "rulesErrorInvalidAsn": "Ungültiges ASN", + "rulesErrorInvalidAsnDescription": "Geben Sie ein gültiges ASN ein (z.B., AS15169).", "ruleUpdated": "Regeln aktualisiert", "ruleUpdatedDescription": "Regeln erfolgreich aktualisiert", "ruleErrorUpdate": "Operation fehlgeschlagen", "ruleErrorUpdateDescription": "Während des Speichervorgangs ist ein Fehler aufgetreten", "rulesPriority": "Priorität", + "rulesReorderDragHandle": "Ziehen, um die Regelpriorität neu zu ordnen", "rulesAction": "Aktion", "rulesMatchType": "Übereinstimmungstyp", "value": "Wert", @@ -792,7 +810,7 @@ "rulesResource": "Ressourcen-Regelkonfiguration", "rulesResourceDescription": "Regeln konfigurieren, um den Zugriff auf die Ressource zu steuern", "ruleSubmit": "Regel hinzufügen", - "rulesNoOne": "Keine Regeln. Fügen Sie eine Regel über das Formular hinzu.", + "rulesNoOne": "Noch keine Regeln vorhanden.", "rulesOrder": "Regeln werden nach aufsteigender Priorität ausgewertet.", "rulesSubmit": "Regeln speichern", "policyErrorCreate": "Fehler beim Erstellen der Richtlinie", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Ein unerwarteter Fehler ist aufgetreten", "policyCreatedSuccess": "Ressourcenrichtlinie erfolgreich erstellt", "policyUpdatedSuccess": "Ressourcenrichtlinie erfolgreich aktualisiert", - "authMethodsSave": "Authentifizierungsmethoden speichern", + "authMethodsSave": "Einstellungen speichern", + "policyAuthStackTitle": "Authentifizierung", + "policyAuthStackDescription": "Kontrollieren Sie, welche Authentifizierungsmethoden erforderlich sind, um auf diese Ressource zuzugreifen", + "policyAuthOrLogicTitle": "Mehrere Authentifizierungsmethoden aktiv", + "policyAuthOrLogicBanner": "Besucher können sich mit einer der unten aktiven Methoden authentifizieren. Sie müssen nicht alle abschließen.", + "policyAuthMethodActive": "Aktiv", + "policyAuthMethodOff": "Aus", + "policyAuthSsoTitle": "Plattform SSO", + "policyAuthSsoDescription": "Anmeldung über den Identitätsanbieter Ihrer Organisation erforderlich", + "policyAuthSsoSummary": "{idp} · {users} Benutzer, {roles} Rollen", + "policyAuthSsoDefaultIdp": "Standardanbieter", + "policyAuthAddDefaultIdentityProvider": "Standardidentitätsanbieter hinzufügen", + "policyAuthOtherMethodsTitle": "Andere Methoden", + "policyAuthOtherMethodsDescription": "Optionale Methoden, die Besucher anstelle von oder zusammen mit Plattform-SSO verwenden können", + "policyAuthPasscodeTitle": "Passwort", + "policyAuthPasscodeDescription": "Erfordere einen geteilten alphanumerischen Passcode für den Zugriff auf die Ressource", + "policyAuthPasscodeSummary": "Passcode festgelegt", + "policyAuthPincodeTitle": "PIN-Code", + "policyAuthPincodeDescription": "Ein kurzer numerischer Code, der erforderlich ist, um auf die Ressource zuzugreifen", + "policyAuthPincodeSummary": "6-stelliger PIN festgelegt", + "policyAuthEmailTitle": "E-Mail-Whitelist", + "policyAuthEmailDescription": "Erlaubte E-Mail-Adressen mit Einmalpasswörtern", + "policyAuthEmailSummary": "{count} Adressen erlaubt", + "policyAuthEmailOtpCallout": "Durch Aktivieren der E-Mail-Whitelist wird beim Einloggen ein Einmalpasswort an die E-Mail des Besuchers gesendet.", + "policyAuthHeaderAuthTitle": "Grundlegende Header-Authentifizierung", + "policyAuthHeaderAuthDescription": "Überprüfen Sie einen benutzerdefinierten HTTP-Headernamen und -wert bei jeder Anfrage", + "policyAuthHeaderAuthSummary": "Header konfiguriert", + "policyAuthHeaderName": "Header-Name", + "policyAuthHeaderValue": "Erwarteter Wert", + "policyAuthSetPasscode": "Passcode setzen", + "policyAuthSetPincode": "PIN-Code festlegen", + "policyAuthSetEmailWhitelist": "E-Mail-Whitelist festlegen", + "policyAuthSetHeaderAuth": "Grundlegende Header-Authentifizierung festlegen", + "policyAccessRulesTitle": "Zugriffsregeln", + "policyAccessRulesEnableDescription": "Bei Aktivierung werden die Regeln in absteigender Reihenfolge ausgewertet, bis eine als wahr ausgewertet wird.", + "policyAccessRulesFirstMatch": "Regeln werden von oben nach unten ausgewertet. Die erste übereinstimmende Regel bestimmt das Ergebnis.", + "policyAccessRulesHowItWorks": "Regeln vergleichen Anfragen nach Pfad, IP-Adresse, Standort oder anderen Kriterien. Jede Regel wendet eine Aktion an: Authentifizierung umgehen, Zugriff blockieren oder zur Authentifizierung weiterleiten. Wenn keine Regel zutrifft, wird der Verkehr zur Authentifizierung weitergeleitet.", + "policyAccessRulesFallthroughOff": "Wenn Regeln deaktiviert sind, wird der gesamte Verkehr zur Authentifizierung weitergeleitet.", + "policyAccessRulesFallthroughOn": "Wenn keine Regel übereinstimmt, wird der Verkehr zur Authentifizierung weitergeleitet.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Regeln speichern", "resourceErrorCreate": "Fehler beim Erstellen der Ressource", "resourceErrorCreateDescription": "Beim Erstellen der Ressource ist ein Fehler aufgetreten", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "access": "Zugriff", "accessControl": "Zugriffskontrolle", - "shareLink": "{resource} Freigabe-Link", + "shareLink": "{resource} Freigabelink", "resourceSelect": "Ressource auswählen", - "shareLinks": "Freigabe-Links", + "shareLinks": "Teilbare Links", "share": "Teilbare Links", "shareDescription2": "Erstellen Sie teilbare Links zu Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Verfallsdauer des Links beim Erstellen eines Links festlegen.", "shareEasyCreate": "Einfach zu erstellen und zu teilen", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Administratoren haben immer Zugriff auf diese Ressource.", "resourcePolicySelectTitle": "Zugriffsrichtlinie für Ressourcen", "resourcePolicySelectDescription": "Wählen Sie den Ressourcentransfertyp für die Authentifizierung", + "resourcePolicyTypeLabel": "Richtlinientyp", + "resourcePolicyLabel": "Ressourcenrichtlinie", "resourcePolicyInline": "Inline-Ressourcenrichtlinie", "resourcePolicyInlineDescription": "Zugriffsrichtlinie nur für diese Ressource", "resourcePolicyShared": "Geteilte Ressourcenrichtlinie", - "resourcePolicySharedDescription": "Diese Ressource verwendet eine geteilte Richtlinie. Richtlinienebene Einstellungen (Authentifizierungsmethoden, E-Mail-Whitelist) sind gesperrt. Sie können ressourcenspezifische Regeln, Rollen und Benutzer hinzufügen.", + "resourcePolicySharedDescription": "Diese Ressource verwendet eine gemeinsame Richtlinie.", + "sharedPolicy": "Gemeinsame Richtlinie", + "sharedPolicyNoneDescription": "Diese Ressource hat ihre eigene Richtlinie.", + "resourceSharedPolicyOwnDescription": "Diese Ressource hat eigene Authentifizierungs- und Zugriffsregel-Kontrollen.", + "resourceSharedPolicyInheritedDescription": "Diese Ressource erbt von {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Diese Ressource verwendet eine geteilte Richtlinie. Einige Authentifizierungseinstellungen können in dieser Ressource bearbeitet werden, um zur Richtlinie beizutragen. Um die zugrunde liegende Richtlinie zu ändern, müssen Sie zu {policyName} wechseln.", + "resourceSharedPolicyRulesNotice": "Diese Ressource verwendet eine gemeinsame Richtlinie. Einige Zugriffsregeln können in dieser Ressource bearbeitet werden. Um die zugrunde liegende Richtlinie zu ändern, müssen Sie {policyName} bearbeiten.", "resourceUsersRoles": "Zugriffskontrolle", "resourceUsersRolesDescription": "Konfigurieren Sie, welche Benutzer und Rollen diese Ressource besuchen können", "resourceUsersRolesSubmit": "Zugriffskontrollen speichern", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Sichtbarkeit", "resourceVisibilityTitleDescription": "Ressourcensichtbarkeit vollständig aktivieren oder deaktivieren", "resourceGeneral": "Allgemeine Einstellungen", - "resourceGeneralDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diese Ressource", + "resourceGeneralDescription": "Konfigurieren Sie Name, Adresse und Zugriffsrichtlinie für diese Ressource.", + "resourceGeneralDetailsSubsection": "Ressourcendetails", + "resourceGeneralDetailsSubsectionDescription": "Legen Sie den Anzeigenamen, die Kennung und die öffentlich zugängliche Domain für diese Ressource fest.", + "resourceGeneralDetailsSubsectionPortDescription": "Legen Sie den Anzeigenamen, die Kennung und den öffentlichen Port für diese Ressource fest.", + "resourceGeneralPublicAddressSubsection": "Öffentliche Adresse", + "resourceGeneralPublicAddressSubsectionDescription": "Bestimmen Sie, wie Benutzer diese Ressource erreichen.", + "resourceGeneralAuthenticationAccessSubsection": "Authentifizierung und Zugriff", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Wählen Sie, ob diese Ressource ihre eigene Richtlinie verwendet oder von einer gemeinsamen Richtlinie erbt.", "resourceEnable": "Ressource aktivieren", "resourceTransfer": "Ressource übertragen", "resourceTransferDescription": "Diese Ressource auf einen anderen Standort übertragen", @@ -1220,11 +1294,14 @@ "addLabels": "Etiketten hinzufügen", "siteLabelsTab": "Etiketten", "siteLabelsDescription": "Verwalten Sie die mit diesem Standort verbundenen Etiketten.", - "labelsNotFound": "Etiketten nicht gefunden", + "labelsNotFound": "Keine Kennzeichnungen gefunden.", + "labelsEmptyCreateHint": "Beginnen Sie oben zu tippen, um ein Label zu erstellen.", "labelSearch": "Etiketten suchen", + "labelSearchOrCreate": "Suchen oder erstellen Sie ein Label", "accessLabelFilterCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "labelOverflowCount": "+{count, plural, one {# Etikett} other {# Etiketten}}", "accessLabelFilterClear": "Etikettenfilter löschen", + "accessFilterClear": "Filter löschen", "selectColor": "Farbe auswählen", "createNewLabel": "Neues Org-Etikett \"{label}\" erstellen", "inviteInvalidDescription": "Der Einladungslink ist ungültig.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Ressourcen", "sidebarProxyResources": "Öffentlich", "sidebarClientResources": "Privat", - "sidebarPolicies": "Richtlinien", - "sidebarResourcePolicies": "Ressourcen", + "sidebarPolicies": "Gemeinsame Richtlinien", + "sidebarResourcePolicies": "Öffentliche Ressourcen", "sidebarAccessControl": "Zugriffskontrolle", "sidebarLogsAndAnalytics": "Protokolle & Analysen", "sidebarTeam": "Team", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Einladungen", "sidebarRoles": "Rollen", - "sidebarShareableLinks": "Links", + "sidebarShareableLinks": "Teilbare Links", "sidebarApiKeys": "API-Schlüssel", "sidebarProvisioning": "Bereitstellung", "sidebarSettings": "Einstellungen", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blaupausen", "blueprintsLog": "Blaupausen-Protokoll", - "blueprintsDescription": "Frühere Blaupausen-Anwendungen und deren Ergebnisse ansehen", + "blueprintsDescription": "Betrachten Sie vergangene Blueprint-Anwendungen und deren Ergebnisse oder wenden Sie einen neuen Blueprint an", "blueprintAdd": "Blueprint hinzufügen", "blueprintGoBack": "Alle Blueprints ansehen", "blueprintCreate": "Blueprint erstellen", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Docker Blueprint aktivieren", "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprint-Etiketten. Der Socket-Pfad muss dem Site-Connector angegeben werden. Lesen Sie, wie dies in der Dokumentation funktioniert.", "newtAutoUpdate": "Standort-Auto-Update aktivieren", - "newtAutoUpdateDescription": "Wenn aktiviert, aktualisieren sich die Standort-Connectoren automatisch auf die neueste Version, sobald eine neue Version verfügbar ist.", + "newtAutoUpdateDescription": "Wenn aktiviert, werden die Seiten-Connectoren automatisch die neueste Version herunterladen und sich selbst neu starten. Dies kann für jede Seite überschrieben werden.", "siteAutoUpdate": "Standort-Auto-Update", "siteAutoUpdateLabel": "Autoupdate aktivieren", - "siteAutoUpdateDescription": "Steuern Sie, ob der Connector dieses Standorts automatisch die neueste Version herunterlädt.", + "siteAutoUpdateDescription": "Wenn aktiviert, wird der Seiten-Connector automatisch die neueste Version herunterladen und sich selbst neu starten.", "siteAutoUpdateOrgDefault": "Standard der Organisation: {state}", "siteAutoUpdateOverriding": "Organisations-Einstellung überschreiben", "siteAutoUpdateResetToOrg": "Auf Standard der Organisation zurücksetzen", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Kontoeinrichtung abgeschlossen! Willkommen bei Pangolin!", "documentation": "Dokumentation", "saveAllSettings": "Alle Einstellungen speichern", - "saveResourceTargets": "Ziele speichern", - "saveResourceHttp": "Proxy-Einstellungen speichern", - "saveProxyProtocol": "Proxy-Protokolleinstellungen speichern", + "saveResourceTargets": "Einstellungen speichern", + "saveResourceHttp": "Einstellungen speichern", + "saveProxyProtocol": "Einstellungen speichern", "settingsUpdated": "Einstellungen aktualisiert", "settingsUpdatedDescription": "Einstellungen erfolgreich aktualisiert", "settingsErrorUpdate": "Einstellungen konnten nicht aktualisiert werden", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Unbekannt", "healthCheck": "Gesundheits-Check", "configureHealthCheck": "Gesundheits-Check konfigurieren", - "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", + "configureHealthCheckDescription": "Richten Sie die Überwachung für Ihre Resource ein, um sicherzustellen, dass sie immer verfügbar ist", "enableHealthChecks": "Gesundheits-Checks aktivieren", "healthCheckDisabledStateDescription": "Wenn deaktiviert, führt der Standort keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.", "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", "healthScheme": "Methode", "healthSelectScheme": "Methode auswählen", - "healthCheckPortInvalid": "Der Gesundheitskontroll-Port muss zwischen 1 und 65535 liegen", + "healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen", "healthCheckPath": "Pfad", "healthHostname": "IP / Host", "healthPort": "Port", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Gerätegenehmigungen erforderlich", "requireDeviceApprovalDescription": "Benutzer mit dieser Rolle benötigen neue Geräte, die von einem Administrator genehmigt wurden, bevor sie sich verbinden und auf Ressourcen zugreifen können.", "sshSettings": "SSH-Einstellungen", + "sshAccess": "SSH Zugriff", "rdpSettings": "RDP-Einstellungen", "vncSettings": "VNC-Einstellungen", "sshServer": "SSH-Server", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Stellen Sie sicher, dass Ihr Zielhost korrekt konfiguriert ist, um den Auth-Daemon auszuführen, bevor Sie dieses Setup abschließen, andernfalls wird die Bereitstellung fehlschlagen.", "sshDaemonPort": "Daemon-Port", "sshServerDestination": "Serverziel", - "sshServerDestinationDescription": "Konfigurieren Sie das Ziel und den Port des SSH-Servers", + "sshServerDestinationDescription": "Ziel des SSH-Servers konfigurieren", "destination": "Ziel", + "destinationRequired": "Ziel ist erforderlich.", + "domainRequired": "Domain ist erforderlich.", + "proxyPortRequired": "Port ist erforderlich.", + "invalidPathConfiguration": "Ungültige Pfadkonfiguration.", + "invalidRewritePathConfiguration": "Ungültige Neupfad-Konfiguration.", "bgTargetMultiSiteDisclaimer": "Die Auswahl mehrerer Standorte ermöglicht eine widerstandsfähige Weiterleitung und einen Failover für hohe Verfügbarkeit.", "roleAllowSsh": "SSH erlauben", "roleAllowSshAllow": "Erlauben", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.", "sshSudo": "sudo erlauben", "sshSudoCommands": "Sudo-Befehle", - "sshSudoCommandsDescription": "Komma-getrennte Liste von Befehlen, die der Benutzer mit sudo ausführen darf. Es müssen absolute Pfade verwendet werden.", + "sshSudoCommandsDescription": "Liste der Befehle, die der Benutzer mit sudo ausführen darf, durch Kommas, Leerzeichen oder neue Zeilen getrennt. Absolute Pfade müssen verwendet werden.", "sshCreateHomeDir": "Home-Verzeichnis erstellen", "sshUnixGroups": "Unix-Gruppen", - "sshUnixGroupsDescription": "Durch Komma getrennte Unix-Gruppen, um den Benutzer auf dem Zielhost hinzuzufügen.", + "sshUnixGroupsDescription": "Unix-Gruppen, in die der Benutzer auf dem Ziel-Host aufgenommen werden soll, getrennt durch Kommas, Leerzeichen oder neue Zeilen.", + "roleTextFieldPlaceholder": "Werte eingeben oder eine .txt- oder .csv-Datei ablegen", + "roleTextImportTitle": "Von Datei importieren", + "roleTextImportDescription": "Importiere {fileName} in {fieldLabel}.", + "roleTextImportSkipHeader": "Erste Zeile überspringen (Header)", + "roleTextImportOverride": "Vorhandenes ersetzen", + "roleTextImportAppend": "An vorhandenes anfügen", + "roleTextImportMode": "Importmodus", + "roleTextImportPreview": "Vorschau", + "roleTextImportItemCount": "{count, plural, =0 {Keine Elemente zu importieren} one {1 Element zu importieren} other {# Elemente zu importieren}}", + "roleTextImportTotalCount": "{existing} vorhanden + {imported} importiert = {total} gesamt", + "roleTextImportConfirm": "Importieren", + "roleTextImportInvalidFile": "Nicht unterstützter Dateityp", + "roleTextImportInvalidFileDescription": "Nur .txt- und .csv-Dateien werden unterstützt.", + "roleTextImportEmpty": "Keine Elemente in der Datei gefunden", + "roleTextImportEmptyDescription": "Die Datei enthält keine importierbaren Elemente.", "retryAttempts": "Wiederholungsversuche", "expectedResponseCodes": "Erwartete Antwortcodes", "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Proxy-Protokoll aktivieren", "proxyProtocolInfo": "Client-IP-Adressen für TCP-Backends beibehalten", "proxyProtocolVersion": "Proxy-Protokollversion", - "version1": " Version 1 (empfohlen)", + "version1": "Version 1 (Empfohlen)", "version2": "Version 2", - "versionDescription": "Die Version 1 ist textbasiert und unterstützt die Version 2, ist binär und effizienter, aber weniger kompatibel.", + "version1Description": "Textbasiert und weit verbreitet. Sicherstellen, dass das Servers-Transport zur dynamischen Konfiguration hinzugefügt wurde.", + "version2Description": "Binär und effizienter, aber weniger kompatibel. Sicherstellen, dass das Servers-Transport zur dynamischen Konfiguration hinzugefügt wurde.", "warning": "Warnung", "proxyProtocolWarning": "Die Backend-Anwendung muss so konfiguriert sein, dass Proxy-Protokoll-Verbindungen akzeptiert werden. Wenn Ihr Backend das Proxy-Protokoll nicht unterstützt, wird das Aktivieren aller Verbindungen unterbrochen, so dass Sie dies nur aktivieren, wenn Sie wissen, was Sie tun. Stellen Sie sicher, dass Sie Ihr Backend so konfigurieren, dass es Proxy-Protokoll-Header von Traefik vertraut.", "restarting": "Neustarten...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Bestätigung eingeben", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standard Identitätsanbieter", - "defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.", + "defaultIdentityProviderDescription": "Der Benutzer wird automatisch zu diesem Identitätsanbieter für die Authentifizierung weitergeleitet.", "editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen", "editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie", "editInternalResourceDialogAddRoles": "Rollen hinzufügen", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Art des Wartungsmodus", "showMaintenancePage": "Eine Wartungsseite für Besucher anzeigen", "enableMaintenanceMode": "Wartungsmodus aktivieren", + "enableMaintenanceModeDescription": "Bei Aktivierung sehen Besucher eine Wartungsseite anstelle Ihrer Ressource.", "automatic": "Automatisch", "automaticModeDescription": " Wartungsseite nur anzeigen, wenn alle Backend-Ziele deaktiviert oder ungesund sind. Deine Ressource funktioniert normal, solange mindestens ein Ziel gesund ist.", "forced": "Erzwungen", @@ -3082,6 +3182,8 @@ "warning:": "Warnung:", "forcedeModeWarning": "Der gesamte Datenverkehr wird zur Wartungsseite weitergeleitet. Ihre Backend-Ressourcen werden keine Anfragen erhalten.", "pageTitle": "Seitentitel", + "maintenancePageContentSubsection": "Seiteninhalt", + "maintenancePageContentSubsectionDescription": "Passen Sie den auf der Wartungsseite angezeigten Inhalt an", "pageTitleDescription": "Die Hauptüberschrift auf der Wartungsseite", "maintenancePageMessage": "Wartungsmeldung", "maintenancePageMessagePlaceholder": "Wir sind bald wieder da! Unsere Seite wird derzeit planmäßig gewartet.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Sind Sie sicher, dass Sie die Verknüpfung dieses Identitätsanbieters mit dieser Organisation aufheben möchten?", "idpUnassociateDescription": "Alle Benutzer, die mit diesem Identitätsanbieter verbunden sind, werden aus dieser Organisation entfernt, aber der Identitätsanbieter bleibt für andere verbundene Organisationen weiterhin bestehen.", "idpUnassociateConfirm": "Verknüpfung des Identitätsanbieters aufheben bestätigen", + "idpConfirmDeleteAndRemoveMeFromOrg": "LÖSCHE UND ENTFERNE MICH AUS DER ORG", + "idpUnassociateAndRemoveMeFromOrg": "VERBINDUNG LÖSEN UND ENTFERNE MICH AUS DER ORG", "idpUnassociateWarning": "Dies kann für diese Organisation nicht rückgängig gemacht werden.", "idpUnassociatedDescription": "Identitätsanbieter erfolgreich von dieser Organisation gelöst", "idpUnassociateMenu": "Verknüpfung aufheben", @@ -3439,18 +3543,58 @@ "sshConnecting": "Verbindung wird hergestellt…", "sshInitializing": "Initialisieren…", "sshSignInTitle": "Anmelden bei SSH", - "sshSignInDescription": "Geben Sie Ihre SSH-Anmeldedaten ein", + "sshSignInDescription": "Geben Sie Ihre SSH-Anmeldedaten ein, um sich zu verbinden", "sshPasswordTab": "Passwort", "sshPrivateKeyTab": "Privater Schlüssel", "sshPrivateKeyField": "Privater Schlüssel", "sshPrivateKeyDisclaimer": "Ihr privater Schlüssel wird von Pangolin nicht gespeichert oder angezeigt. Alternativ können Sie kurzlebige Zertifikate für nahtlose Authentifizierung mit Ihrer bestehenden Pangolin-Identität verwenden.", "sshLearnMore": "Mehr erfahren", "sshPrivateKeyFile": "Private Schlüsseldatei", - "sshAuthenticate": "Authentifizieren", + "sshAuthenticate": "Verbinden", "sshTerminate": "Beenden", "sshPoweredBy": "Bereitgestellt von", "sshErrorNoTarget": "Kein Ziel angegeben", "sshErrorWebSocket": "WebSocket-Verbindung fehlgeschlagen", "sshErrorAuthFailed": "Authentifizierung fehlgeschlagen", - "sshErrorConnectionClosed": "Verbindung geschlossen, bevor die Authentifizierung abgeschlossen wurde" + "sshErrorConnectionClosed": "Verbindung geschlossen, bevor die Authentifizierung abgeschlossen wurde", + "sitePangolinSshDescription": "Erlaube SSH-Zugriff auf Ressourcen an diesem Standort. Dies kann später geändert werden.", + "browserGatewayNoResourceForDomain": "Keine Ressource für diese Domain gefunden", + "browserGatewayNoTarget": "Kein Ziel", + "browserGatewayConnect": "Verbinden", + "browserGatewayCtrlAltDel": "Strg+Alt+Entf", + "sshErrorSignKeyFailed": "Fehler beim Signieren des SSH-Schlüssels für die PAM-Push-Authentifizierung. Haben Sie sich als Benutzer angemeldet?", + "sshTerminalError": "Fehler: {error}", + "sshConnectionClosedCode": "Verbindung geschlossen (Code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich", + "vncTitle": "VNC", + "vncSignInDescription": "Geben Sie Ihr VNC-Passwort ein, um sich zu verbinden", + "vncPasswordOptional": "Passwort (optional)", + "vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar", + "vncFailedToLoadNovnc": "Fehler beim Laden von noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Zwischenablage einfügen", + "rdpTitle": "RDP", + "rdpSignInTitle": "Anmeldung bei Remote Desktop", + "rdpSignInDescription": "Geben Sie die Windows-Anmeldedaten ein, um sich zu verbinden", + "rdpLoadingModule": "Modul wird geladen...", + "rdpFailedToLoadModule": "Fehler beim Laden des RDP-Moduls", + "rdpNotReady": "Nicht bereit", + "rdpModuleInitializing": "RDP-Modul wird noch initialisiert", + "rdpDownloadingFiles": "Herunterladen von {count} Datei(en) von Remote…", + "rdpDownloadFailed": "Download fehlgeschlagen: {fileName}", + "rdpUploaded": "Hochgeladen: {fileName}", + "rdpNoConnectionTarget": "Kein Verbindungsziel verfügbar", + "rdpConnectionFailed": "Verbindung fehlgeschlagen", + "rdpFit": "Anpassen", + "rdpFull": "Vollständig", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Dateien hochladen", + "rdpFilesReadyToPaste": "Dateien bereit zum Einfügen", + "rdpFilesReadyToPasteDescription": "{count} Datei(en) in die Remote-Zwischenablage kopiert — drücken Sie Strg+V auf dem Remote-Desktop, um einzufügen.", + "rdpUploadFailed": "Upload fehlgeschlagen", + "rdpUnicodeKeyboardMode": "Unicode-Tastaturmodus", + "sessionToolbarShow": "Werkzeugleiste zeigen", + "sessionToolbarHide": "Werkzeugleiste ausblenden" } From 30a6889ba820d9d41e24e6e967ee3d6bf59b422a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:46 -0700 Subject: [PATCH 023/264] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 240 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 192 insertions(+), 48 deletions(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 78018b288..7623e8f64 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Visualizza Risorse Private", "siteInstallNewt": "Installa Newt", "siteInstallNewtDescription": "Esegui Newt sul tuo sistema", + "siteInstallKubernetesDocsDescription": "Per ulteriori informazioni aggiornate sull'installazione di Kubernetes, consulta docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Per le istruzioni sull'installazione del modem Advantech, consulta docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Configurazione WireGuard", "WgConfigurationDescription": "Utilizzare la seguente configurazione per connettersi alla rete", "operatingSystem": "Sistema Operativo", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteInfo": "Informazioni Sito", "status": "Stato", - "shareTitle": "Gestisci Collegamenti Di Condivisione", + "shareTitle": "Gestisci Collegamenti Condivisibili", "shareDescription": "Crea link condivisibili per concedere accesso temporaneo o permanente alle risorse proxy", - "shareSearch": "Cerca link condivisi...", - "shareCreate": "Crea Link Di Condivisione", + "shareSearch": "Cerca collegamenti condivisibili...", + "shareCreate": "Crea Collegamento Condivisibile", "shareErrorDelete": "Impossibile eliminare il link", "shareErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione del link", "shareDeleted": "Link eliminato", "shareDeletedDescription": "Il link è stato eliminato", - "shareDelete": "Elimina Link di Condivisione", - "shareDeleteConfirm": "Conferma Eliminazione Link di Condivisione", + "shareDelete": "Elimina Collegamento Condivisibile", + "shareDeleteConfirm": "Conferma Eliminazione Collegamento Condivisibile", "shareQuestionRemove": "Sei sicuro di voler eliminare questo link di condivisione?", "shareMessageRemove": "Una volta eliminato, il link non funzionerà più e chiunque lo utilizzi perderà l'accesso alla risorsa.", "shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Chiunque con questo link può accedere alla risorsa", "shareTitleOptional": "Titolo (facoltativo)", "sharePathOptional": "Percorso (opzionale)", + "sharePathDescription": "Il link reindirizzerà gli utenti a questo percorso dopo l'autenticazione.", "expireIn": "Scadenza In", "neverExpire": "Nessuna scadenza", "shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", @@ -201,7 +204,7 @@ "proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web", "publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web", - "publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", + "publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "clientResourceTitle": "Gestisci Risorse Private", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", @@ -209,15 +212,19 @@ "resourcesSearch": "Cerca risorse...", "resourceAdd": "Aggiungi Risorsa", "resourceErrorDelte": "Errore nell'eliminare la risorsa", - "resourcePoliciesTitle": "Gestisci Politiche sulle Risorse", - "resourcePoliciesAttachedResourcesColumnTitle": "Risorse collegate", + "resourcePoliciesBannerTitle": "Riutilizza Regole di Autenticazione e Accesso", + "resourcePoliciesBannerDescription": "Le politiche di risorsa condivise ti permettono di definire metodi di autenticazione e regole di accesso una volta, poi di applicarle a più risorse pubbliche. Quando aggiorni una politica, ogni risorsa collegata eredita il cambiamento automaticamente.", + "resourcePoliciesBannerButtonText": "Scopri di più", + "resourcePoliciesTitle": "Gestisci Politiche delle Risorse Pubbliche", + "resourcePoliciesAttachedResourcesColumnTitle": "Risorse", "resourcePoliciesAttachedResources": "{count} risorsa(e)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# risorsa} other {# risorse}}", "resourcePoliciesAttachedResourcesEmpty": "nessuna risorsa", - "resourcePoliciesDescription": "Crea e gestisci le politiche di autenticazione per controllare l'accesso alle tue risorse", + "resourcePoliciesDescription": "Crea e gestisci politiche d'autenticazione per controllare l'accesso alle tue risorse pubbliche", "resourcePoliciesSearch": "Cerca politiche...", "resourcePoliciesAdd": "Aggiungi Politica", "resourcePoliciesDefaultBadgeText": "Politica Predefinita", - "resourcePoliciesCreate": "Crea Politica Risorse", + "resourcePoliciesCreate": "Crea Politica Risorse Pubbliche", "resourcePoliciesCreateDescription": "Segui i passaggi seguenti per creare una nuova politica", "resourcePolicyName": "Nome Politica", "resourcePolicyNameDescription": "Dai un nome a questa politica per identificarla tra le tue risorse", @@ -274,7 +281,7 @@ "back": "Indietro", "cancel": "Annulla", "resourceConfig": "Snippet Di Configurazione", - "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP", + "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP.", "resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint", "resourceExposePorts": "Gerbil: espone le porte in Docker Compose", "resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Elimina Etichetta", "labelAdd": "Aggiungi Etichetta", "labelCreateSuccessMessage": "Etichetta Creata con Successo", + "labelDuplicateError": "Etichetta Duplicata", + "labelDuplicateErrorDescription": "Esiste già un'etichetta con questo nome.", "labelEditSuccessMessage": "Etichetta Modificata con Successo", "labelNameField": "Nome Etichetta", "labelColorField": "Colore Etichetta", @@ -311,7 +320,7 @@ "rules": "Regole", "resourceSettingDescription": "Configura le impostazioni sulla risorsa", "resourceSetting": "Impostazioni {resourceName}", - "resourcePolicySettingDescription": "Configura le impostazioni sulla politica delle risorse", + "resourcePolicySettingDescription": "Configura le impostazioni su questa politica di risorsa pubblica", "resourcePolicySetting": "Impostazioni del sito {policyName}", "alwaysAllow": "Bypass Autenticazione", "alwaysDeny": "Blocca Accesso", @@ -719,7 +728,7 @@ "targetSubmit": "Aggiungi Target", "targetNoOne": "Questa risorsa non ha destinazioni. Aggiungi un obiettivo per configurare dove inviare richieste al backend.", "targetNoOneDescription": "L'aggiunta di più di un target abiliterà il bilanciamento del carico.", - "targetsSubmit": "Salva Target", + "targetsSubmit": "Salva Impostazioni", "addTarget": "Aggiungi Target", "proxyMultiSiteRoundRobinNodeHelp": "Il routing round robin non funzionerà tra siti che non sono connessi allo stesso nodo, ma il failover funzionerà.", "targetErrorInvalidIp": "Indirizzo IP non valido", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Regola duplicata", "rulesErrorDuplicateDescription": "Esiste già una regola con queste impostazioni", "rulesErrorInvalidIpAddressRange": "CIDR non valido", - "rulesErrorInvalidIpAddressRangeDescription": "Inserisci un valore CIDR valido", - "rulesErrorInvalidUrl": "Percorso URL non valido", - "rulesErrorInvalidUrlDescription": "Inserisci un valore di percorso URL valido", - "rulesErrorInvalidIpAddress": "IP non valido", - "rulesErrorInvalidIpAddressDescription": "Inserisci un indirizzo IP valido", + "rulesErrorInvalidIpAddressRangeDescription": "Inserisci un intervallo CIDR valido (es., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Percorso non valido", + "rulesErrorInvalidUrlDescription": "Inserisci un percorso URL valido o un pattern (es., /api/*).", + "rulesErrorInvalidIpAddress": "Indirizzo IP non valido", + "rulesErrorInvalidIpAddressDescription": "Inserisci un indirizzo IPv4 o IPv6 valido.", "rulesErrorUpdate": "Impossibile aggiornare le regole", "rulesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle regole", "rulesUpdated": "Abilita Regole", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "Inserisci un indirizzo in formato CIDR (es. 103.21.244.0/22)", "rulesMatchIpAddress": "Inserisci un indirizzo IP (es. 103.21.244.12)", "rulesMatchUrl": "Inserisci un percorso URL o pattern (es. /api/v1/todos o /api/v1/*)", - "rulesErrorInvalidPriority": "Priorità Non Valida", - "rulesErrorInvalidPriorityDescription": "Inserisci una priorità valida", - "rulesErrorDuplicatePriority": "Priorità Duplicate", - "rulesErrorDuplicatePriorityDescription": "Inserisci priorità uniche", + "rulesErrorInvalidPriority": "Priorità non valida", + "rulesErrorInvalidPriorityDescription": "Inserisci un numero intero di 1 o superiore.", + "rulesErrorDuplicatePriority": "Priorità duplicate", + "rulesErrorDuplicatePriorityDescription": "Ogni regola deve avere un numero di priorità univoco.", + "rulesErrorValidation": "Regole non valide", + "rulesErrorValidationRuleDescription": "Regola {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Seleziona un tipo di corrispondenza valido (percorso, IP, CIDR, paese, regione o ASN).", + "rulesErrorValueRequired": "Inserisci un valore per questa regola.", + "rulesErrorInvalidCountry": "Nazione non valida", + "rulesErrorInvalidCountryDescription": "Seleziona un paese valido.", + "rulesErrorInvalidAsn": "ASN non valido", + "rulesErrorInvalidAsnDescription": "Inserisci un ASN valido (es., AS15169).", "ruleUpdated": "Regole aggiornate", "ruleUpdatedDescription": "Regole aggiornate con successo", "ruleErrorUpdate": "Operazione fallita", "ruleErrorUpdateDescription": "Si è verificato un errore durante il salvataggio", "rulesPriority": "Priorità", + "rulesReorderDragHandle": "Trascina per riorganizzare la priorità delle regole", "rulesAction": "Azione", "rulesMatchType": "Tipo di Corrispondenza", "value": "Valore", @@ -792,7 +810,7 @@ "rulesResource": "Configurazione Regole Risorsa", "rulesResourceDescription": "Configura le regole per controllare l'accesso alla risorsa", "ruleSubmit": "Aggiungi Regola", - "rulesNoOne": "Nessuna regola. Aggiungi una regola usando il modulo.", + "rulesNoOne": "Nessuna regola ancora.", "rulesOrder": "Le regole sono valutate per priorità in ordine crescente.", "rulesSubmit": "Salva Regole", "policyErrorCreate": "Errore nella creazione della politica", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Si è verificato un errore imprevisto", "policyCreatedSuccess": "Politica risorse creata con successo", "policyUpdatedSuccess": "Politica risorse aggiornata con successo", - "authMethodsSave": "Salva metodi di autenticazione", + "authMethodsSave": "Salva Impostazioni", + "policyAuthStackTitle": "Autenticazione", + "policyAuthStackDescription": "Controlla quali metodi di autenticazione sono richiesti per accedere a questa risorsa", + "policyAuthOrLogicTitle": "Più metodi di autenticazione attivi", + "policyAuthOrLogicBanner": "I visitatori possono autenticarsi utilizzando uno qualsiasi dei metodi attivi sottostanti. Non è necessario completarli tutti.", + "policyAuthMethodActive": "Attivo", + "policyAuthMethodOff": "Disattivo", + "policyAuthSsoTitle": "SSO della Piattaforma", + "policyAuthSsoDescription": "Richiedi l'accesso tramite il provider di identità della tua organizzazione", + "policyAuthSsoSummary": "{idp} · {users} utenti, {roles} ruoli", + "policyAuthSsoDefaultIdp": "Provider predefinito", + "policyAuthAddDefaultIdentityProvider": "Aggiungi Provider di Identità Predefinito", + "policyAuthOtherMethodsTitle": "Altri Metodi", + "policyAuthOtherMethodsDescription": "Metodi opzionali che i visitatori possono utilizzare al posto o insieme al SSO della piattaforma", + "policyAuthPasscodeTitle": "Codice di Accesso", + "policyAuthPasscodeDescription": "Richiedi un codice alfanumerico condiviso per accedere alla risorsa", + "policyAuthPasscodeSummary": "Codice di accesso impostato", + "policyAuthPincodeTitle": "Codice PIN", + "policyAuthPincodeDescription": "Un breve codice numerico richiesto per accedere alla risorsa", + "policyAuthPincodeSummary": "Codice PIN a 6 cifre impostato", + "policyAuthEmailTitle": "Lista Autorizzazioni Email", + "policyAuthEmailDescription": "Consenti indirizzi email elencati con password monouso", + "policyAuthEmailSummary": "{count} indirizzi consentiti", + "policyAuthEmailOtpCallout": "L'abilitazione dell'elenco email invia una password monouso all'email del visitatore durante il login.", + "policyAuthHeaderAuthTitle": "Autenticazione Header Base", + "policyAuthHeaderAuthDescription": "Convalida un nome e un valore di intestazione HTTP personalizzato su ogni richiesta", + "policyAuthHeaderAuthSummary": "Intestazione configurata", + "policyAuthHeaderName": "Nome dell'intestazione", + "policyAuthHeaderValue": "Valore atteso", + "policyAuthSetPasscode": "Imposta Codice di Accesso", + "policyAuthSetPincode": "Imposta Codice PIN", + "policyAuthSetEmailWhitelist": "Imposta Lista Autorizzazioni Email", + "policyAuthSetHeaderAuth": "Imposta Autenticazione Header Base", + "policyAccessRulesTitle": "Regole di Accesso", + "policyAccessRulesEnableDescription": "Quando abilitate, le regole vengono valutate in ordine discendente finché una non è vera.", + "policyAccessRulesFirstMatch": "Le regole sono valutate dall'alto verso il basso. La prima regola corrispondente decide il risultato.", + "policyAccessRulesHowItWorks": "Le regole corrispondono alle richieste per percorso, indirizzo IP, posizione o altri criteri. Ogni regola applica un'azione: bypassa l'autenticazione, blocca l'accesso o passa all'autenticazione. Se nessuna regola corrisponde, il traffico continua all'autenticazione.", + "policyAccessRulesFallthroughOff": "Quando le regole sono disabilitate, tutto il traffico passa all'autenticazione.", + "policyAccessRulesFallthroughOn": "Quando nessuna regola corrisponde, il traffico passa all'autenticazione.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Salva Regole", "resourceErrorCreate": "Errore nella creazione della risorsa", "resourceErrorCreateDescription": "Si è verificato un errore durante la creazione della risorsa", @@ -826,7 +885,7 @@ "accessControl": "Controllo Accessi", "shareLink": "Link di Condivisione {resource}", "resourceSelect": "Seleziona risorsa", - "shareLinks": "Link di Condivisione", + "shareLinks": "Collegamenti Condivisibili", "share": "Link Condivisibili", "shareDescription2": "Crea link condivisibili alle risorse. I link forniscono un accesso temporaneo o illimitato alla tua risorsa. È possibile configurare la durata di scadenza del collegamento quando ne viene creato uno.", "shareEasyCreate": "Facile da creare e condividere", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Gli amministratori possono sempre accedere a questa risorsa.", "resourcePolicySelectTitle": "Politica di Accesso Risorse", "resourcePolicySelectDescription": "Seleziona il tipo di politica delle risorse per l'autenticazione", + "resourcePolicyTypeLabel": "Tipo di politica", + "resourcePolicyLabel": "Politica delle risorse", "resourcePolicyInline": "Politica Inline delle Risorse", "resourcePolicyInlineDescription": "Politica di Accesso limitata solo a questa risorsa", "resourcePolicyShared": "Politica Condivisa delle Risorse", - "resourcePolicySharedDescription": "Questa risorsa utilizza una politica condivisa. Le impostazioni a livello di politica (metodi di autenticazione, email whitelist) sono bloccate. Puoi aggiungere regole, ruoli e utenti specifici per la risorsa di seguito.", + "resourcePolicySharedDescription": "Questa risorsa utilizza una politica condivisa.", + "sharedPolicy": "Politica Condivisa", + "sharedPolicyNoneDescription": "Questa risorsa ha la sua politica.", + "resourceSharedPolicyOwnDescription": "Questa risorsa ha il controllo delle proprie regole di autenticazione e accesso.", + "resourceSharedPolicyInheritedDescription": "Questa risorsa eredita da {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Questa risorsa utilizza una politica condivisa. Alcune impostazioni di autenticazione possono essere modificate su questa risorsa per aggiungerle alla politica. Per cambiare la politica sottostante, devi modificare {policyName}.", + "resourceSharedPolicyRulesNotice": "Questa risorsa utilizza una politica condivisa. Alcune regole di accesso possono essere modificate su questa risorsa. Per cambiare la politica sottostante, devi modificare {policyName}.", "resourceUsersRoles": "Controlli di Accesso", "resourceUsersRolesDescription": "Configura quali utenti e ruoli possono visitare questa risorsa", "resourceUsersRolesSubmit": "Salva Controlli di Accesso", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Visibilità", "resourceVisibilityTitleDescription": "Abilita o disabilita completamente la visibilità della risorsa", "resourceGeneral": "Impostazioni Generali", - "resourceGeneralDescription": "Configura le impostazioni generali per questa risorsa", + "resourceGeneralDescription": "Configura nome, indirizzo e politica di accesso per questa risorsa.", + "resourceGeneralDetailsSubsection": "Dettagli Risorsa", + "resourceGeneralDetailsSubsectionDescription": "Imposta il nome visualizzato, l'identificatore e il dominio pubblicamente accessibile per questa risorsa.", + "resourceGeneralDetailsSubsectionPortDescription": "Imposta il nome visualizzato, l'identificatore e la porta pubblica per questa risorsa.", + "resourceGeneralPublicAddressSubsection": "Indirizzo Pubblico", + "resourceGeneralPublicAddressSubsectionDescription": "Configura come gli utenti raggiungono questa risorsa.", + "resourceGeneralAuthenticationAccessSubsection": "Autenticazione e Accesso", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Scegli se questa risorsa utilizza la sua politica o eredita da una politica condivisa.", "resourceEnable": "Abilita Risorsa", "resourceTransfer": "Trasferisci Risorsa", "resourceTransferDescription": "Trasferisci questa risorsa a un sito diverso", @@ -1220,11 +1294,14 @@ "addLabels": "Aggiungi etichette", "siteLabelsTab": "Etichette", "siteLabelsDescription": "Gestisci le etichette associate a questo sito.", - "labelsNotFound": "Etichette non trovate", + "labelsNotFound": "Nessuna etichetta trovata.", + "labelsEmptyCreateHint": "Inizia a digitare sopra per creare un'etichetta.", "labelSearch": "Cerca etichette", + "labelSearchOrCreate": "Cerca o crea un'etichetta", "accessLabelFilterCount": "{count, plural, one {# etichetta} other {# etichette}}", "labelOverflowCount": "+{count, plural, one {# etichetta} other {# etichette}}", "accessLabelFilterClear": "Cancella filtri etichette", + "accessFilterClear": "Cancella filtri", "selectColor": "Seleziona colore", "createNewLabel": "Crea nuova etichetta dell'organizzazione \"{label}\"", "inviteInvalidDescription": "Il link di invito non è valido.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Risorse", "sidebarProxyResources": "Pubblico", "sidebarClientResources": "Privato", - "sidebarPolicies": "Politiche", - "sidebarResourcePolicies": "Risorse", + "sidebarPolicies": "Politiche Condivise", + "sidebarResourcePolicies": "Risorse Pubbliche", "sidebarAccessControl": "Controllo Accesso", "sidebarLogsAndAnalytics": "Registri E Analisi", "sidebarTeam": "Squadra", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Amministratore", "sidebarInvitations": "Inviti", "sidebarRoles": "Ruoli", - "sidebarShareableLinks": "Collegamenti", + "sidebarShareableLinks": "Collegamenti Condivisibili", "sidebarApiKeys": "Chiavi API", "sidebarProvisioning": "Accantonamento", "sidebarSettings": "Impostazioni", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Risorsa {id}", "blueprints": "Progetti", "blueprintsLog": "Registro Progetti", - "blueprintsDescription": "Visualizza le applicazioni passate dei progetti e i loro risultati", + "blueprintsDescription": "Visualizza le applicazioni blueprint passate e i loro risultati o applica un nuovo blueprint", "blueprintAdd": "Aggiungi Progetto", "blueprintGoBack": "Vedi tutti i progetti", "blueprintCreate": "Crea Progetto", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Abilita Progetto Docker", "enableDockerSocketDescription": "Abilita lo scraping delle etichette Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito al connettore del sito. Leggi come funziona nel documentazione.", "newtAutoUpdate": "Abilita Aggiornamento Automatico del Sito", - "newtAutoUpdateDescription": "Quando abilitato, i connettori di sito si aggiorneranno automaticamente all'ultima versione quando è disponibile un nuovo rilascio.", + "newtAutoUpdateDescription": "Quando abilitati, i connettori del sito scaricheranno automaticamente l'ultima versione e si riavvieranno. Questo può essere sovrascritto caso per caso.", "siteAutoUpdate": "Aggiornamento Automatico del Sito", "siteAutoUpdateLabel": "Abilita Aggiornamento Automatico", - "siteAutoUpdateDescription": "Controlla se il connettore di questo sito scarica automaticamente l'ultima versione.", + "siteAutoUpdateDescription": "Quando abilitato, il connettore di questo sito scaricherà automaticamente l'ultima versione e si riavvierà.", "siteAutoUpdateOrgDefault": "Predefinito dell'organizzazione: {state}", "siteAutoUpdateOverriding": "Sovrascrivere le impostazioni dell'organizzazione", "siteAutoUpdateResetToOrg": "Reimposta al Predefinito dell'Organizzazione", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Configurazione dell'account completata! Benvenuto su Pangolin!", "documentation": "Documentazione", "saveAllSettings": "Salva Tutte le Impostazioni", - "saveResourceTargets": "Salva Target", - "saveResourceHttp": "Salva Impostazioni Proxy", - "saveProxyProtocol": "Salva impostazioni protocollo proxy", + "saveResourceTargets": "Salva Impostazioni", + "saveResourceHttp": "Salva Impostazioni", + "saveProxyProtocol": "Salva Impostazioni", "settingsUpdated": "Impostazioni aggiornate", "settingsUpdatedDescription": "Impostazioni aggiornate con successo", "settingsErrorUpdate": "Impossibile aggiornare le impostazioni", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Sconosciuto", "healthCheck": "Controllo Salute", "configureHealthCheck": "Configura Controllo Salute", - "configureHealthCheckDescription": "Imposta il monitoraggio della salute per {target}", + "configureHealthCheckDescription": "Imposta il monitoraggio per la tua risorsa per assicurarti che sia sempre disponibile", "enableHealthChecks": "Abilita i Controlli di Salute", "healthCheckDisabledStateDescription": "Quando disabilitato, il sito non eseguirà controlli di integrità e lo stato sarà considerato sconosciuto.", "enableHealthChecksDescription": "Monitorare lo stato di salute di questo obiettivo. Se necessario, è possibile monitorare un endpoint diverso da quello del bersaglio.", "healthScheme": "Metodo", "healthSelectScheme": "Seleziona Metodo", - "healthCheckPortInvalid": "La porta di controllo dello stato di salute deve essere compresa tra 1 e 65535", + "healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535", "healthCheckPath": "Percorso", "healthHostname": "IP / Nome host", "healthPort": "Porta", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Richiede Approvazioni Dispositivo", "requireDeviceApprovalDescription": "Gli utenti con questo ruolo hanno bisogno di nuovi dispositivi approvati da un amministratore prima di poter connettersi e accedere alle risorse.", "sshSettings": "Impostazioni SSH", + "sshAccess": "Accesso SSH", "rdpSettings": "Impostazioni RDP", "vncSettings": "Impostazioni VNC", "sshServer": "Server SSH", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Assicurati che l'host target sia correttamente configurato per eseguire il demone di autenticazione prima di completare questa configurazione, altrimenti il provisioning fallirà.", "sshDaemonPort": "Porta Daemon", "sshServerDestination": "Destinazione Server", - "sshServerDestinationDescription": "Configura la destinazione e la porta del server SSH", + "sshServerDestinationDescription": "Configura la destinazione del server SSH", "destination": "Destinazione", + "destinationRequired": "La destinazione è obbligatoria.", + "domainRequired": "Il dominio è obbligatorio.", + "proxyPortRequired": "La porta è obbligatoria.", + "invalidPathConfiguration": "Configurazione percorso non valida.", + "invalidRewritePathConfiguration": "Configurazione percorso di riscrittura non valida.", "bgTargetMultiSiteDisclaimer": "Selezionare più siti abilita instradamento resiliente e failover per alta disponibilità.", "roleAllowSsh": "Consenti SSH", "roleAllowSshAllow": "Consenti", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.", "sshSudo": "Consenti sudo", "sshSudoCommands": "Comandi Sudo", - "sshSudoCommandsDescription": "Elenco separato da virgole di comandi che l'utente è autorizzato a eseguire con sudo. Devono essere utilizzati percorsi assoluti.", + "sshSudoCommandsDescription": "Elenco di comandi che l'utente è autorizzato ad eseguire con sudo, separati da virgole, spazi o nuove righe. Devono essere utilizzati percorsi assoluti.", "sshCreateHomeDir": "Crea Cartella Home", "sshUnixGroups": "Gruppi Unix", - "sshUnixGroupsDescription": "Gruppi Unix separati da virgole per aggiungere l'utente sull'host di destinazione.", + "sshUnixGroupsDescription": "Gruppi Unix a cui aggiungere l'utente sull'host di destinazione, separati da virgole, spazi o nuove righe.", + "roleTextFieldPlaceholder": "Inserisci i valori o rilascia un file .txt o .csv", + "roleTextImportTitle": "Importa da File", + "roleTextImportDescription": "Importazione di {fileName} in {fieldLabel}.", + "roleTextImportSkipHeader": "Ignora Prima Riga (Intestazione)", + "roleTextImportOverride": "Sostituisci Esistente", + "roleTextImportAppend": "Aggiungi a Esistente", + "roleTextImportMode": "Modalità Importazione", + "roleTextImportPreview": "Anteprima", + "roleTextImportItemCount": "{count, plural, =0 {Nessun elemento da importare} one {1 elemento da importare} other {# elementi da importare}}", + "roleTextImportTotalCount": "{existing} esistente + {imported} importato = {total} totale", + "roleTextImportConfirm": "Importa", + "roleTextImportInvalidFile": "Tipo di file non supportato", + "roleTextImportInvalidFileDescription": "Sono supportati solo file .txt e .csv.", + "roleTextImportEmpty": "Nessun elemento trovato nel file", + "roleTextImportEmptyDescription": "Il file non contiene elementi importabili.", "retryAttempts": "Tentativi di Riprova", "expectedResponseCodes": "Codici di Risposta Attesi", "expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Abilita Protocollo Proxy", "proxyProtocolInfo": "Conserva gli indirizzi IP del client per i backend TCP", "proxyProtocolVersion": "Versione Protocollo Proxy", - "version1": " Versione 1 (Consigliato)", + "version1": "Versione 1 (Consigliato)", "version2": "Versione 2", - "versionDescription": "La versione 1 è testuale e ampiamente supportata. La versione 2 è binaria e più efficiente, ma meno compatibile.", + "version1Description": "Testuale e ampiamente supportata. Assicurati che il trasporto server sia aggiunto alla configurazione dinamica.", + "version2Description": "Binaria e più efficiente ma meno compatibile. Assicurati che il trasporto server sia aggiunto alla configurazione dinamica.", "warning": "Attenzione", "proxyProtocolWarning": "L'applicazione backend deve essere configurata per accettare le connessioni del protocollo proxy. Se il tuo backend non supporta il protocollo proxy, abilitarlo interromperà tutte le connessioni, quindi attivalo solo se sai cosa stai facendo. Assicurati di configurare il tuo backend per fidarti delle intestazioni del protocollo proxy da Traefik.", "restarting": "Riavvio...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Inserisci conferma", "blueprintViewDetails": "Dettagli", "defaultIdentityProvider": "Provider di Identità Predefinito", - "defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.", + "defaultIdentityProviderDescription": "L'utente verrà automaticamente reindirizzato a questo provider di identità per l'autenticazione.", "editInternalResourceDialogNetworkSettings": "Impostazioni di Rete", "editInternalResourceDialogAccessPolicy": "Politica di Accesso", "editInternalResourceDialogAddRoles": "Aggiungi Ruoli", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Tipo di Modalità di Manutenzione", "showMaintenancePage": "Mostra una pagina di manutenzione ai visitatori", "enableMaintenanceMode": "Abilita Modalità di Manutenzione", + "enableMaintenanceModeDescription": "Quando abilitato, i visitatori vedranno una pagina di manutenzione invece della tua risorsa.", "automatic": "Automatico", "automaticModeDescription": "Mostra pagina di manutenzione solo quando tutti i target del backend sono inattivi o non in salute. La tua risorsa continua a funzionare normalmente finché almeno un target è in salute.", "forced": "Forzato", @@ -3082,6 +3182,8 @@ "warning:": "Avviso:", "forcedeModeWarning": "Tutto il traffico verrà indirizzato alla pagina di manutenzione. Le risorse del tuo backend non riceveranno richieste.", "pageTitle": "Titolo Pagina", + "maintenancePageContentSubsection": "Contenuto della Pagina", + "maintenancePageContentSubsectionDescription": "Personalizza il contenuto visualizzato sulla pagina di manutenzione", "pageTitleDescription": "L'intestazione principale visualizzata sulla pagina di manutenzione", "maintenancePageMessage": "Messaggio di Manutenzione", "maintenancePageMessagePlaceholder": "Torneremo presto! Il nostro sito è attualmente in manutenzione programmata.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Sei sicuro di voler disassociare questo provider di identità da questa organizzazione?", "idpUnassociateDescription": "Tutti gli utenti associati a questo provider di identità verranno rimossi da questa organizzazione, ma il provider di identità continuerà ad esistere per altre organizzazioni associate.", "idpUnassociateConfirm": "Conferma Disassociazione Provider di Identità", + "idpConfirmDeleteAndRemoveMeFromOrg": "CANCELLA E RIMUOVIMI DALL'ORGANIZZAZIONE", + "idpUnassociateAndRemoveMeFromOrg": "DISASSOCIA E RIMUOVIMI DALL'ORGANIZZAZIONE", "idpUnassociateWarning": "Questo non può essere annullato per questa organizzazione.", "idpUnassociatedDescription": "Provider di identità disassociato con successo da questa organizzazione", "idpUnassociateMenu": "Disassocia", @@ -3439,18 +3543,58 @@ "sshConnecting": "Connessione…", "sshInitializing": "Inizializzazione…", "sshSignInTitle": "Accedi a SSH", - "sshSignInDescription": "Inserisci le tue credenziali SSH", + "sshSignInDescription": "Inserisci le tue credenziali SSH per connetterti", "sshPasswordTab": "Password", "sshPrivateKeyTab": "Chiave Privata", "sshPrivateKeyField": "Chiave Privata", "sshPrivateKeyDisclaimer": "La tua chiave privata non è memorizzata o visibile a Pangolin. In alternativa, puoi utilizzare certificati a vita breve per un'autenticazione continua utilizzando la tua identità Pangolin esistente.", "sshLearnMore": "Scopri di più", "sshPrivateKeyFile": "File Chiave Privata", - "sshAuthenticate": "Autentica", + "sshAuthenticate": "Connetti", "sshTerminate": "Termina", "sshPoweredBy": "Offerto da", "sshErrorNoTarget": "Nessun obiettivo specificato", "sshErrorWebSocket": "Connessione WebSocket fallita", "sshErrorAuthFailed": "Autenticazione fallita", - "sshErrorConnectionClosed": "Connessione chiusa prima del completamento dell'autenticazione" + "sshErrorConnectionClosed": "Connessione chiusa prima del completamento dell'autenticazione", + "sitePangolinSshDescription": "Consenti l'accesso SSH alle risorse su questo sito. Questo può essere modificato in seguito.", + "browserGatewayNoResourceForDomain": "Nessuna risorsa trovata per questo dominio", + "browserGatewayNoTarget": "Nessun bersaglio", + "browserGatewayConnect": "Connetti", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Canc", + "sshErrorSignKeyFailed": "Impossibile firmare la chiave SSH per l'autenticazione push PAM. Ti sei autenticato come utente?", + "sshTerminalError": "Errore: {error}", + "sshConnectionClosedCode": "Connessione chiusa (codice {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "È richiesta una chiave privata", + "vncTitle": "VNC", + "vncSignInDescription": "Inserisci la tua password VNC per connetterti", + "vncPasswordOptional": "Password (opzionale)", + "vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile", + "vncFailedToLoadNovnc": "Impossibile caricare noVNC", + "vncAuthFailedStatus": "Stato {status}", + "vncPasteClipboard": "Incolla appunti", + "rdpTitle": "RDP", + "rdpSignInTitle": "Accedi al Desktop Remoto", + "rdpSignInDescription": "Inserisci le credenziali di Windows per connetterti", + "rdpLoadingModule": "Caricamento modulo...", + "rdpFailedToLoadModule": "Impossibile caricare il modulo RDP", + "rdpNotReady": "Non pronto", + "rdpModuleInitializing": "Il modulo RDP è ancora in inizializzazione", + "rdpDownloadingFiles": "Scaricamento di {count} file(s) da remoto…", + "rdpDownloadFailed": "Download fallito: {fileName}", + "rdpUploaded": "Caricato: {fileName}", + "rdpNoConnectionTarget": "Nessun bersaglio di connessione disponibile", + "rdpConnectionFailed": "Connessione fallita", + "rdpFit": "Adatta", + "rdpFull": "Completo", + "rdpReal": "Reale", + "rdpMeta": "Meta", + "rdpUploadFiles": "Carica file", + "rdpFilesReadyToPaste": "File pronti per essere incollati", + "rdpFilesReadyToPasteDescription": "{count} file(s) copiati negli appunti remoti — premi Ctrl+V sul desktop remoto per incollare.", + "rdpUploadFailed": "Caricamento fallito", + "rdpUnicodeKeyboardMode": "Modalità tastiera Unicode", + "sessionToolbarShow": "Mostra barra degli strumenti", + "sessionToolbarHide": "Nascondi barra degli strumenti" } From ef0575cc3667859571f118621128b41e3bc1174c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:48 -0700 Subject: [PATCH 024/264] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 242 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 49 deletions(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 43213d093..f3829dd16 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "개인 리소스 보기", "siteInstallNewt": "Newt 설치", "siteInstallNewtDescription": "시스템에서 Newt 실행하기", + "siteInstallKubernetesDocsDescription": "더 많은 정보와 최신의 쿠버네티스 설치 정보를 보려면 docs.pangolin.net/manage/sites/install-kubernetes를 참조하세요.", + "siteInstallAdvantechDocsDescription": "Advantech 모뎀 설치 지침은 docs.pangolin.net/manage/sites/install-advantech을 참조하세요.", "WgConfiguration": "WireGuard 구성", "WgConfigurationDescription": "네트워크에 연결하기 위한 다음 구성을 사용하세요.", "operatingSystem": "운영 체제", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "siteInfo": "사이트 정보", "status": "상태", - "shareTitle": "공유 링크 관리", + "shareTitle": "공유 가능한 링크 관리", "shareDescription": "공유 가능한 링크를 생성하여 프록시 리소스에 임시 또는 영구적으로 액세스하세요.", - "shareSearch": "공유 링크 검색...", - "shareCreate": "공유 링크 생성", + "shareSearch": "공유 가능한 링크 검색...", + "shareCreate": "공유 가능한 링크 생성", "shareErrorDelete": "링크 삭제에 실패했습니다.", "shareErrorDeleteMessage": "링크 삭제 중 오류가 발생했습니다.", "shareDeleted": "링크가 삭제되었습니다.", "shareDeletedDescription": "링크가 삭제되었습니다.", - "shareDelete": "공유 링크 삭제", - "shareDeleteConfirm": "공유 링크 삭제 확인", + "shareDelete": "공유 가능한 링크 삭제", + "shareDeleteConfirm": "공유 가능한 링크 삭제 확인", "shareQuestionRemove": "이 공유 링크를 삭제하시겠습니까?", "shareMessageRemove": "삭제되면 링크가 더 이상 작동하지 않으며, 이를 사용하는 모든 사용자는 자원에 대한 접근을 잃게 됩니다.", "shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.", @@ -177,6 +179,7 @@ "shareCreateDescription": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다.", "shareTitleOptional": "제목 (선택 사항)", "sharePathOptional": "경로 (선택 사항)", + "sharePathDescription": "링크는 인증 후 이 경로로 사용자를 리디렉션합니다.", "expireIn": "만료됨", "neverExpire": "만료되지 않음", "shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "리소스를 선택하세요", "proxyResourceTitle": "공개 리소스 관리", "proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.", - "publicResourcesBannerTitle": "웹 기반 공공 접근", - "publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", + "publicResourcesBannerTitle": "웹 기반의 공개 액세스", + "publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", "clientResourceTitle": "개인 리소스 관리", "clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.", "privateResourcesBannerTitle": "제로 트러스트 개인 접근", @@ -209,15 +212,19 @@ "resourcesSearch": "리소스 검색...", "resourceAdd": "리소스 추가", "resourceErrorDelte": "리소스 삭제 중 오류 발생", - "resourcePoliciesTitle": "리소스 정책 관리", - "resourcePoliciesAttachedResourcesColumnTitle": "첨부 리소스", + "resourcePoliciesBannerTitle": "인증 및 액세스 규칙 재사용", + "resourcePoliciesBannerDescription": "공유 리소스 정책을 사용하면 한 번 인증 방법 및 액세스 규칙을 정의하고, 여러 공개 리소스에 첨부할 수 있습니다. 정책을 업데이트하면 모든 연결된 리소스가 자동으로 변경 사항을 상속받습니다.", + "resourcePoliciesBannerButtonText": "자세히 알아보기", + "resourcePoliciesTitle": "공개 리소스 정책 관리", + "resourcePoliciesAttachedResourcesColumnTitle": "리소스", "resourcePoliciesAttachedResources": "{count} 리소스", + "resourcePoliciesAttachedResourcesCount": "{count, plural, other {# 자원}}", "resourcePoliciesAttachedResourcesEmpty": "리소스 없음", - "resourcePoliciesDescription": "리소스에 대한 접근을 제어할 인증 정책을 생성 및 관리합니다", + "resourcePoliciesDescription": "공개 리소스에 대한 인증 정책을 생성하고 관리하여 접근을 제어합니다", "resourcePoliciesSearch": "정책 검색...", "resourcePoliciesAdd": "정책 추가", "resourcePoliciesDefaultBadgeText": "기본 정책", - "resourcePoliciesCreate": "리소스 정책 생성", + "resourcePoliciesCreate": "공개 리소스 정책 생성", "resourcePoliciesCreateDescription": "새로운 정책을 생성하려면 아래 단계들을 따르세요", "resourcePolicyName": "정책 이름", "resourcePolicyNameDescription": "이 정책에 리소스 간에 식별할 이름을 지정합니다", @@ -274,7 +281,7 @@ "back": "뒤로", "cancel": "취소", "resourceConfig": "구성 스니펫", - "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣습니다.", + "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣으세요.", "resourceAddEntrypoints": "Traefik: 엔트리포인트 추가", "resourceExposePorts": "Gerbil: Docker Compose에서 포트 노출", "resourceLearnRaw": "TCP/UDP 리소스 구성 방법 알아보기", @@ -287,6 +294,8 @@ "labelDelete": "레이블 삭제", "labelAdd": "레이블 추가", "labelCreateSuccessMessage": "레이블이 성공적으로 생성되었습니다", + "labelDuplicateError": "중복 레이블", + "labelDuplicateErrorDescription": "이 이름의 레이블이 이미 존재합니다.", "labelEditSuccessMessage": "레이블이 성공적으로 수정되었습니다", "labelNameField": "레이블 이름", "labelColorField": "레이블 색상", @@ -311,7 +320,7 @@ "rules": "규칙", "resourceSettingDescription": "리소스의 설정을 구성하세요.", "resourceSetting": "{resourceName} 설정", - "resourcePolicySettingDescription": "리소스 정책에 대한 설정을 구성합니다", + "resourcePolicySettingDescription": "이 공개 리소스 정책의 설정을 구성하세요", "resourcePolicySetting": "{policyName} 설정", "alwaysAllow": "인증 우회", "alwaysDeny": "접근 차단", @@ -719,7 +728,7 @@ "targetSubmit": "대상 추가", "targetNoOne": "이 리소스에는 대상이 없습니다. 백엔드로 요청을 보낼 대상을 구성하려면 대상을 추가하세요.", "targetNoOneDescription": "위에 하나 이상의 대상을 추가하면 로드 밸런싱이 활성화됩니다.", - "targetsSubmit": "대상 저장", + "targetsSubmit": "설정 저장", "addTarget": "대상 추가", "proxyMultiSiteRoundRobinNodeHelp": "라운드 로빈 라우팅은 동일한 노드에 연결되지 않은 사이트 간에는 작동하지 않으나, 대체 라우팅은 작동합니다.", "targetErrorInvalidIp": "유효하지 않은 IP 주소", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "중복 규칙", "rulesErrorDuplicateDescription": "이 설정을 가진 규칙이 이미 존재합니다.", "rulesErrorInvalidIpAddressRange": "유효하지 않은 CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "유효한 CIDR 값을 입력하십시오.", - "rulesErrorInvalidUrl": "유효하지 않은 URL 경로", - "rulesErrorInvalidUrlDescription": "유효한 URL 경로 값을 입력해 주세요.", - "rulesErrorInvalidIpAddress": "유효하지 않은 IP", - "rulesErrorInvalidIpAddressDescription": "유효한 IP 주소를 입력하세요", + "rulesErrorInvalidIpAddressRangeDescription": "유효한 CIDR 범위를 입력하세요 (예: 10.0.0.0/8).", + "rulesErrorInvalidUrl": "유효하지 않은 경로", + "rulesErrorInvalidUrlDescription": "유효한 URL 경로 또는 패턴을 입력하세요 (예: /api/*).", + "rulesErrorInvalidIpAddress": "유효하지 않은 IP 주소", + "rulesErrorInvalidIpAddressDescription": "유효한 IPv4 또는 IPv6 주소를 입력하세요.", "rulesErrorUpdate": "규칙 업데이트에 실패했습니다.", "rulesErrorUpdateDescription": "규칙 업데이트 중 오류가 발생했습니다.", "rulesUpdated": "규칙 활성화", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "IP 주소를 입력하세요 (예: 103.21.244.12)", "rulesMatchUrl": "URL 경로 또는 패턴을 입력하세요 (예: /api/v1/todos 또는 /api/v1/*)", "rulesErrorInvalidPriority": "유효하지 않은 우선순위", - "rulesErrorInvalidPriorityDescription": "유효한 우선 순위를 입력하세요.", - "rulesErrorDuplicatePriority": "중복 우선순위", - "rulesErrorDuplicatePriorityDescription": "고유한 우선 순위를 입력하십시오.", + "rulesErrorInvalidPriorityDescription": "1 이상의 정수를 입력하세요.", + "rulesErrorDuplicatePriority": "중복된 우선순위", + "rulesErrorDuplicatePriorityDescription": "각 규칙은 고유한 우선순위 번호를 가져야 합니다.", + "rulesErrorValidation": "유효하지 않은 규칙", + "rulesErrorValidationRuleDescription": "규칙 {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "유효한 매칭 유형을 선택하세요 (경로, IP, CIDR, 국가, 지역, 또는 ASN).", + "rulesErrorValueRequired": "이 규칙에 대한 값을 입력하세요.", + "rulesErrorInvalidCountry": "유효하지 않은 국가", + "rulesErrorInvalidCountryDescription": "유효한 국가를 선택하세요.", + "rulesErrorInvalidAsn": "유효하지 않은 ASN", + "rulesErrorInvalidAsnDescription": "유효한 ASN을 입력하세요 (예: AS15169).", "ruleUpdated": "규칙이 업데이트되었습니다", "ruleUpdatedDescription": "규칙이 성공적으로 업데이트되었습니다", "ruleErrorUpdate": "작업 실패", "ruleErrorUpdateDescription": "저장 작업 중 오류가 발생했습니다.", "rulesPriority": "우선순위", + "rulesReorderDragHandle": "드래그하여 규칙 우선순위 재정렬", "rulesAction": "작업", "rulesMatchType": "일치 유형", "value": "값", @@ -792,7 +810,7 @@ "rulesResource": "리소스 규칙 구성", "rulesResourceDescription": "리소스에 대한 접근을 제어하는 규칙 구성", "ruleSubmit": "규칙 추가", - "rulesNoOne": "규칙이 없습니다. 양식을 사용하여 규칙을 추가하십시오.", + "rulesNoOne": "아직 규칙이 없습니다.", "rulesOrder": "규칙은 우선 순위에 따라 오름차순으로 평가됩니다.", "rulesSubmit": "규칙 저장", "policyErrorCreate": "정책 생성 오류", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "예기치 않은 오류가 발생했습니다", "policyCreatedSuccess": "리소스 정책이 성공적으로 생성되었습니다", "policyUpdatedSuccess": "리소스 정책이 성공적으로 업데이트되었습니다", - "authMethodsSave": "인증 방법 저장", + "authMethodsSave": "설정 저장", + "policyAuthStackTitle": "인증", + "policyAuthStackDescription": "이 리소스에 접근하려면 어떤 인증 방법이 필요한지 제어합니다", + "policyAuthOrLogicTitle": "다수의 인증 방법 활성화", + "policyAuthOrLogicBanner": "방문자는 아래 활성화된 방법 중 하나만을 선택하여 인증할 수 있습니다. 모든 방법을 완료할 필요는 없습니다.", + "policyAuthMethodActive": "활성화", + "policyAuthMethodOff": "비활성화", + "policyAuthSsoTitle": "플랫폼 SSO", + "policyAuthSsoDescription": "사용자의 아이덴티티 공급자를 통해 로그인 필요", + "policyAuthSsoSummary": "{idp} · {users} 사용자, {roles} 역할", + "policyAuthSsoDefaultIdp": "기본 공급자", + "policyAuthAddDefaultIdentityProvider": "기본 아이덴티티 공급자 추가", + "policyAuthOtherMethodsTitle": "기타 방법", + "policyAuthOtherMethodsDescription": "플랫폼 SSO 대신 또는 함께 사용할 수 있는 선택적 방법", + "policyAuthPasscodeTitle": "패스코드", + "policyAuthPasscodeDescription": "리소스 접근을 위한 공유 알파벳 및 숫자 패스코드 필요", + "policyAuthPasscodeSummary": "패스코드 설정됨", + "policyAuthPincodeTitle": "PIN 코드", + "policyAuthPincodeDescription": "리소스 접근에 필요한 짧은 숫자 코드", + "policyAuthPincodeSummary": "6자리 PIN 코드 설정됨", + "policyAuthEmailTitle": "이메일 화이트리스트", + "policyAuthEmailDescription": "허용된 이메일 주소로 일회용 비밀번호 전송", + "policyAuthEmailSummary": "{count}개의 주소 허용됨", + "policyAuthEmailOtpCallout": "이메일 화이트리스트를 활성화하면 로그인 시 방문자의 이메일로 일회용 비밀번호가 전송됩니다.", + "policyAuthHeaderAuthTitle": "기본 헤더 인증", + "policyAuthHeaderAuthDescription": "각 요청에서 맞춤 HTTP 헤더 이름 및 값을 검증", + "policyAuthHeaderAuthSummary": "헤더 구성됨", + "policyAuthHeaderName": "헤더 이름", + "policyAuthHeaderValue": "예상 값", + "policyAuthSetPasscode": "패스코드 설정", + "policyAuthSetPincode": "PIN 코드 설정", + "policyAuthSetEmailWhitelist": "이메일 화이트리스트 설정", + "policyAuthSetHeaderAuth": "기본 헤더 인증 설정", + "policyAccessRulesTitle": "액세스 규칙", + "policyAccessRulesEnableDescription": "활성화되면 규칙은 내림차순으로 평가되며, 하나가 참으로 평가될 때까지 계속됩니다.", + "policyAccessRulesFirstMatch": "규칙은 위에서 아래로 평가됩니다. 첫 번째 매칭 규칙이 결과를 결정합니다.", + "policyAccessRulesHowItWorks": "규칙은 경로, IP 주소, 위치 또는 기타 기준에 따라 요청을 매칭합니다. 각 규칙은 인증 우회, 접근 차단 또는 인증 전송의 액션을 적용합니다. 매칭되는 규칙이 없으면, 트래픽은 인증으로 계속됩니다.", + "policyAccessRulesFallthroughOff": "규칙이 비활성화되면, 모든 트래픽은 인증으로 넘어갑니다.", + "policyAccessRulesFallthroughOn": "매칭되는 규칙이 없으면, 트래픽은 인증으로 넘어갑니다.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "규칙 저장", "resourceErrorCreate": "리소스 생성 오류", "resourceErrorCreateDescription": "리소스를 생성하는 중 오류가 발생했습니다.", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "access": "접속", "accessControl": "액세스 제어", - "shareLink": "{resource} 공유 링크", + "shareLink": "{resource} 공유 가능한 링크", "resourceSelect": "리소스 선택", - "shareLinks": "공유 링크", + "shareLinks": "공유 가능한 링크", "share": "공유 가능한 링크", "shareDescription2": "리소스에 대한 공유 가능한 링크를 생성하세요. 링크는 리소스에 대한 임시 또는 무제한 액세스를 제공합니다. 링크를 생성할 때 만료 기간을 설정할 수 있습니다.", "shareEasyCreate": "생성하고 공유하기 쉬움", @@ -916,10 +975,18 @@ "resourceRoleDescription": "관리자는 항상 이 리소스에 접근할 수 있습니다.", "resourcePolicySelectTitle": "리소스 액세스 정책", "resourcePolicySelectDescription": "인증을 위한 리소스 정책 유형을 선택하세요", + "resourcePolicyTypeLabel": "정책 유형", + "resourcePolicyLabel": "리소스 정책", "resourcePolicyInline": "인라인 리소스 정책", "resourcePolicyInlineDescription": "이 리소스에만 범위가 있는 액세스 정책", "resourcePolicyShared": "공유 리소스 정책", - "resourcePolicySharedDescription": "이 리소스는 공유 정책을 사용합니다. 정책 수준 설정(인증 방법, 이메일 화이트리스트)은 잠겨 있습니다. 아래에서 리소스별 규칙, 역할 및 사용자를 추가할 수 있습니다.", + "resourcePolicySharedDescription": "이 리소스는 공유 정책을 사용합니다.", + "sharedPolicy": "공유 정책", + "sharedPolicyNoneDescription": "이 리소스는 자체 정책을 가지고 있습니다.", + "resourceSharedPolicyOwnDescription": "이 리소스는 자체 인증 및 접근 규칙 제어를 가지고 있습니다.", + "resourceSharedPolicyInheritedDescription": "이 리소스는 {policyName}에서 상속받습니다.", + "resourceSharedPolicyAuthenticationNotice": "이 리소스는 공유 정책을 사용합니다. 일부 인증 설정은 이 리소스에서 정책에 추가하기 위해 편집할 수 있습니다. 기본 정책을 변경하려면 {policyName}을 편집해야 합니다.", + "resourceSharedPolicyRulesNotice": "이 리소스는 공유 정책을 사용합니다. 일부 액세스 규칙은 이 리소스에서 편집할 수 있습니다. 기본 정책을 변경하려면 {policyName}을 수정해야 합니다.", "resourceUsersRoles": "접근 제어", "resourceUsersRolesDescription": "이 리소스를 방문할 수 있는 사용자 및 역할을 구성하십시오", "resourceUsersRolesSubmit": "접근 제어 저장", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "가시성", "resourceVisibilityTitleDescription": "리소스 가시성을 완전히 활성화하거나 비활성화", "resourceGeneral": "일반 설정", - "resourceGeneralDescription": "이 리소스에 대한 일반 설정을 구성하십시오.", + "resourceGeneralDescription": "이 리소스를 위한 이름, 주소 및 접근 정책을 구성하세요.", + "resourceGeneralDetailsSubsection": "리소스 세부 정보", + "resourceGeneralDetailsSubsectionDescription": "이 리소스를 위한 표시 이름, 식별자 및 공개 도메인을 설정합니다.", + "resourceGeneralDetailsSubsectionPortDescription": "이 리소스를 위한 표시 이름, 식별자 및 공개 포트를 설정합니다.", + "resourceGeneralPublicAddressSubsection": "공공 주소", + "resourceGeneralPublicAddressSubsectionDescription": "사용자가 이 리소스에 도달하는 방법을 구성하세요.", + "resourceGeneralAuthenticationAccessSubsection": "인증 및 접근", + "resourceGeneralAuthenticationAccessSubsectionDescription": "이 리소스가 자체 정책을 사용하는지 또는 공유 정책에서 상속받는지를 선택하세요.", "resourceEnable": "리소스 활성화", "resourceTransfer": "리소스 전송", "resourceTransferDescription": "이 리소스를 다른 사이트로 전송", @@ -1220,11 +1294,14 @@ "addLabels": "레이블 추가", "siteLabelsTab": "레이블", "siteLabelsDescription": "이 사이트와 연결된 레이블을 관리합니다.", - "labelsNotFound": "레이블을 찾을 수 없습니다", + "labelsNotFound": "레이블을 찾을 수 없습니다.", + "labelsEmptyCreateHint": "라벨을 생성하려면 위에서 입력을 시작하세요.", "labelSearch": "레이블 검색", + "labelSearchOrCreate": "레이블을 검색하거나 생성하세요", "accessLabelFilterCount": "{count, plural, other {# 레이블}}", "labelOverflowCount": " +{count, plural, other {# 레이블}}", "accessLabelFilterClear": "레이블 필터 초기화", + "accessFilterClear": "필터 지우기", "selectColor": "색상 선택", "createNewLabel": "새 조직 레이블 \"{label}\" 만들기", "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", @@ -1461,8 +1538,8 @@ "sidebarResources": "리소스", "sidebarProxyResources": "공유", "sidebarClientResources": "비공개", - "sidebarPolicies": "정책", - "sidebarResourcePolicies": "리소스", + "sidebarPolicies": "공유 정책들", + "sidebarResourcePolicies": "공개 리소스", "sidebarAccessControl": "액세스 제어", "sidebarLogsAndAnalytics": "로그 및 분석", "sidebarTeam": "팀", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "관리자", "sidebarInvitations": "초대", "sidebarRoles": "역할", - "sidebarShareableLinks": "링크", + "sidebarShareableLinks": "공유 가능한 링크", "sidebarApiKeys": "API 키", "sidebarProvisioning": "프로비저닝", "sidebarSettings": "설정", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "리소스 {id}", "blueprints": "청사진", "blueprintsLog": "블루프린트 로그", - "blueprintsDescription": "과거 블루프린트 적용 및 결과 보기", + "blueprintsDescription": "이전에 블루프린트 응용 프로그램과 그 결과를 보거나 새 블루프린트를 적용하세요", "blueprintAdd": "청사진 추가", "blueprintGoBack": "모든 청사진 보기", "blueprintCreate": "청사진 생성", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Docker 청사진 활성화", "enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 스크래핑을 활성화합니다. 소켓 경로는 사이트 커넥터에 제공되어야 합니다. 동작 방법에 대한 자세한 정보는 문서에서 확인하세요.", "newtAutoUpdate": "사이트 자동 업데이트 활성화", - "newtAutoUpdateDescription": "활성화되면, 사이트 커넥터는 새 릴리스가 출시될 때 자동으로 최신 버전으로 업데이트됩니다.", + "newtAutoUpdateDescription": "활성화되면, 사이트 커넥터는 최신 버전을 자동으로 다운로드하고 재시작합니다. 각 사이트별로 이를 무시할 수 있습니다.", "siteAutoUpdate": "사이트 자동 업데이트", "siteAutoUpdateLabel": "자동 업데이트 활성화", - "siteAutoUpdateDescription": "이 사이트의 커넥터가 최신 버전을 자동으로 다운로드할지 여부를 제어합니다.", + "siteAutoUpdateDescription": "활성화되면, 이 사이트의 커넥터는 최신 버전을 자동으로 다운로드하고 재시작합니다.", "siteAutoUpdateOrgDefault": "조직 기본값: {state}", "siteAutoUpdateOverriding": "조직 설정 재정의", "siteAutoUpdateResetToOrg": "조직 기본값으로 재설정", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "계정 설정이 완료되었습니다! 판골린에 오신 것을 환영합니다!", "documentation": "문서", "saveAllSettings": "모든 설정 저장", - "saveResourceTargets": "대상 저장", - "saveResourceHttp": "프록시 설정 저장", - "saveProxyProtocol": "프록시 프로토콜 설정 저장", + "saveResourceTargets": "설정 저장", + "saveResourceHttp": "설정 저장", + "saveProxyProtocol": "설정 저장", "settingsUpdated": "설정이 업데이트되었습니다", "settingsUpdatedDescription": "설정이 성공적으로 업데이트되었습니다.", "settingsErrorUpdate": "설정 업데이트 실패", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "알 수 없음", "healthCheck": "상태 확인", "configureHealthCheck": "상태 확인 설정", - "configureHealthCheckDescription": "{target}에 대한 상태 모니터링 설정", + "configureHealthCheckDescription": "리소스의 모니터링을 설정하여 항상 이용 가능하도록 하세요", "enableHealthChecks": "상태 확인 활성화", "healthCheckDisabledStateDescription": "비활성화되면 이 사이트가 상태 확인을 수행하지 않으며 상태가 알 수 없는 것으로 간주됩니다.", "enableHealthChecksDescription": "이 대상을 모니터링하여 건강 상태를 확인하세요. 필요에 따라 대상과 다른 엔드포인트를 모니터링할 수 있습니다.", "healthScheme": "방법", "healthSelectScheme": "방법 선택", - "healthCheckPortInvalid": "올바르지 않은 서브넷 마스크입니다. 1에서 65535 사이여야 합니다", + "healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다", "healthCheckPath": "경로", "healthHostname": "IP / 호스트", "healthPort": "포트", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "장치 승인 요구", "requireDeviceApprovalDescription": "이 역할을 가진 사용자는 장치가 연결되기 전에 관리자의 승인이 필요합니다.", "sshSettings": "SSH 설정", + "sshAccess": "SSH 접속", "rdpSettings": "RDP 설정", "vncSettings": "VNC 설정", "sshServer": "SSH 서버", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "이 설정을 완료하기 전에 인증 데몬을 실행할 대상 호스트가 적절히 구성되었는지 확인하십시오. 그렇지 않으면 프로비저닝이 실패할 수 있습니다.", "sshDaemonPort": "데몬 포트", "sshServerDestination": "서버 목적지", - "sshServerDestinationDescription": "SSH 서버의 목적지 및 포트를 구성합니다", + "sshServerDestinationDescription": "SSH 서버의 목적지를 설정합니다", "destination": "대상지", + "destinationRequired": "목적지가 필요합니다.", + "domainRequired": "도메인은 필수입니다.", + "proxyPortRequired": "포트가 필요합니다.", + "invalidPathConfiguration": "유효하지 않은 경로 구성입니다.", + "invalidRewritePathConfiguration": "유효하지 않은 재작성 경로 구성입니다.", "bgTargetMultiSiteDisclaimer": "여러 사이트를 선택하면 고가용성을 위한 내구성 있는 라우팅 및 장애 조치를 활성화합니다.", "roleAllowSsh": "SSH 허용", "roleAllowSshAllow": "허용", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.", "sshSudo": "Sudo 허용", "sshSudoCommands": "Sudo 명령", - "sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있는 명령의 쉼표로 구분된 목록입니다. 절대 경로를 사용해야 합니다.", + "sshSudoCommandsDescription": "사용자가 쉘에서 sudo로 실행할 수 있는 명령 목록, 쉼표, 공백 또는 새 줄로 구분됩니다. 절대 경로를 사용해야 합니다.", "sshCreateHomeDir": "홈 디렉터리 생성", "sshUnixGroups": "유닉스 그룹", - "sshUnixGroupsDescription": "대상 호스트에서 사용자에게 추가할 유닉스 그룹의 쉼표로 구분된 목록입니다.", + "sshUnixGroupsDescription": "사용자를 대상 호스트에 추가할 유닉스 그룹들, 쉼표, 공백 또는 새 줄로 구분됩니다.", + "roleTextFieldPlaceholder": "값을 입력하거나 .txt나 .csv 파일을 드롭하세요", + "roleTextImportTitle": "파일에서 가져오기", + "roleTextImportDescription": "{fileName}을(를) {fieldLabel}에 가져오는 중", + "roleTextImportSkipHeader": "첫 행 건너뛰기 (헤더)", + "roleTextImportOverride": "기존 항목 교체", + "roleTextImportAppend": "기존 항목에 추가", + "roleTextImportMode": "가져오기 모드", + "roleTextImportPreview": "미리보기", + "roleTextImportItemCount": "{count, plural, =0 {가져올 항목 없음} other {# 개의 항목 가져오기}}", + "roleTextImportTotalCount": "{existing} 기존 + {imported} 가져옴 = {total} 총계", + "roleTextImportConfirm": "가져오기", + "roleTextImportInvalidFile": "지원되지 않는 파일 유형", + "roleTextImportInvalidFileDescription": ".txt 및 .csv 파일만 지원됩니다.", + "roleTextImportEmpty": "파일에서 항목을 찾을 수 없습니다", + "roleTextImportEmptyDescription": "파일에 가져올 항목이 포함되어 있지 않습니다.", "retryAttempts": "재시도 횟수", "expectedResponseCodes": "예상 응답 코드", "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "프록시 프로토콜 활성화", "proxyProtocolInfo": "TCP 백엔드에 대한 클라이언트 IP 주소를 유지합니다.", "proxyProtocolVersion": "프록시 프로토콜 버전", - "version1": " 버전 1 (추천)", + "version1": "버전 1 (추천)", "version2": "버전 2", - "versionDescription": "버전 1은 텍스트 기반으로 널리 지원됩니다. 버전 2는 이진 기반으로 더 효율적이지만 호환성이 낮습니다.", + "version1Description": "텍스트 기반으로 널리 지원됩니다. 서버 전송이 동적 구성에 추가되었는지 확인하세요.", + "version2Description": "바이너리 및 더 효율적이지만 호환성은 낮습니다. 서버 전송이 동적 구성에 추가되었는지 확인하세요.", "warning": "경고", "proxyProtocolWarning": "백엔드 애플리케이션이 프록시 프로토콜 연결을 허용하도록 구성되어야 합니다. 백엔드가 프록시 프로토콜을 지원하지 않으면, 이를 활성화하면 모든 연결이 끊어집니다. 트래픽에서 온 프록시 프로토콜 헤더를 백엔드가 신뢰하도록 구성하십시오.", "restarting": "재시작 중...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "확인 입력", "blueprintViewDetails": "세부 정보", "defaultIdentityProvider": "기본 아이덴티티 공급자", - "defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.", + "defaultIdentityProviderDescription": "사용자는 인증을 위해 이 아이덴티티 공급자로 자동 리디렉션됩니다.", "editInternalResourceDialogNetworkSettings": "네트워크 설정", "editInternalResourceDialogAccessPolicy": "액세스 정책", "editInternalResourceDialogAddRoles": "역할 추가", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "유지보수 모드 유형", "showMaintenancePage": "방문자에게 유지보수 페이지 표시", "enableMaintenanceMode": "유지보수 모드 활성화", + "enableMaintenanceModeDescription": "활성화되면 방문자는 리소스 대신 유지보수 페이지를 보게 됩니다.", "automatic": "자동", "automaticModeDescription": "백엔드 타깃이 모두 다운되거나 건강하지 않을 때만 유지보수 페이지를 표시합니다. 적어도 하나의 타깃이 건강한 한 리소스는 정상 작동합니다.", "forced": "강제", @@ -3082,6 +3182,8 @@ "warning:": "경고:", "forcedeModeWarning": "모든 트래픽이 유지보수 페이지로 전달됩니다. 백엔드 리소스는 어떠한 요청도 받지 않습니다.", "pageTitle": "페이지 제목", + "maintenancePageContentSubsection": "페이지 콘텐츠", + "maintenancePageContentSubsectionDescription": "유지보수 페이지에 표시될 콘텐츠를 사용자 정의하세요", "pageTitleDescription": "유지보수 페이지에 표시될 주요 제목", "maintenancePageMessage": "유지보수 메시지", "maintenancePageMessagePlaceholder": "곧 돌아오겠습니다! 사이트는 현재 예정된 유지보수를 진행 중입니다.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "정말로 이 조직에서 이 아이덴티티 공급자의 연관을 해제하시겠습니까?", "idpUnassociateDescription": "이 아이덴티티 공급자와 연관된 모든 사용자는 이 조직에서 제거될 것이지만, 아이덴티티 공급자는 다른 연관된 조직에 계속해서 존재할 것입니다.", "idpUnassociateConfirm": "아이덴티티 공급자 연관 해제 확인", + "idpConfirmDeleteAndRemoveMeFromOrg": "조직에서 삭제하고 제거하기", + "idpUnassociateAndRemoveMeFromOrg": "조직에서 연관 해제하고 제거하기", "idpUnassociateWarning": "이 조직에서 이것은 되돌릴 수 없습니다.", "idpUnassociatedDescription": "아이덴티티 공급자가 이 조직에서 성공적으로 연관 해제되었습니다", "idpUnassociateMenu": "연관 해제", @@ -3439,18 +3543,58 @@ "sshConnecting": "연결 중…", "sshInitializing": "초기화 중…", "sshSignInTitle": "SSH에 로그인", - "sshSignInDescription": "SSH 자격 증명을 입력하세요", + "sshSignInDescription": "연결하려면 SSH 자격 증명을 입력하세요", "sshPasswordTab": "비밀번호", "sshPrivateKeyTab": "개인 키", "sshPrivateKeyField": "개인 키", "sshPrivateKeyDisclaimer": "당신의 개인 키는 Pangolin에 저장되거나 보이지 않습니다. 대신, 기존 Pangolin 신원을 사용하여 매끄러운 인증을 제공하는 단기 인증서를 사용할 수 있습니다.", "sshLearnMore": "자세히 알아보기", "sshPrivateKeyFile": "개인 키 파일", - "sshAuthenticate": "인증", + "sshAuthenticate": "연결", "sshTerminate": "종료", "sshPoweredBy": "제공자", "sshErrorNoTarget": "지정된 대상이 없습니다", "sshErrorWebSocket": "WebSocket 연결 실패", "sshErrorAuthFailed": "인증 실패", - "sshErrorConnectionClosed": "인증이 완료되기 전에 연결이 닫혔습니다" + "sshErrorConnectionClosed": "인증이 완료되기 전에 연결이 닫혔습니다", + "sitePangolinSshDescription": "이 사이트의 리소스에 SSH 접속을 허용합니다. 나중에 변경할 수 있습니다.", + "browserGatewayNoResourceForDomain": "이 도메인에 대한 리소스를 찾을 수 없습니다", + "browserGatewayNoTarget": "대상 없음", + "browserGatewayConnect": "연결", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "PAM 푸시 인증을 위한 SSH 키 서명 실패. 사용자로 로그인하셨나요?", + "sshTerminalError": "오류: {error}", + "sshConnectionClosedCode": "연결 종료됨 (코드 {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "프라이빗 키가 필요합니다", + "vncTitle": "VNC", + "vncSignInDescription": "연결하려면 VNC 비밀번호를 입력하세요", + "vncPasswordOptional": "비밀번호 (선택 사항)", + "vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다", + "vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다", + "vncAuthFailedStatus": "상태 {status}", + "vncPasteClipboard": "클립보드 붙여넣기", + "rdpTitle": "RDP", + "rdpSignInTitle": "원격 데스크톱에 로그인", + "rdpSignInDescription": "연결하려면 Windows 자격 증명을 입력하세요", + "rdpLoadingModule": "모듈 로딩 중...", + "rdpFailedToLoadModule": "RDP 모듈 로딩 실패", + "rdpNotReady": "준비되지 않음", + "rdpModuleInitializing": "RDP 모듈이 아직 초기화 중입니다", + "rdpDownloadingFiles": "원격에서 {count}개의 파일 다운로드 중…", + "rdpDownloadFailed": "다운로드 실패: {fileName}", + "rdpUploaded": "업로드 완료: {fileName}", + "rdpNoConnectionTarget": "연결 대상 없음", + "rdpConnectionFailed": "연결 실패", + "rdpFit": "적합", + "rdpFull": "전체", + "rdpReal": "실제", + "rdpMeta": "메타", + "rdpUploadFiles": "파일 업로드", + "rdpFilesReadyToPaste": "붙여넣기 준비 완료된 파일", + "rdpFilesReadyToPasteDescription": "{count}개의 파일이 원격 클립보드에 복사되었습니다 — 원격 데스크탑에서 Ctrl+V를 눌러 붙여 넣으세요.", + "rdpUploadFailed": "업로드 실패", + "rdpUnicodeKeyboardMode": "유니코드 키보드 모드", + "sessionToolbarShow": "툴바 보기", + "sessionToolbarHide": "툴바 숨기기" } From 86ff2720954399b235829263d51f10adc4a833e6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:49 -0700 Subject: [PATCH 025/264] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 240 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 192 insertions(+), 48 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 5c36b2504..642423454 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Privébronnen bekijken", "siteInstallNewt": "Installeer Newt", "siteInstallNewtDescription": "Laat Newt draaien op uw systeem", + "siteInstallKubernetesDocsDescription": "Voor meer informatie over de installatie van Kubernetes, zie docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Voor instructies voor de installatie van Advantech modems, zie docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard Configuratie", "WgConfigurationDescription": "Gebruik de volgende configuratie om verbinding te maken met het netwerk", "operatingSystem": "Operating systeem", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "siteInfo": "Site informatie", "status": "Status", - "shareTitle": "Beheer deellinks", + "shareTitle": "Beheer Deelbare Links", "shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot proxybronnen te verlenen", - "shareSearch": "Zoek share links...", - "shareCreate": "Maak Share link", + "shareSearch": "Zoek deelbare links...", + "shareCreate": "Creëer Deelbare Link", "shareErrorDelete": "Kan link niet verwijderen", "shareErrorDeleteMessage": "Fout opgetreden tijdens het verwijderen link", "shareDeleted": "Link verwijderd", "shareDeletedDescription": "De link is verwijderd", - "shareDelete": "Verwijder Deel Link", - "shareDeleteConfirm": "Bevestig verwijdering van Deel Link", + "shareDelete": "Verwijder Deelbare Link", + "shareDeleteConfirm": "Bevestig Verwijdering Deelbare Link", "shareQuestionRemove": "Weet u zeker dat u deze deel link wilt verwijderen?", "shareMessageRemove": "Zodra verwijderd, zal de link niet meer werken en zal iedereen die het gebruikt de toegang tot de bron verliezen.", "shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Iedereen met deze link heeft toegang tot de pagina", "shareTitleOptional": "Titel (optioneel)", "sharePathOptional": "Pad (optioneel)", + "sharePathDescription": "De link zal gebruikers naar dit pad doorsturen na authenticatie.", "expireIn": "Vervalt in", "neverExpire": "Nooit verlopen", "shareExpireDescription": "Vervaltijd is hoe lang de link bruikbaar is en geeft toegang tot de bron. Na deze tijd zal de link niet meer werken en zullen gebruikers die deze link hebben gebruikt de toegang tot de pagina verliezen.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", - "publicResourcesBannerTitle": "Webgebaseerde openbare toegang", - "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.", + "publicResourcesBannerTitle": "Web-gebaseerde Openbare Toegang", + "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot privébronnen hoeven ze geen client-software te hebben en kunnen ze identiteit- en context bewuste toegangsmiddelen bevatten.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -209,15 +212,19 @@ "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", - "resourcePoliciesTitle": "Beheer Bron Beleid", - "resourcePoliciesAttachedResourcesColumnTitle": "Bijgevoegde bronnen", + "resourcePoliciesBannerTitle": "Herbruik Authenticatie en Toegangsregels", + "resourcePoliciesBannerDescription": "Gedeelde bronbeleidslijnen laten u authenticatiemethoden en toegangsregels eenmaal definiëren, en ze vervolgens koppelen aan meerdere openbare bronnen. Wanneer u een beleid bijwerkt, erft elke gekoppelde bron de wijziging automatisch.", + "resourcePoliciesBannerButtonText": "Meer informatie", + "resourcePoliciesTitle": "Beheer Openbare Bronnenbeleid", + "resourcePoliciesAttachedResourcesColumnTitle": "Bronnen", "resourcePoliciesAttachedResources": "{count} bron(nen)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# bron} other {# bronnen}}", "resourcePoliciesAttachedResourcesEmpty": "geen bronnen", - "resourcePoliciesDescription": "Maak en beheer authenticatiebeleid om toegang tot uw bronnen te controleren", + "resourcePoliciesDescription": "Creëer en beheer authenticatiebeleid om toegang tot uw openbare bronnen te controleren", "resourcePoliciesSearch": "Beleidsregels zoeken...", "resourcePoliciesAdd": "Beleid toevoegen", "resourcePoliciesDefaultBadgeText": "Standaard beleidsregel", - "resourcePoliciesCreate": "Creëer Bronbeleid", + "resourcePoliciesCreate": "Openbare Bronbeleid maken", "resourcePoliciesCreateDescription": "Volg de onderstaande stappen om een nieuw beleid aan te maken", "resourcePolicyName": "Beleidsregelnaam", "resourcePolicyNameDescription": "Geef deze beleidsregel een naam om deze te identificeren in uw bronnen", @@ -274,7 +281,7 @@ "back": "Achterzijde", "cancel": "Annuleren", "resourceConfig": "Configuratie tekstbouwstenen", - "resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om de TCP/UDP-bron in te stellen", + "resourceConfigDescription": "Kopieer en plak deze configuratiesnippets om de TCP/UDP-bron op te zetten.", "resourceAddEntrypoints": "Traefik: Entrypoints toevoegen", "resourceExposePorts": "Gerbild: Gevangen blootstellen in Docker Compose", "resourceLearnRaw": "Leer hoe je TCP/UDP bronnen kunt configureren", @@ -287,6 +294,8 @@ "labelDelete": "Label verwijderen", "labelAdd": "Label toevoegen", "labelCreateSuccessMessage": "Label succesvol aangemaakt", + "labelDuplicateError": "Dubbel Label", + "labelDuplicateErrorDescription": "Een label met deze naam bestaat al.", "labelEditSuccessMessage": "Label succesvol gewijzigd", "labelNameField": "Labelnaam", "labelColorField": "Label kleur", @@ -311,7 +320,7 @@ "rules": "Regels", "resourceSettingDescription": "Configureer de instellingen in de bron", "resourceSetting": "{resourceName} instellingen", - "resourcePolicySettingDescription": "Configureer de instellingen op het bronbeleid", + "resourcePolicySettingDescription": "Configureer de instellingen van dit openbare bronbeleid", "resourcePolicySetting": "{policyName} instellingen", "alwaysAllow": "Authenticatie omzeilen", "alwaysDeny": "Blokkeer toegang", @@ -719,7 +728,7 @@ "targetSubmit": "Doelwit toevoegen", "targetNoOne": "Deze bron heeft geen doelwitten. Voeg een doel toe om te configureren waar verzoeken naar de backend verzonden kunnen worden.", "targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.", - "targetsSubmit": "Doelstellingen opslaan", + "targetsSubmit": "Instellingen opslaan", "addTarget": "Doelwit toevoegen", "proxyMultiSiteRoundRobinNodeHelp": "Round-robin routering werkt niet tussen locaties die niet met hetzelfde knooppunt zijn verbonden, maar failover werkt wel.", "targetErrorInvalidIp": "Ongeldig IP-adres", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Dupliceer regel", "rulesErrorDuplicateDescription": "Een regel met deze instellingen bestaat al", "rulesErrorInvalidIpAddressRange": "Ongeldige CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Voer een geldige CIDR waarde in", - "rulesErrorInvalidUrl": "Ongeldige URL pad", - "rulesErrorInvalidUrlDescription": "Voer een geldige URL padwaarde in", - "rulesErrorInvalidIpAddress": "Ongeldig IP", - "rulesErrorInvalidIpAddressDescription": "Voer een geldig IP-adres in", + "rulesErrorInvalidIpAddressRangeDescription": "Voer een geldig CIDR-bereik in (bijv. 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Ongeldig pad", + "rulesErrorInvalidUrlDescription": "Voer een geldig URL-pad of patroon in (bijv. /api/*).", + "rulesErrorInvalidIpAddress": "Ongeldig IP-adres", + "rulesErrorInvalidIpAddressDescription": "Voer een geldig IPv4- of IPv6-adres in.", "rulesErrorUpdate": "Regels bijwerken mislukt", "rulesErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de regels", "rulesUpdated": "Regels inschakelen", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Voer een IP-adres in (bijv. 103.21.244.12)", "rulesMatchUrl": "Voer een URL-pad of patroon in (bijv. /api/v1/todos of /api/v1/*)", "rulesErrorInvalidPriority": "Ongeldige prioriteit", - "rulesErrorInvalidPriorityDescription": "Voer een geldige prioriteit in", + "rulesErrorInvalidPriorityDescription": "Voer een geheel getal van 1 of hoger in.", "rulesErrorDuplicatePriority": "Dubbele prioriteiten", - "rulesErrorDuplicatePriorityDescription": "Voer unieke prioriteiten in", + "rulesErrorDuplicatePriorityDescription": "Elke regel moet een uniek prioriteitsnummer hebben.", + "rulesErrorValidation": "Ongeldige regels", + "rulesErrorValidationRuleDescription": "Regel {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Selecteer een geldig matchtype (pad, IP, CIDR, land, regio of ASN).", + "rulesErrorValueRequired": "Voer een waarde in voor deze regel.", + "rulesErrorInvalidCountry": "Ongeldig land", + "rulesErrorInvalidCountryDescription": "Selecteer een geldig land.", + "rulesErrorInvalidAsn": "Ongeldige ASN", + "rulesErrorInvalidAsnDescription": "Voer een geldig ASN in (bijv. AS15169).", "ruleUpdated": "Regels bijgewerkt", "ruleUpdatedDescription": "Regels met succes bijgewerkt", "ruleErrorUpdate": "Bewerking mislukt", "ruleErrorUpdateDescription": "Er is een fout opgetreden tijdens het opslaan", "rulesPriority": "Prioriteit", + "rulesReorderDragHandle": "Sleep om de regelprioriteit te herordenen", "rulesAction": "actie", "rulesMatchType": "Wedstrijd Type", "value": "Waarde", @@ -792,7 +810,7 @@ "rulesResource": "Configuratie Resource Regels", "rulesResourceDescription": "Regels instellen om toegang tot de bron te beheren", "ruleSubmit": "Regel toevoegen", - "rulesNoOne": "Geen regels. Voeg een regel toe via het formulier.", + "rulesNoOne": "Nog geen regels.", "rulesOrder": "Regels worden in oplopende volgorde volgens prioriteit beoordeeld.", "rulesSubmit": "Regels opslaan", "policyErrorCreate": "Fout bij het maken van beleid", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Er is een onverwachte fout opgetreden", "policyCreatedSuccess": "Bronbeleid met succes aangemaakt", "policyUpdatedSuccess": "Bronbeleid succesvol bijgewerkt", - "authMethodsSave": "Bewaar authenticatiemethoden", + "authMethodsSave": "Instellingen opslaan", + "policyAuthStackTitle": "Authenticatie", + "policyAuthStackDescription": "Bepaal welke authenticatiemethoden vereist zijn om toegang tot deze bron te krijgen", + "policyAuthOrLogicTitle": "Meerdere authenticatiemethoden actief", + "policyAuthOrLogicBanner": "Bezoekers kunnen zich aanmelden met een van de hieronder actieve methoden. Ze hoeven ze niet allemaal te voltooien.", + "policyAuthMethodActive": "Actief", + "policyAuthMethodOff": "Uit", + "policyAuthSsoTitle": "Platform SSO", + "policyAuthSsoDescription": "Vereis inloggen via de identiteit provider van uw organisatie", + "policyAuthSsoSummary": "{idp} · {users} gebruikers, {roles} rollen", + "policyAuthSsoDefaultIdp": "Standaard provider", + "policyAuthAddDefaultIdentityProvider": "Standaard Identiteit Provider Toevoegen", + "policyAuthOtherMethodsTitle": "Andere Methoden", + "policyAuthOtherMethodsDescription": "Optionele methoden die bezoekers kunnen gebruiken in plaats van of samen met platform SSO", + "policyAuthPasscodeTitle": "Toegangscode", + "policyAuthPasscodeDescription": "Vereis een alfanumerieke toegangscode om toegang te krijgen tot de bron", + "policyAuthPasscodeSummary": "Toegangscode ingesteld", + "policyAuthPincodeTitle": "Pincode", + "policyAuthPincodeDescription": "Een korte numerieke code vereist om toegang tot de bron te krijgen", + "policyAuthPincodeSummary": "6-cijferige Pincode ingesteld", + "policyAuthEmailTitle": "E-mail Whitelist", + "policyAuthEmailDescription": "Sta vermelde e-mailadressen toe met eenmalige wachtwoorden", + "policyAuthEmailSummary": "{count} adressen toegestaan", + "policyAuthEmailOtpCallout": "Het inschakelen van e-mailwhitelist stuurt een eenmalig wachtwoord naar de e-mail van de bezoeker bij het inloggen.", + "policyAuthHeaderAuthTitle": "Basic Header Authenticatie", + "policyAuthHeaderAuthDescription": "Valideer een aangepaste HTTP-headernaam en waarde bij elk verzoek", + "policyAuthHeaderAuthSummary": "Header geconfigureerd", + "policyAuthHeaderName": "Header naam", + "policyAuthHeaderValue": "Verwachte waarde", + "policyAuthSetPasscode": "Stel toegangscode in", + "policyAuthSetPincode": "Stel Pincode in", + "policyAuthSetEmailWhitelist": "Stel E-mail Whitelist in", + "policyAuthSetHeaderAuth": "Stel Basis Header Authenticatie in", + "policyAccessRulesTitle": "Toegang Regels", + "policyAccessRulesEnableDescription": "Wanneer ingeschakeld, worden regels in aflopende volgorde geëvalueerd totdat er één als waar wordt geoordeeld.", + "policyAccessRulesFirstMatch": "Regels worden van boven naar beneden geëvalueerd. De eerste overeenkomstige regel bepaalt de uitkomst.", + "policyAccessRulesHowItWorks": "Regels komen overeen met verzoeken op basis van pad, IP-adres, locatie of andere criteria. Elke regel past een actie toe: authenticatie omzeilen, toegang blokkeren of authenticatie doorgeven. Als er geen regel overeenkomt, gaat het verkeer door naar authenticatie.", + "policyAccessRulesFallthroughOff": "Wanneer regels zijn uitgeschakeld, passeert al het verkeer naar authenticatie.", + "policyAccessRulesFallthroughOn": "Wanneer geen regel overeenkomt, passeert het verkeer naar authenticatie.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Regels opslaan", "resourceErrorCreate": "Fout bij maken document", "resourceErrorCreateDescription": "Er is een fout opgetreden bij het maken van het document", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "access": "Toegangsrechten", "accessControl": "Toegangs controle", - "shareLink": "{resource} Share link", + "shareLink": "{resource} Deelbare Link", "resourceSelect": "Selecteer resource", - "shareLinks": "Links delen", + "shareLinks": "Deelbare Links", "share": "Deelbare links", "shareDescription2": "Maak deelbare links naar bronnen. Links bieden tijdelijke of onbeperkte toegang tot je bestand. U kunt de vervalduur van de link configureren wanneer u er een aanmaakt.", "shareEasyCreate": "Makkelijk te maken en te delen", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Beheerders hebben altijd toegang tot deze bron.", "resourcePolicySelectTitle": "Toegangsbeleid voor bronnen", "resourcePolicySelectDescription": "Selecteer het bronbeleidstype voor authenticatie", + "resourcePolicyTypeLabel": "Beleidstype", + "resourcePolicyLabel": "Bronbeleid", "resourcePolicyInline": "Inline bronbeleid", "resourcePolicyInlineDescription": "Toegangsbeleid alleen gericht op deze bron", "resourcePolicyShared": "Gedeeld bronbeleid", - "resourcePolicySharedDescription": "Deze bron gebruikt een gedeeld beleid. Instellingen op beleidsniveau (authenticatiemethoden, e-mailwhitelist) zijn vergrendeld. U kunt hieronder bron-specifieke regels, rollen en gebruikers toevoegen.", + "resourcePolicySharedDescription": "Deze bron gebruikt een gedeeld beleid.", + "sharedPolicy": "Gedeeld Beleid", + "sharedPolicyNoneDescription": "Deze bron heeft zijn eigen beleid.", + "resourceSharedPolicyOwnDescription": "Deze bron heeft zijn eigen authenticatie- en toegangsvanregels.", + "resourceSharedPolicyInheritedDescription": "Deze bron erft van {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Deze bron gebruikt een gedeeld beleid. Sommige authenticatie-instellingen kunnen op deze bron worden bewerkt om toe te voegen aan het beleid. Om het onderliggende beleid te wijzigen, moet u het beleid bewerken in {policyName}.", + "resourceSharedPolicyRulesNotice": "Deze bron gebruikt een gedeeld beleid. Sommige toegangsregels kunnen op deze bron worden bewerkt. Om het onderliggende beleid te wijzigen, moet u {policyName} bewerken.", "resourceUsersRoles": "Toegang Bediening", "resourceUsersRolesDescription": "Configureer welke gebruikers en rollen deze pagina kunnen bezoeken", "resourceUsersRolesSubmit": "Bewaar Toegangsbesturing", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Zichtbaarheid", "resourceVisibilityTitleDescription": "Zichtbaarheid van bestanden volledig in- of uitschakelen", "resourceGeneral": "Algemene instellingen", - "resourceGeneralDescription": "Configureer de algemene instellingen voor deze bron", + "resourceGeneralDescription": "Configureer naam, adres en toegangspolicy voor deze bron.", + "resourceGeneralDetailsSubsection": "Bron Details", + "resourceGeneralDetailsSubsectionDescription": "Stel de weergavenaam, identificatiecode en publiek toegankelijk domein voor deze bron in.", + "resourceGeneralDetailsSubsectionPortDescription": "Stel de weergavenaam, identificatiecode en publieke poort voor deze bron in.", + "resourceGeneralPublicAddressSubsection": "Publiek Adres", + "resourceGeneralPublicAddressSubsectionDescription": "Configureer hoe gebruikers deze bron bereiken.", + "resourceGeneralAuthenticationAccessSubsection": "Authenticatie & Toegang", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Kies of deze bron zijn eigen beleid gebruikt of van een gedeeld beleid erft.", "resourceEnable": "Resource inschakelen", "resourceTransfer": "Bronnen overdragen", "resourceTransferDescription": "Verplaats dit document naar een andere site", @@ -1220,11 +1294,14 @@ "addLabels": "Labels toevoegen", "siteLabelsTab": "Labels", "siteLabelsDescription": "Beheer labels geassocieerd met deze site.", - "labelsNotFound": "Labels niet gevonden", + "labelsNotFound": "Geen labels gevonden.", + "labelsEmptyCreateHint": "Begin hierboven te typen om een label te maken.", "labelSearch": "Labels zoeken", + "labelSearchOrCreate": "Zoek of maak een label", "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Labelfilters wissen", + "accessFilterClear": "Wissen van filters", "selectColor": "Kleur selecteren", "createNewLabel": "Nieuw org-label \"{label}\" aanmaken", "inviteInvalidDescription": "Uitnodigingslink is ongeldig.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Bronnen", "sidebarProxyResources": "Openbaar", "sidebarClientResources": "Privé", - "sidebarPolicies": "Beleid", - "sidebarResourcePolicies": "Bronnen", + "sidebarPolicies": "Gedeeld Beleid", + "sidebarResourcePolicies": "Openbare Bronnen", "sidebarAccessControl": "Toegangs controle", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Beheerder", "sidebarInvitations": "Uitnodigingen", "sidebarRoles": "Rollen", - "sidebarShareableLinks": "Koppelingen", + "sidebarShareableLinks": "Deelbare Links", "sidebarApiKeys": "API sleutels", "sidebarProvisioning": "Provisie", "sidebarSettings": "Instellingen", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Bron {id}", "blueprints": "Blauwdrukken", "blueprintsLog": "Log Blueprints", - "blueprintsDescription": "Bekijk eerdere blueprint-toepassingen en hun resultaten", + "blueprintsDescription": "Bekijk eerdere blauwdruktoepassingen en hun resultaten of pas een nieuwe blauwdruk toe", "blueprintAdd": "Blauwdruk toevoegen", "blueprintGoBack": "Bekijk alle Blauwdrukken", "blueprintCreate": "Creëer blauwdruk", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Schakel Docker Blauwdruk in", "enableDockerSocketDescription": "Schakel Docker Socket-label in voor blueprint-labels. Socket-pad moet worden opgegeven aan de siteconnector. Lees meer over hoe dit werkt in de documentatie.", "newtAutoUpdate": "Automatische site-update inschakelen", - "newtAutoUpdateDescription": "Wanneer ingeschakeld, zullen site-connectors automatisch updaten naar de nieuwste versie wanneer een nieuwe release beschikbaar is.", + "newtAutoUpdateDescription": "Als ingeschakeld, downloaden de site-connectoren automatisch de laatste versie en starten zichzelf opnieuw. Dit kan per site worden overschreven.", "siteAutoUpdate": "Automatische site-update", "siteAutoUpdateLabel": "Automatische update inschakelen", - "siteAutoUpdateDescription": "Controleer of de siteconnector automatisch de laatste versie downloadt.", + "siteAutoUpdateDescription": "Als dit is ingeschakeld, downloadt en start deze site-connector automatisch de nieuwste versie opnieuw.", "siteAutoUpdateOrgDefault": "Standaard van organisatie: {state}", "siteAutoUpdateOverriding": "Overschrijving van organisatiestandaardinstelling", "siteAutoUpdateResetToOrg": "Terugzetten naar standaard van organisatie", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Accountinstelling voltooid! Welkom bij Pangolin!", "documentation": "Documentatie", "saveAllSettings": "Alle instellingen opslaan", - "saveResourceTargets": "Doelstellingen opslaan", - "saveResourceHttp": "Proxyinstellingen opslaan", - "saveProxyProtocol": "Proxy-protocolinstellingen opslaan", + "saveResourceTargets": "Instellingen opslaan", + "saveResourceHttp": "Instellingen opslaan", + "saveProxyProtocol": "Instellingen opslaan", "settingsUpdated": "Instellingen bijgewerkt", "settingsUpdatedDescription": "Instellingen succesvol bijgewerkt", "settingsErrorUpdate": "Bijwerken van instellingen mislukt", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Onbekend", "healthCheck": "Gezondheidscontrole", "configureHealthCheck": "Configureer Gezondheidscontrole", - "configureHealthCheckDescription": "Stel gezondheid monitor voor {target} in", + "configureHealthCheckDescription": "Stel monitoring in voor uw bron om ervoor te zorgen dat deze altijd beschikbaar is", "enableHealthChecks": "Inschakelen Gezondheidscontroles", "healthCheckDisabledStateDescription": "Wanneer uitgeschakeld, zal de site geen gezondheidscontroles uitvoeren en wordt de staat als onbekend beschouwd.", "enableHealthChecksDescription": "Controleer de gezondheid van dit doel. U kunt een ander eindpunt monitoren dan het doel indien vereist.", "healthScheme": "Methode", "healthSelectScheme": "Selecteer methode", - "healthCheckPortInvalid": "Health check poort moet tussen 1 en 65535 zijn", + "healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn", "healthCheckPath": "Pad", "healthHostname": "IP / Hostnaam", "healthPort": "Poort", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Vereist goedkeuring van apparaat", "requireDeviceApprovalDescription": "Gebruikers met deze rol hebben nieuwe apparaten nodig die door een beheerder zijn goedgekeurd voordat ze verbinding kunnen maken met bronnen en deze kunnen gebruiken.", "sshSettings": "SSH-instellingen", + "sshAccess": "SSH Toegang", "rdpSettings": "RDP-instellingen", "vncSettings": "VNC-instellingen", "sshServer": "SSH-server", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Zorg ervoor dat uw doelhost goed is geconfigureerd om de auth daemon uit te voeren voordat u deze configuratie voltooit, anders zal de voorziening mislukken.", "sshDaemonPort": "Demonpoort", "sshServerDestination": "Serverbestemming", - "sshServerDestinationDescription": "Configureer de bestemming en poort van de SSH-server", + "sshServerDestinationDescription": "Configureer de bestemming van de SSH-server", "destination": "Bestemming", + "destinationRequired": "Bestemming is vereist.", + "domainRequired": "Domein is vereist.", + "proxyPortRequired": "Poort is vereist.", + "invalidPathConfiguration": "Ongeldige padconfiguratie.", + "invalidRewritePathConfiguration": "Ongeldige overschrijfpadconfiguratie.", "bgTargetMultiSiteDisclaimer": "Het selecteren van meerdere sites maakt veerkrachtige routering mogelijk en failover voor hoge beschikbaarheid.", "roleAllowSsh": "SSH toestaan", "roleAllowSshAllow": "Toestaan", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.", "sshSudo": "sudo toestaan", "sshSudoCommands": "Sudo Commando's", - "sshSudoCommandsDescription": "Commagescheiden lijst van commando's die de gebruiker met sudo mag uitvoeren. Absolute paden moeten worden gebruikt.", + "sshSudoCommandsDescription": "Lijst met commando's die de gebruiker mag uitvoeren met sudo, gescheiden door komma's, spaties of nieuwe regels. Absolute paden moeten worden gebruikt.", "sshCreateHomeDir": "Maak Home Directory", "sshUnixGroups": "Unix groepen", - "sshUnixGroupsDescription": "Door komma's gescheiden Unix-groepen om de gebruiker toe te voegen aan de doelhost.", + "sshUnixGroupsDescription": "Unix-groepen om de gebruiker aan toe te voegen op de doelhost, gescheiden door komma's, spaties of nieuwe regels.", + "roleTextFieldPlaceholder": "Voer waarden in, of sleep een .txt of .csv-bestand", + "roleTextImportTitle": "Importeer vanuit Bestand", + "roleTextImportDescription": "{fileName} importeren naar {fieldLabel}.", + "roleTextImportSkipHeader": "Sla de eerste rij over (header)", + "roleTextImportOverride": "Vervang Bestaande", + "roleTextImportAppend": "Voeg toe aan Bestaande", + "roleTextImportMode": "Importeer modus", + "roleTextImportPreview": "Voorbeeld", + "roleTextImportItemCount": "{count, plural, =0 {Geen items om te importeren} one {1 item om te importeren} other {# items om te importeren}}", + "roleTextImportTotalCount": "{existing} bestaande + {imported} geïmporteerd = {total} totaal", + "roleTextImportConfirm": "Importeren", + "roleTextImportInvalidFile": "Niet-ondersteund bestandstype", + "roleTextImportInvalidFileDescription": "Alleen .txt en .csv-bestanden worden ondersteund.", + "roleTextImportEmpty": "Geen items gevonden in bestand", + "roleTextImportEmptyDescription": "Het bestand bevat geen importeerbare items.", "retryAttempts": "Herhaal Pogingen", "expectedResponseCodes": "Verwachte Reactiecodes", "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Proxy Protocol inschakelen", "proxyProtocolInfo": "Behoud IP adressen van de client voor TCP backends", "proxyProtocolVersion": "Proxy Protocol Versie", - "version1": " Versie 1 (Aanbevolen)", + "version1": "Versie 1 (Aanbevolen)", "version2": "Versie 2", - "versionDescription": "Versie 1 is text-based en breed ondersteund. Versie 2 is binair en efficiënter maar minder compatibel.", + "version1Description": "Tekstgebaseerd en breed ondersteund. Zorg ervoor dat servertransport aan dynamische configuratie is toegevoegd.", + "version2Description": "Binair en efficiënter maar minder compatibel. Zorg ervoor dat servertransport aan dynamische configuratie is toegevoegd.", "warning": "Waarschuwing", "proxyProtocolWarning": "De backend applicatie moet worden geconfigureerd om Proxy Protocol verbindingen te accepteren. Als je backend geen Proxy Protocol ondersteunt, zal het inschakelen van dit alle verbindingen verbreken, dus schakel dit alleen in als je weet wat je doet. Zorg ervoor dat je je backend configureert om Proxy Protocol headers van Traefik.", "restarting": "Herstarten...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Bevestiging invoeren", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standaard Identiteitsprovider", - "defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.", + "defaultIdentityProviderDescription": "De gebruiker wordt automatisch doorgestuurd naar deze identity provider voor authenticatie.", "editInternalResourceDialogNetworkSettings": "Netwerkinstellingen", "editInternalResourceDialogAccessPolicy": "Toegangsbeleid", "editInternalResourceDialogAddRoles": "Rollen toevoegen", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Type onderhoudsmodus", "showMaintenancePage": "Toon een onderhoudspagina aan bezoekers", "enableMaintenanceMode": "Onderhoudsmodus inschakelen", + "enableMaintenanceModeDescription": "Wanneer ingeschakeld zien bezoekers een onderhoudspagina in plaats van uw bron.", "automatic": "Automatisch", "automaticModeDescription": " Toon onderhoudspagina alleen wanneer alle back-enddoelen niet beschikbaar zijn of ongezond zijn. Jouw bron blijft normaal functioneren zolang er tenminste één doel gezond is.", "forced": "Geforceerd", @@ -3082,6 +3182,8 @@ "warning:": "Waarschuwing:", "forcedeModeWarning": "Al het verkeer wordt naar de onderhoudspagina geleid. Jouw back-endbronnen ontvangen geen verzoeken.", "pageTitle": "Paginatitel", + "maintenancePageContentSubsection": "Pagina-inhoud", + "maintenancePageContentSubsectionDescription": "Pas de inhoud aan die op de onderhoudspagina wordt weergegeven", "pageTitleDescription": "De hoofdkop die op de onderhoudspagina wordt weergegeven", "maintenancePageMessage": "Onderhoudsbericht", "maintenancePageMessagePlaceholder": "We keren snel terug! Onze site ondergaat momenteel gepland onderhoud.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Weet u zeker dat u deze identiteitsprovider van deze organisatie wilt loskoppelen?", "idpUnassociateDescription": "Alle gebruikers die aan deze identiteitsprovider zijn gekoppeld, worden uit deze organisatie verwijderd, maar de identiteitsprovider blijft bestaan voor andere gerelateerde organisaties.", "idpUnassociateConfirm": "Bevestig ontkoppelen identiteitsprovider", + "idpConfirmDeleteAndRemoveMeFromOrg": "VERWIJDER EN VERWIJDER ME VAN ORG", + "idpUnassociateAndRemoveMeFromOrg": "ONTKOPPEL EN VERWIJDER ME VAN ORG", "idpUnassociateWarning": "Dit kan niet ongedaan worden gemaakt voor deze organisatie.", "idpUnassociatedDescription": "Identiteitsprovider succesvol losgekoppeld van deze organisatie", "idpUnassociateMenu": "Ontkoppelen", @@ -3439,18 +3543,58 @@ "sshConnecting": "Verbinding maken…", "sshInitializing": "Initialiseren…", "sshSignInTitle": "Meld u aan bij SSH", - "sshSignInDescription": "Voer uw SSH-referenties in", + "sshSignInDescription": "Voer uw SSH-gegevens in om verbinding te maken", "sshPasswordTab": "Wachtwoord", "sshPrivateKeyTab": "Privésleutel", "sshPrivateKeyField": "Privésleutel", "sshPrivateKeyDisclaimer": "Uw privésleutel wordt niet opgeslagen of zichtbaar gemaakt voor Pangolin. U kunt ook gebruik maken van kortlopende certificaten voor naadloze authenticatie met uw bestaande Pangolin-identiteit.", "sshLearnMore": "Meer informatie", "sshPrivateKeyFile": "Bestand met privésleutel", - "sshAuthenticate": "Authenticeren", + "sshAuthenticate": "Verbinden", "sshTerminate": "Beëindigen", "sshPoweredBy": "Aangeboden door", "sshErrorNoTarget": "Geen doelwit gespecificeerd", "sshErrorWebSocket": "WebSocket-verbinding is mislukt", "sshErrorAuthFailed": "Authenticatie mislukt", - "sshErrorConnectionClosed": "Verbinding gesloten voordat authenticatie was voltooid" + "sshErrorConnectionClosed": "Verbinding gesloten voordat authenticatie was voltooid", + "sitePangolinSshDescription": "Sta SSH-toegang toe tot bronnen op deze site. Dit kan later worden gewijzigd.", + "browserGatewayNoResourceForDomain": "Geen bron gevonden voor dit domein", + "browserGatewayNoTarget": "Geen doelwit", + "browserGatewayConnect": "Verbinden", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Kan SSH-sleutel voor PAM push-authenticatie niet ondertekenen. Heeft u als gebruiker aangemeld?", + "sshTerminalError": "Fout: {error}", + "sshConnectionClosedCode": "Verbinding gesloten (code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Privésleutel is vereist", + "vncTitle": "VNC", + "vncSignInDescription": "Voer uw VNC-wachtwoord in om verbinding te maken", + "vncPasswordOptional": "Wachtwoord (optioneel)", + "vncNoResourceTarget": "Geen bron doelwit beschikbaar", + "vncFailedToLoadNovnc": "Laden van noVNC mislukt", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Klembord plakken", + "rdpTitle": "RDP", + "rdpSignInTitle": "Meld u aan bij Remote Desktop", + "rdpSignInDescription": "Voer Windows-gegevens in om verbinding te maken", + "rdpLoadingModule": "Module laden...", + "rdpFailedToLoadModule": "Laden van RDP-module mislukt", + "rdpNotReady": "Niet gereed", + "rdpModuleInitializing": "RDP-module is nog aan het initialiseren", + "rdpDownloadingFiles": "{count} bestand(en) worden van een externe locatie gedownload…", + "rdpDownloadFailed": "Download mislukt: {fileName}", + "rdpUploaded": "Geüpload: {fileName}", + "rdpNoConnectionTarget": "Geen verbinding doelwit beschikbaar", + "rdpConnectionFailed": "Verbinding mislukt", + "rdpFit": "Schalen", + "rdpFull": "Volledig", + "rdpReal": "Reëel", + "rdpMeta": "Meta", + "rdpUploadFiles": "Bestanden uploaden", + "rdpFilesReadyToPaste": "Bestanden klaar om te plakken", + "rdpFilesReadyToPasteDescription": "{count} bestand(en) gekopieerd naar klembord op afstand — druk op Ctrl+V op het externe bureaublad om te plakken.", + "rdpUploadFailed": "Upload mislukt", + "rdpUnicodeKeyboardMode": "Unicode toetsenbordmodus", + "sessionToolbarShow": "Toon werkbalk", + "sessionToolbarHide": "Verberg werkbalk" } From 5b0f79a8bc9cdfcb732d32c37c3d3522a8f2d58f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:50 -0700 Subject: [PATCH 026/264] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 240 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 192 insertions(+), 48 deletions(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index e793145b3..3de2fe165 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Zobacz zasoby prywatne", "siteInstallNewt": "Zainstaluj Newt", "siteInstallNewtDescription": "Uruchom Newt w swoim systemie", + "siteInstallKubernetesDocsDescription": "Aby uzyskać więcej aktualnych informacji o instalacji Kubernetes, zobacz docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Aby uzyskać instrukcje dotyczące instalacji modemu Advantech, zobacz: docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Konfiguracja WireGuard", "WgConfigurationDescription": "Użyj następującej konfiguracji, aby połączyć się z siecią", "operatingSystem": "System operacyjny", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Możesz to zobaczyć tylko raz. Upewnij się, że skopiuj je do bezpiecznego miejsca.", "siteInfo": "Informacje o witrynie", "status": "Status", - "shareTitle": "Zarządzaj linkami udostępniania", + "shareTitle": "Zarządzaj linkami do udostępnienia", "shareDescription": "Utwórz linki do współdzielenia, aby przyznać tymczasowy lub stały dostęp do zasobów proxy", - "shareSearch": "Szukaj linków udostępnienia...", - "shareCreate": "Utwórz link udostępniania", + "shareSearch": "Wyszukaj linki do udostępnienia...", + "shareCreate": "Utwórz link do udostępnienia", "shareErrorDelete": "Nie udało się usunąć linku", "shareErrorDeleteMessage": "Wystąpił błąd podczas usuwania linku", "shareDeleted": "Link usunięty", "shareDeletedDescription": "Link został usunięty", - "shareDelete": "Usuń link udostępniania", - "shareDeleteConfirm": "Potwierdź usunięcie linku udostępniania", + "shareDelete": "Usuń link do udostępnienia", + "shareDeleteConfirm": "Potwierdź usunięcie linku do udostępnienia", "shareQuestionRemove": "Czy na pewno chcesz usunąć ten link udostępniania?", "shareMessageRemove": "Po usunięciu, link przestanie działać i wszyscy korzystający z niego stracą dostęp do zasobu.", "shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Każdy z tym linkiem może uzyskać dostęp do zasobu", "shareTitleOptional": "Tytuł (opcjonalnie)", "sharePathOptional": "Ścieżka (opcjonalnie)", + "sharePathDescription": "Link przekieruje użytkowników do tej ścieżki po uwierzytelnieniu.", "expireIn": "Wygasa za", "neverExpire": "Nigdy nie wygasa", "shareExpireDescription": "Czas wygaśnięcia to jak długo link będzie mógł być użyty i zapewni dostęp do zasobu. Po tym czasie link nie będzie już działał, a użytkownicy, którzy użyli tego linku, utracą dostęp do zasobu.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Wybierz zasób", "proxyResourceTitle": "Zarządzaj zasobami publicznymi", "proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej", - "publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", - "publicResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", + "publicResourcesBannerTitle": "Publiczny dostęp przez przeglądarkę internetową", + "publicResourcesBannerDescription": "Zasoby publiczne to serwery proxy HTTPS, dostępne dla każdego w Internecie za pośrednictwem przeglądarki. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", "clientResourceTitle": "Zarządzaj zasobami prywatnymi", "clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta", "privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu", @@ -209,15 +212,19 @@ "resourcesSearch": "Szukaj zasobów...", "resourceAdd": "Dodaj zasób", "resourceErrorDelte": "Błąd podczas usuwania zasobu", - "resourcePoliciesTitle": "Zarządzaj politykami zasobów", - "resourcePoliciesAttachedResourcesColumnTitle": "Dołączone zasoby", + "resourcePoliciesBannerTitle": "Ponownie użyj Uwierzytelniania i Zasad Dostępu", + "resourcePoliciesBannerDescription": "Polityki zasobów współdzielonych pozwalają zdefiniować metody uwierzytelniania oraz zasady dostępu jednokrotnie, a następnie przypiąć je do wielu zasobów publicznych. Po zaktualizowaniu polityki każda powiązana zasób automatycznie dziedziczy zmianę.", + "resourcePoliciesBannerButtonText": "Dowiedz się więcej", + "resourcePoliciesTitle": "Zarządzaj publicznymi zasadami zasobów", + "resourcePoliciesAttachedResourcesColumnTitle": "Zasoby", "resourcePoliciesAttachedResources": "{count} zasób(y)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# zasób} few {# zasoby} many {# zasobów} other {# zasobów}}", "resourcePoliciesAttachedResourcesEmpty": "brak zasobów", - "resourcePoliciesDescription": "Twórz i zarządzaj politykami uwierzytelniania, aby kontrolować dostęp do swoich zasobów", + "resourcePoliciesDescription": "Twórz i zarządzaj politykami uwierzytelniania, aby kontrolować dostęp do swoich zasobów publicznych", "resourcePoliciesSearch": "Szukaj polityk...", "resourcePoliciesAdd": "Dodaj politykę", "resourcePoliciesDefaultBadgeText": "Domyślna polityka", - "resourcePoliciesCreate": "Utwórz politykę zasobu", + "resourcePoliciesCreate": "Utwórz publiczną politykę zasobów", "resourcePoliciesCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nową politykę", "resourcePolicyName": "Nazwa polityki", "resourcePolicyNameDescription": "Nadaj tej polityce nazwę, aby można ją było zidentyfikować w całych zasobach", @@ -274,7 +281,7 @@ "back": "Powrót", "cancel": "Anuluj", "resourceConfig": "Snippety konfiguracji", - "resourceConfigDescription": "Skopiuj i wklej te fragmenty konfiguracji, aby skonfigurować zasób TCP/UDP", + "resourceConfigDescription": "Skopiuj i wklej te fragmenty konfiguracji, aby skonfigurować zasób TCP/UDP.", "resourceAddEntrypoints": "Traefik: Dodaj punkty wejścia", "resourceExposePorts": "Gerbil: Podnieś porty w Komponencie Dockera", "resourceLearnRaw": "Dowiedz się, jak skonfigurować zasoby TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Usuń etykietę", "labelAdd": "Dodaj etykietę", "labelCreateSuccessMessage": "Etykieta została utworzona pomyślnie", + "labelDuplicateError": "Zduplikowana etykieta", + "labelDuplicateErrorDescription": "Etykieta o tej nazwie już istnieje.", "labelEditSuccessMessage": "Etykieta została pomyślnie zmodyfikowana", "labelNameField": "Nazwa etykiety", "labelColorField": "Kolor etykiety", @@ -311,7 +320,7 @@ "rules": "Regulamin", "resourceSettingDescription": "Skonfiguruj ustawienia zasobu", "resourceSetting": "Ustawienia {resourceName}", - "resourcePolicySettingDescription": "Skonfiguruj ustawienia w polityce zasobów", + "resourcePolicySettingDescription": "Skonfiguruj ustawienia tej publicznej polityki zasobów", "resourcePolicySetting": "Ustawienia {policyName}", "alwaysAllow": "Omijanie uwierzytelniania", "alwaysDeny": "Blokuj dostęp", @@ -719,7 +728,7 @@ "targetSubmit": "Dodaj cel", "targetNoOne": "Ten zasób nie ma żadnych celów. Dodaj cel do skonfigurowania adresów wysyłania żądań do backendu.", "targetNoOneDescription": "Dodanie więcej niż jednego celu powyżej włączy równoważenie obciążenia.", - "targetsSubmit": "Zapisz cele", + "targetsSubmit": "Zapisz ustawienia", "addTarget": "Dodaj cel", "proxyMultiSiteRoundRobinNodeHelp": "Trasowanie round-robin nie będzie działać między witrynami, które nie są połączone z tym samym węzłem, ale przełączanie awaryjne będzie działać.", "targetErrorInvalidIp": "Nieprawidłowy adres IP", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Duplikat reguły", "rulesErrorDuplicateDescription": "Reguła o tych ustawieniach już istnieje", "rulesErrorInvalidIpAddressRange": "Nieprawidłowy CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Wprowadź prawidłową wartość CIDR", - "rulesErrorInvalidUrl": "Nieprawidłowa ścieżka URL", - "rulesErrorInvalidUrlDescription": "Wprowadź prawidłową wartość ścieżki URL", - "rulesErrorInvalidIpAddress": "Nieprawidłowe IP", - "rulesErrorInvalidIpAddressDescription": "Wprowadź prawidłowy adres IP", + "rulesErrorInvalidIpAddressRangeDescription": "Wprowadź poprawny zakres CIDR (np. 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Nieprawidłowa ścieżka", + "rulesErrorInvalidUrlDescription": "Wprowadź poprawną ścieżkę URL lub wzorzec (np. /api/*).", + "rulesErrorInvalidIpAddress": "Nieprawidłowy adres IP", + "rulesErrorInvalidIpAddressDescription": "Wprowadź poprawny adres IPv4 lub IPv6.", "rulesErrorUpdate": "Nie udało się zaktualizować reguł", "rulesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji reguł", "rulesUpdated": "Włącz reguły", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Wprowadź adres IP (np. 103.21.244.12)", "rulesMatchUrl": "Wprowadź ścieżkę URL lub wzorzec (np. /api/v1/todos lub /api/v1/*)", "rulesErrorInvalidPriority": "Nieprawidłowy priorytet", - "rulesErrorInvalidPriorityDescription": "Wprowadź prawidłowy priorytet", + "rulesErrorInvalidPriorityDescription": "Wprowadź liczbę całkowitą 1 lub wyższą.", "rulesErrorDuplicatePriority": "Zduplikowane priorytety", - "rulesErrorDuplicatePriorityDescription": "Wprowadź unikalne priorytety", + "rulesErrorDuplicatePriorityDescription": "Każda reguła musi mieć unikalny numer priorytetu.", + "rulesErrorValidation": "Nieprawidłowe reguły", + "rulesErrorValidationRuleDescription": "Reguła {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Wybierz poprawny typ dopasowania (ścieżka, IP, CIDR, kraj, region lub ASN).", + "rulesErrorValueRequired": "Wprowadź wartość dla tej reguły.", + "rulesErrorInvalidCountry": "Nieprawidłowy kraj", + "rulesErrorInvalidCountryDescription": "Wybierz poprawny kraj.", + "rulesErrorInvalidAsn": "Nieprawidłowy ASN", + "rulesErrorInvalidAsnDescription": "Wprowadź poprawny ASN (np. AS15169).", "ruleUpdated": "Reguły zaktualizowane", "ruleUpdatedDescription": "Reguły zostały pomyślnie zaktualizowane", "ruleErrorUpdate": "Operacja nie powiodła się", "ruleErrorUpdateDescription": "Wystąpił błąd podczas operacji zapisu", "rulesPriority": "Priorytet", + "rulesReorderDragHandle": "Przeciągnij, aby zmienić kolejność priorytetów reguł", "rulesAction": "Akcja", "rulesMatchType": "Typ dopasowania", "value": "Wartość", @@ -792,7 +810,7 @@ "rulesResource": "Konfiguracja reguł zasobu", "rulesResourceDescription": "Skonfiguruj reguły, aby kontrolować dostęp do zasobu", "ruleSubmit": "Dodaj regułę", - "rulesNoOne": "Brak reguł. Dodaj regułę używając formularza.", + "rulesNoOne": "Brak reguł.", "rulesOrder": "Reguły są oceniane według priorytetu w kolejności rosnącej.", "rulesSubmit": "Zapisz reguły", "policyErrorCreate": "Błąd przy tworzeniu polityki", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Wystąpił nieoczekiwany błąd", "policyCreatedSuccess": "Polityka zasobów została pomyślnie utworzona", "policyUpdatedSuccess": "Polityka zasobów została pomyślnie zaktualizowana", - "authMethodsSave": "Zapisz metody uwierzytelniania", + "authMethodsSave": "Zapisz ustawienia", + "policyAuthStackTitle": "Uwierzytelnianie", + "policyAuthStackDescription": "Kontroluj, które metody uwierzytelniania są wymagane do uzyskania dostępu do tego zasobu", + "policyAuthOrLogicTitle": "Kilka metod uwierzytelniania jest aktywnych", + "policyAuthOrLogicBanner": "Odwiedzający mogą się uwierzytelnić, korzystając z jednej z poniższych aktywnych metod. Nie muszą ukończyć wszystkich z nich.", + "policyAuthMethodActive": "Aktywny", + "policyAuthMethodOff": "Wyłączony", + "policyAuthSsoTitle": "Platforma SSO", + "policyAuthSsoDescription": "Wymagany znak w identyfikatorze dostawcy Twojej organizacji", + "policyAuthSsoSummary": "{idp} · {users} użytkowników, {roles} ról", + "policyAuthSsoDefaultIdp": "Dostawca domyślny", + "policyAuthAddDefaultIdentityProvider": "Dodaj Dostawcę Tożsamości Domyślnej", + "policyAuthOtherMethodsTitle": "Inne Metody", + "policyAuthOtherMethodsDescription": "Opcjonalne metody, których odwiedzający mogą używać zamiast lub razem z platformą SSO", + "policyAuthPasscodeTitle": "Hasło dostępu", + "policyAuthPasscodeDescription": "Wymagane wspólne hasło alfanumeryczne do uzyskania dostępu do zasobu", + "policyAuthPasscodeSummary": "Zestaw hasła dostępu", + "policyAuthPincodeTitle": "Kod PIN", + "policyAuthPincodeDescription": "Krótki kod numeryczny wymagany do uzyskania dostępu do zasobu", + "policyAuthPincodeSummary": "Ustawiono 6-cyfrowy kod PIN", + "policyAuthEmailTitle": "Biała lista e-mail", + "policyAuthEmailDescription": "Dozwolone adresy e-mail z hasłami jednorazowymi", + "policyAuthEmailSummary": "Dozwolonych {count} adresów", + "policyAuthEmailOtpCallout": "Włączenie białej listy e-mail wysyła hasło jednorazowe na e-mail odwiedzającego podczas logowania.", + "policyAuthHeaderAuthTitle": "Podstawowe Uwierzytelnianie Nagłówka", + "policyAuthHeaderAuthDescription": "Walidacja niestandardowej nazwy i wartości nagłówka HTTP przy każdym żądaniu", + "policyAuthHeaderAuthSummary": "Skonfigurowany nagłówek", + "policyAuthHeaderName": "Nazwa nagłówka", + "policyAuthHeaderValue": "Oczekiwana wartość", + "policyAuthSetPasscode": "Ustaw hasło dostępu", + "policyAuthSetPincode": "Ustaw kod PIN", + "policyAuthSetEmailWhitelist": "Ustaw białą listę e-mail", + "policyAuthSetHeaderAuth": "Ustaw Podstawowe Uwierzytelnianie Nagłówka", + "policyAccessRulesTitle": "Zasady Dostępu", + "policyAccessRulesEnableDescription": "Gdy zostaną włączone, reguły są oceniane w kolejności malejącej, aż jedna z nich zostanie oceniona jako prawdziwa.", + "policyAccessRulesFirstMatch": "Reguły są oceniane od góry do dołu. Pierwsza pasująca reguła decyduje o wyniku.", + "policyAccessRulesHowItWorks": "Reguły dopasowują żądania według ścieżki, adresu IP, lokalizacji lub innych kryteriów. Każda reguła stosuje działanie: pominięcie uwierzytelniania, blokowanie dostępu lub przekazanie do uwierzytelniania. Jeśli żadna reguła nie pasuje, ruch przechodzi dalej do uwierzytelniania.", + "policyAccessRulesFallthroughOff": "Gdy reguły są wyłączone, cały ruch przechodzi do uwierzytelniania.", + "policyAccessRulesFallthroughOn": "Gdy żadna reguła nie pasuje, ruch przechodzi do uwierzytelniania.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Zapisz zasady", "resourceErrorCreate": "Błąd podczas tworzenia zasobu", "resourceErrorCreateDescription": "Wystąpił błąd podczas tworzenia zasobu", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu", "access": "Dostęp", "accessControl": "Kontrola dostępu", - "shareLink": "Link udostępniania {resource}", + "shareLink": "{resource} Link do udostępnienia", "resourceSelect": "Wybierz zasób", - "shareLinks": "Linki udostępniania", + "shareLinks": "Linki do udostępnienia", "share": "Linki do udostępniania", "shareDescription2": "Utwórz linki do zasobów, które można współdzielić. Linki zapewniają tymczasowy lub nieograniczony dostęp do twojego zasobu. Możesz skonfigurować czas ważności linku, gdy go utworzysz.", "shareEasyCreate": "Łatwe tworzenie i udostępnianie", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Administratorzy zawsze mają dostęp do tego zasobu.", "resourcePolicySelectTitle": "Polityka dostępu do zasobów", "resourcePolicySelectDescription": "Wybierz typ polityki zasobów do uwierzytelniania", + "resourcePolicyTypeLabel": "Typ polityki", + "resourcePolicyLabel": "Polityka zasobów", "resourcePolicyInline": "Warunkowa polityka zasobów", "resourcePolicyInlineDescription": "Polityka dostępu tylko do tego zasobu", "resourcePolicyShared": "Dzielona polityka zasobów", - "resourcePolicySharedDescription": "Ten zasób korzysta z dzielonej polityki. Ustawienia na poziomie polityki (metody uwierzytelniania, biała lista e-maili) są zablokowane. Możesz dodać zasady specyficzne dla zasobów, role i użytkowników poniżej.", + "resourcePolicySharedDescription": "Ten zasób korzysta z polityki współdzielonej.", + "sharedPolicy": "Polityka Współdzielona", + "sharedPolicyNoneDescription": "Ten zasób ma własną politykę.", + "resourceSharedPolicyOwnDescription": "Ten zasób ma własne kontrole zasad uwierzytelniania i dostępu.", + "resourceSharedPolicyInheritedDescription": "Ten zasób dziedziczy z {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Ten zasób używa polityki współdzielonej. Niektóre ustawienia uwierzytelniania można edytować w tym zasobie, aby dodać do polityki. Aby zmienić podlegającą politykę, musisz ją edytować do {policyName}.", + "resourceSharedPolicyRulesNotice": "Ten zasób używa polityki współdzielonej. Niektóre zasady dostępu można edytować w tym zasobie. Aby zmienić podlegającą politykę, musisz edytować {policyName}.", "resourceUsersRoles": "Kontrola dostępu", "resourceUsersRolesDescription": "Skonfiguruj, którzy użytkownicy i role mogą odwiedzać ten zasób", "resourceUsersRolesSubmit": "Zapisz kontrole dostępu", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Widoczność", "resourceVisibilityTitleDescription": "Całkowicie włącz lub wyłącz widoczność zasobu", "resourceGeneral": "Ustawienia ogólne", - "resourceGeneralDescription": "Skonfiguruj ustawienia ogólne dla tego zasobu", + "resourceGeneralDescription": "Skonfiguruj nazwę, adres i zasady dostępu dla tego zasobu.", + "resourceGeneralDetailsSubsection": "Szczegóły zasobu", + "resourceGeneralDetailsSubsectionDescription": "Ustaw nazwę wyświetlaną, identyfikator i publicznie dostępna domenę dla tego zasobu.", + "resourceGeneralDetailsSubsectionPortDescription": "Ustaw nazwę wyświetlaną, identyfikator i publiczny port dla tego zasobu.", + "resourceGeneralPublicAddressSubsection": "Publiczny Adres", + "resourceGeneralPublicAddressSubsectionDescription": "Skonfiguruj, jak użytkownicy mogą dotrzeć do tego zasobu.", + "resourceGeneralAuthenticationAccessSubsection": "Uwierzytelnianie i Dostęp", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Wybierz, czy ten zasób używa własnej polityki, czy dziedziczy z polityki współdzielonej.", "resourceEnable": "Włącz zasób", "resourceTransfer": "Przenieś zasób", "resourceTransferDescription": "Przenieś ten zasób do innej witryny", @@ -1220,11 +1294,14 @@ "addLabels": "Dodaj etykiety", "siteLabelsTab": "Etykiety", "siteLabelsDescription": "Zarządzaj etykietami powiązanymi z tą stroną.", - "labelsNotFound": "Nie znaleziono etykiet", + "labelsNotFound": "Nie znaleziono etykiet.", + "labelsEmptyCreateHint": "Zacznij pisać powyżej, aby utworzyć etykietę.", "labelSearch": "Szukaj etykiet", + "labelSearchOrCreate": "Wyszukaj lub utwórz etykietę", "accessLabelFilterCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "labelOverflowCount": "+{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "accessLabelFilterClear": "Wyczyść filtry etykiet", + "accessFilterClear": "Wyczyść filtry", "selectColor": "Wybierz kolor", "createNewLabel": "Utwórz nową etykietę org \"{label}\"", "inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Zasoby", "sidebarProxyResources": "Publiczne", "sidebarClientResources": "Prywatny", - "sidebarPolicies": "Polityki", - "sidebarResourcePolicies": "Zasoby", + "sidebarPolicies": "Polityki Współdzielone", + "sidebarResourcePolicies": "Zasoby publiczne", "sidebarAccessControl": "Kontrola dostępu", "sidebarLogsAndAnalytics": "Logi i Analityki", "sidebarTeam": "Drużyna", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Administrator", "sidebarInvitations": "Zaproszenia", "sidebarRoles": "Role", - "sidebarShareableLinks": "Linki", + "sidebarShareableLinks": "Linki do udostępnienia", "sidebarApiKeys": "Klucze API", "sidebarProvisioning": "Dostarczanie", "sidebarSettings": "Ustawienia", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Zasób {id}", "blueprints": "Schematy", "blueprintsLog": "Dziennik szablonów", - "blueprintsDescription": "Zobacz wcześniejsze zastosowania szablonów i ich wyniki", + "blueprintsDescription": "Przeglądaj wcześniejsze aplikacje wzorców i ich wyniki lub zastosuj nowy wzorzec", "blueprintAdd": "Dodaj schemat", "blueprintGoBack": "Zobacz wszystkie schematy", "blueprintCreate": "Utwórz schemat", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Włącz schemat dokera", "enableDockerSocketDescription": "Włącz etykietowanie gniazda dokera dla etykiet szablonów. Ścieżka do gniazda musi być dostarczona do łącznika strony. Przeczytaj zarówno jak to działa w dokumentacji.", "newtAutoUpdate": "Włącz automatyczną aktualizację witryny", - "newtAutoUpdateDescription": "Kiedy włączone, łączniki witryn będą się automatycznie aktualizować do najnowszej wersji, gdy dostępne będzie nowe wydanie.", + "newtAutoUpdateDescription": "Po włączeniu, łączniki witryn automatycznie pobiorą najnowszą wersję i uruchomią się ponownie. Można to nadpisać na poziomie poszczególnych witryn.", "siteAutoUpdate": "Automatyczna aktualizacja strony", "siteAutoUpdateLabel": "Włącz aktualizacje automatyczne", - "siteAutoUpdateDescription": "Kontroluj czy łącznik tej strony automatycznie pobiera najnowszą wersję.", + "siteAutoUpdateDescription": "Po włączeniu, łącznik tej witryny automatycznie pobierze najnowszą wersję i uruchomi się ponownie.", "siteAutoUpdateOrgDefault": "Domyślnie dla organizacji: {state}", "siteAutoUpdateOverriding": "Nadpisywanie ustawień organizacji", "siteAutoUpdateResetToOrg": "Zresetuj do domyślnych ustawień organizacji", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Konfiguracja konta zakończona! Witaj w Pangolin!", "documentation": "Dokumentacja", "saveAllSettings": "Zapisz wszystkie ustawienia", - "saveResourceTargets": "Zapisz cele", - "saveResourceHttp": "Zapisz ustawienia proxy", - "saveProxyProtocol": "Zapisz ustawienia protokołu proxy", + "saveResourceTargets": "Zapisz ustawienia", + "saveResourceHttp": "Zapisz ustawienia", + "saveProxyProtocol": "Zapisz ustawienia", "settingsUpdated": "Ustawienia zaktualizowane", "settingsUpdatedDescription": "Ustawienia zostały pomyślnie zaktualizowane", "settingsErrorUpdate": "Nie udało się zaktualizować ustawień", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Nieznany", "healthCheck": "Kontrola Zdrowia", "configureHealthCheck": "Skonfiguruj Kontrolę Zdrowia", - "configureHealthCheckDescription": "Skonfiguruj monitorowanie zdrowia dla {target}", + "configureHealthCheckDescription": "Skonfiguruj monitorowanie zasobu, aby zapewnić jego dostępność", "enableHealthChecks": "Włącz Kontrole Zdrowia", "healthCheckDisabledStateDescription": "Gdy wyłączone, strona nie będzie wykonywać kontroli zdrowia, a stan zostanie uznany za nieznany.", "enableHealthChecksDescription": "Monitoruj zdrowie tego celu. Możesz monitorować inny punkt końcowy niż docelowy w razie potrzeby.", "healthScheme": "Metoda", "healthSelectScheme": "Wybierz metodę", - "healthCheckPortInvalid": "Port oceny stanu musi znajdować się między 1 a 65535", + "healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535", "healthCheckPath": "Ścieżka", "healthHostname": "IP / Nazwa hosta", "healthPort": "Port", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Wymagaj zatwierdzenia urządzenia", "requireDeviceApprovalDescription": "Użytkownicy o tej roli potrzebują nowych urządzeń zatwierdzonych przez administratora, zanim będą mogli połączyć się i uzyskać dostęp do zasobów.", "sshSettings": "Ustawienia SSH", + "sshAccess": "Dostęp SSH", "rdpSettings": "Ustawienia RDP", "vncSettings": "Ustawienia VNC", "sshServer": "Serwer SSH", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Upewnij się, że Twoja maszyna docelowa jest poprawnie skonfigurowana do uruchamiania demona uwierzytelniania zanim ukończysz tę konfigurację, w przeciwnym razie provisioning zakończy się niepowodzeniem.", "sshDaemonPort": "Port Demona", "sshServerDestination": "Miejsce docelowe serwera", - "sshServerDestinationDescription": "Skonfiguruj miejsce docelowe i port serwera SSH", + "sshServerDestinationDescription": "Skonfiguruj miejsce docelowe serwera SSH", "destination": "Miejsce docelowe", + "destinationRequired": "Wymagane jest miejsce docelowe.", + "domainRequired": "Wymagana jest domena.", + "proxyPortRequired": "Wymagany jest port.", + "invalidPathConfiguration": "Nieprawidłowa konfiguracja ścieżki.", + "invalidRewritePathConfiguration": "Nieprawidłowa konfiguracja ścieżki modyfikacji.", "bgTargetMultiSiteDisclaimer": "Wybór wielu stron umożliwia odporność trasowania i zmienioność dla wysokiej dostępności.", "roleAllowSsh": "Zezwalaj na SSH", "roleAllowSshAllow": "Zezwól", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.", "sshSudo": "Zezwól na sudo", "sshSudoCommands": "Komendy Sudo", - "sshSudoCommandsDescription": "Lista rozdzielona przecinkami poleceń, które użytkownik może uruchomić z sudo. Należy używać ścieżek bezwzględnych.", + "sshSudoCommandsDescription": "Lista poleceń, które użytkownik może uruchomić z sudo, oddzielone przecinkami, spacjami lub nowymi liniami. Absolutne ścieżki muszą być używane.", "sshCreateHomeDir": "Utwórz katalog domowy", "sshUnixGroups": "Grupy Unix", - "sshUnixGroupsDescription": "Oddzielone przecinkami grupy Unix, aby dodać użytkownika do docelowego hosta.", + "sshUnixGroupsDescription": "Grupy Uniksowe, do których dodać użytkownika na docelowym hoście, oddzielone przecinkami, spacjami, lub nowymi liniami.", + "roleTextFieldPlaceholder": "Wprowadź wartości lub upuść plik .txt lub .csv", + "roleTextImportTitle": "Importuj z pliku", + "roleTextImportDescription": "Importowanie {fileName} do {fieldLabel}.", + "roleTextImportSkipHeader": "Pomiń pierwszy wiersz (Nagłówek)", + "roleTextImportOverride": "Zamień istniejące", + "roleTextImportAppend": "Dołącz do istniejącego", + "roleTextImportMode": "Tryb importu", + "roleTextImportPreview": "Podgląd", + "roleTextImportItemCount": "{count, plural, =0 {Brak elementów do zaimportowania} one {1 element do zaimportowania} few {# elementy do zaimportowania} many {# elementów do zaimportowania} other {# elementów do zaimportowania}}", + "roleTextImportTotalCount": "{existing} istniejące + {imported} zaimportowane = {total} łącznie", + "roleTextImportConfirm": "Importuj", + "roleTextImportInvalidFile": "Nieobsługiwany typ pliku", + "roleTextImportInvalidFileDescription": "Obsługiwane są tylko pliki .txt i .csv.", + "roleTextImportEmpty": "Nie znaleziono elementów w pliku", + "roleTextImportEmptyDescription": "Plik nie zawiera żadnych elementów możliwych do zaimportowania.", "retryAttempts": "Próby Ponowienia", "expectedResponseCodes": "Oczekiwane Kody Odpowiedzi", "expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Włącz protokół proxy", "proxyProtocolInfo": "Zachowaj adresy IP klienta dla backendów TCP", "proxyProtocolVersion": "Wersja protokołu proxy", - "version1": " Wersja 1 (zalecane)", + "version1": "Wersja 1 (Zalecane)", "version2": "Wersja 2", - "versionDescription": "Wersja 1 jest oparta na tekście i szeroko wspierana. Wersja 2 jest binarna i bardziej efektywna, ale mniej kompatybilna.", + "version1Description": "Oparta na tekście i szeroko wspierana. Upewnij się, że transport serwera został dodany do dynamicznej konfiguracji.", + "version2Description": "Binarna i bardziej efektywna, ale mniej kompatybilna. Upewnij się, że transport serwera został dodany do dynamicznej konfiguracji.", "warning": "Ostrzeżenie", "proxyProtocolWarning": "Aplikacja backend musi być skonfigurowana do akceptowania połączeń protokołu proxy. Jeśli Twój backend nie obsługuje protokołu Proxy, włączenie tego spowoduje przerwanie wszystkich połączeń, więc włącz to tylko jeśli wiesz, co robisz. Upewnij się, że konfiguracja twojego backendu do zaufanych nagłówków protokołu proxy z Traefik.", "restarting": "Restartowanie...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Wprowadź potwierdzenie", "blueprintViewDetails": "Szczegóły", "defaultIdentityProvider": "Domyślny dostawca tożsamości", - "defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.", + "defaultIdentityProviderDescription": "Użytkownik zostanie automatycznie przekierowany do tego dostawcy tożsamości w celu uwierzytelnienia.", "editInternalResourceDialogNetworkSettings": "Ustawienia sieci", "editInternalResourceDialogAccessPolicy": "Polityka dostępowa", "editInternalResourceDialogAddRoles": "Dodaj role", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Typ trybu konserwacji", "showMaintenancePage": "Pokaż odwiedzającym stronę konserwacji", "enableMaintenanceMode": "Włącz tryb konserwacji", + "enableMaintenanceModeDescription": "Gdy włączone, odwiedzający zobaczą stronę konserwacyjną zamiast Twojego zasobu.", "automatic": "Automatycznie", "automaticModeDescription": "Pokaż stronę konserwacyjną tylko wtedy, gdy wszystkie cele zaplecza są wyłączone lub niezdrowe. Twój zasób działa nadal normalnie, o ile przynajmniej jeden cel jest zdrowy.", "forced": "Wymuszone", @@ -3082,6 +3182,8 @@ "warning:": "Ostrzeżenie:", "forcedeModeWarning": "Cały ruch zostanie skierowany na stronę konserwacyjną. Twoje zasoby zaplecza nie otrzymają żadnych żądań.", "pageTitle": "Tytuł strony", + "maintenancePageContentSubsection": "Zawartość strony", + "maintenancePageContentSubsectionDescription": "Dostosuj treść wyświetlaną na stronie konserwacyjnej", "pageTitleDescription": "Główny nagłówek wyświetlany na stronie konserwacyjnej", "maintenancePageMessage": "Komunikat konserwacyjny", "maintenancePageMessagePlaceholder": "Wrócimy wkrótce! Nasza strona przechodzi obecnie zaplanowaną konserwację.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Czy na pewno chcesz odłączyć tego dostawcę tożsamości od tej organizacji?", "idpUnassociateDescription": "Wszystkie użytkownicy powiązani z tym dostawcą tożsamości zostaną usunięci z tej organizacji, ale dostawca tożsamości będzie nadal istniał dla innych powiązanych organizacji.", "idpUnassociateConfirm": "Potwierdź odłączenie dostawcy tożsamości", + "idpConfirmDeleteAndRemoveMeFromOrg": "USUŃ I USUŃ MNIE Z ORGANIZACJI", + "idpUnassociateAndRemoveMeFromOrg": "ODSTAW I USUŃ MNIE Z ORGANIZACJI", "idpUnassociateWarning": "Tego nie można cofnąć dla tej organizacji.", "idpUnassociatedDescription": "Dostawca tożsamości pomyślnie odłączony od tej organizacji", "idpUnassociateMenu": "Odłącz", @@ -3439,18 +3543,58 @@ "sshConnecting": "Łączenie…", "sshInitializing": "Inicjalizacja…", "sshSignInTitle": "Zaloguj się do SSH", - "sshSignInDescription": "Wprowadź swoje poświadczenia SSH", + "sshSignInDescription": "Wprowadź poświadczenia SSH, aby się połączyć", "sshPasswordTab": "Hasło", "sshPrivateKeyTab": "Klucz prywatny", "sshPrivateKeyField": "Klucz prywatny", "sshPrivateKeyDisclaimer": "Twój klucz prywatny nie jest przechowywany ani widoczny dla Pangolin. Alternatywnie, możesz używać certyfikatów krótkoterminowych do bezproblemowego uwierzytelniania za pomocą Twojej istniejącej tożsamości Pangolin.", "sshLearnMore": "Dowiedz się więcej", "sshPrivateKeyFile": "Plik klucza prywatnego", - "sshAuthenticate": "Uwierzytelnij", + "sshAuthenticate": "Połącz", "sshTerminate": "Zakończ", "sshPoweredBy": "Obsługiwane przez", "sshErrorNoTarget": "Nie określono celu", "sshErrorWebSocket": "Połączenie WebSocket nie powiodło się", "sshErrorAuthFailed": "Uwierzytelnianie nie powiodło się", - "sshErrorConnectionClosed": "Połączenie zamknięte przed ukończeniem uwierzytelniania" + "sshErrorConnectionClosed": "Połączenie zamknięte przed ukończeniem uwierzytelniania", + "sitePangolinSshDescription": "Pozwól na dostęp SSH do zasobów na tej stronie. Można to zmienić później.", + "browserGatewayNoResourceForDomain": "Nie znaleziono zasobu dla tej domeny", + "browserGatewayNoTarget": "Brak celu", + "browserGatewayConnect": "Połącz", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Nie udało się podpisać klucza SSH dla uwierzytelniania PAM. Czy zalogowałeś się jako użytkownik?", + "sshTerminalError": "Błąd: {error}", + "sshConnectionClosedCode": "Połączenie zamknięte (kod {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Wymagany jest klucz prywatny", + "vncTitle": "VNC", + "vncSignInDescription": "Wprowadź hasło VNC, aby się połączyć", + "vncPasswordOptional": "Hasło (opcjonalne)", + "vncNoResourceTarget": "Brak dostępnego celu zasobu", + "vncFailedToLoadNovnc": "Błąd ładowania noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Wklej schowek", + "rdpTitle": "RDP", + "rdpSignInTitle": "Zaloguj się na Pulpit Zdalny", + "rdpSignInDescription": "Wprowadź poświadczenia Windows, aby się połączyć", + "rdpLoadingModule": "Ładowanie modułu...", + "rdpFailedToLoadModule": "Nie udało się załadować modułu RDP", + "rdpNotReady": "Nie gotowy", + "rdpModuleInitializing": "Moduł RDP jest nadal inicjalizowany", + "rdpDownloadingFiles": "Pobieranie {count} pliku(ów) zdalnego…", + "rdpDownloadFailed": "Nie udało się pobrać: {fileName}", + "rdpUploaded": "Przesłano: {fileName}", + "rdpNoConnectionTarget": "Brak dostępnego celu połączenia", + "rdpConnectionFailed": "Połączenie niepowiodło się", + "rdpFit": "Dopasuj", + "rdpFull": "Pełny", + "rdpReal": "Rzeczywisty", + "rdpMeta": "Meta", + "rdpUploadFiles": "Prześlij pliki", + "rdpFilesReadyToPaste": "Pliki gotowe do wklejenia", + "rdpFilesReadyToPasteDescription": "Skopiowano {count} plik(-ów/-i) do zdalnego schowka — naciśnij Ctrl+V na zdalnym pulpicie, aby wkleić.", + "rdpUploadFailed": "Niepowodzenie przesyłania", + "rdpUnicodeKeyboardMode": "Tryb klawiatury Unicode", + "sessionToolbarShow": "Pokaż pasek narzędzi", + "sessionToolbarHide": "Ukryj pasek narzędzi" } From 61f0bc95c79cea832d653c4c0acb4dd493740bc4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:52 -0700 Subject: [PATCH 027/264] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 244 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 194 insertions(+), 50 deletions(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 97c88ff3f..78105f403 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Visualizar Recursos Privados", "siteInstallNewt": "Instalar Novo", "siteInstallNewtDescription": "Novo item em execução no seu sistema", + "siteInstallKubernetesDocsDescription": "Para mais informações atualizadas sobre a instalação do Kubernetes, veja docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Para instruções de instalação do modem da Advantech, veja docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Configuração do WireGuard", "WgConfigurationDescription": "Use a seguinte configuração para conectar-se à rede", "operatingSystem": "Sistema operacional", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Você só será capaz de ver esta vez. Certifique-se de copiá-lo para um lugar seguro.", "siteInfo": "Informações do Site", "status": "SItuação", - "shareTitle": "Gerir links partilhados", + "shareTitle": "Gerenciar Links Compartilháveis", "shareDescription": "Criar links compartilháveis para conceder acesso temporário ou permanente aos recursos do proxy", - "shareSearch": "Pesquisar links de compartilhamento...", - "shareCreate": "Criar Link de Compartilhamento", + "shareSearch": "Pesquisar links compartilháveis...", + "shareCreate": "Criar Link Compartilhável", "shareErrorDelete": "Falha ao apagar o link", "shareErrorDeleteMessage": "Ocorreu um erro ao apagar o link", "shareDeleted": "Link excluído", "shareDeletedDescription": "O link foi eliminado", - "shareDelete": "Excluir Link de Compartilhamento", - "shareDeleteConfirm": "Confirmar Exclusão de Link de Compartilhamento", + "shareDelete": "Excluir Link Compartilhável", + "shareDeleteConfirm": "Confirmar exclusão do Link Compartilhável", "shareQuestionRemove": "Tem certeza de que deseja excluir este link de compartilhamento?", "shareMessageRemove": "Uma vez excluído, o link não funcionará mais e qualquer pessoa que o utilizar perderá o acesso ao recurso.", "shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Qualquer um com este link pode aceder o recurso", "shareTitleOptional": "Título (opcional)", "sharePathOptional": "Caminho (opcional)", + "sharePathDescription": "O link redirecionará os usuários para este caminho após a autenticação.", "expireIn": "Expira em", "neverExpire": "Nunca expirar", "shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os utilizadores que usaram este link perderão acesso ao recurso.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Por favor, selecione um recurso", "proxyResourceTitle": "Gerenciar Recursos Públicos", "proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web", - "publicResourcesBannerTitle": "Acesso Público via Web", - "publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", + "publicResourcesBannerTitle": "Acesso Público Baseado em Web", + "publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS acessíveis a qualquer pessoa na internet através de um navegador web. Ao contrário dos recursos privados, eles não exigem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", "clientResourceTitle": "Gerenciar recursos privados", "clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado", "privateResourcesBannerTitle": "Acesso Privado com Confiança Zero", @@ -209,15 +212,19 @@ "resourcesSearch": "Procurar recursos...", "resourceAdd": "Adicionar Recurso", "resourceErrorDelte": "Erro ao apagar recurso", - "resourcePoliciesTitle": "Gerenciar Políticas de Recurso", - "resourcePoliciesAttachedResourcesColumnTitle": "Recursos Anexados", + "resourcePoliciesBannerTitle": "Reutilizar Regras de Autenticação e Acesso", + "resourcePoliciesBannerDescription": "Políticas de recursos compartilhados permitem que você defina métodos de autenticação e regras de acesso apenas uma vez, e então as associe a vários recursos públicos. Quando você atualiza uma política, cada recurso vinculado herda a alteração automaticamente.", + "resourcePoliciesBannerButtonText": "Saiba mais", + "resourcePoliciesTitle": "Gerenciar Políticas de Recursos Públicos", + "resourcePoliciesAttachedResourcesColumnTitle": "Recursos", "resourcePoliciesAttachedResources": "{count} recurso(s)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# recurso} other {# recursos}}", "resourcePoliciesAttachedResourcesEmpty": "sem recursos", - "resourcePoliciesDescription": "Crie e gerencie políticas de autenticação para controlar o acesso aos seus recursos", + "resourcePoliciesDescription": "Crie e gerencie políticas de autenticação para controlar o acesso aos seus recursos públicos", "resourcePoliciesSearch": "Pesquisar políticas...", "resourcePoliciesAdd": "Adicionar Política", "resourcePoliciesDefaultBadgeText": "Política Padrão", - "resourcePoliciesCreate": "Criar Política de Recurso", + "resourcePoliciesCreate": "Criar Política de Recurso Público", "resourcePoliciesCreateDescription": "Siga os passos abaixo para criar uma nova política", "resourcePolicyName": "Nome da Política", "resourcePolicyNameDescription": "Dê um nome a esta política para identificá-la em seus recursos", @@ -274,7 +281,7 @@ "back": "Anterior", "cancel": "cancelar", "resourceConfig": "Snippets de Configuração", - "resourceConfigDescription": "Copie e cole estes snippets de configuração para configurar o recurso TCP/UDP", + "resourceConfigDescription": "Copie e cole estes trechos de configuração para configurar o recurso TCP/UDP.", "resourceAddEntrypoints": "Traefik: Adicionar pontos de entrada", "resourceExposePorts": "Gerbil: Expor Portas no Docker Compose", "resourceLearnRaw": "Aprenda como configurar os recursos TCP/UDP", @@ -287,6 +294,8 @@ "labelDelete": "Excluir Etiqueta", "labelAdd": "Adicionar Etiqueta", "labelCreateSuccessMessage": "Etiqueta Criada com Sucesso", + "labelDuplicateError": "Etiqueta Duplicada", + "labelDuplicateErrorDescription": "Já existe uma etiqueta com este nome.", "labelEditSuccessMessage": "Etiqueta Modificada com Sucesso", "labelNameField": "Nome da Etiqueta", "labelColorField": "Cor da Etiqueta", @@ -311,7 +320,7 @@ "rules": "Regras", "resourceSettingDescription": "Configure as configurações do recurso", "resourceSetting": "Configurações do {resourceName}", - "resourcePolicySettingDescription": "Configure as configurações na política de recurso", + "resourcePolicySettingDescription": "Configure as configurações nesta política de recurso público", "resourcePolicySetting": "Configurações de {policyName}", "alwaysAllow": "Autenticação de bypass", "alwaysDeny": "Bloquear Acesso", @@ -719,7 +728,7 @@ "targetSubmit": "Adicionar Alvo", "targetNoOne": "Este recurso não tem nenhum alvo. Adicione um alvo para configurar para onde enviar solicitações para o backend.", "targetNoOneDescription": "Adicionar mais de um alvo acima habilitará o balanceamento de carga.", - "targetsSubmit": "Guardar Alvos", + "targetsSubmit": "Salvar Configurações", "addTarget": "Adicionar Alvo", "proxyMultiSiteRoundRobinNodeHelp": "O roteamento round robin não funcionará entre sites que não estão conectados ao mesmo nó, mas o failover funcionará.", "targetErrorInvalidIp": "Endereço IP inválido", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Regra duplicada", "rulesErrorDuplicateDescription": "Uma regra com estas configurações já existe", "rulesErrorInvalidIpAddressRange": "CIDR inválido", - "rulesErrorInvalidIpAddressRangeDescription": "Por favor, insira um valor CIDR válido", - "rulesErrorInvalidUrl": "Caminho URL inválido", - "rulesErrorInvalidUrlDescription": "Por favor, insira um valor de caminho URL válido", - "rulesErrorInvalidIpAddress": "IP inválido", - "rulesErrorInvalidIpAddressDescription": "Por favor, insira um endereço IP válido", + "rulesErrorInvalidIpAddressRangeDescription": "Digite um intervalo CIDR válido (ex.: 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Caminho inválido", + "rulesErrorInvalidUrlDescription": "Insira um caminho URL válido ou padrão (ex.: /api/*).", + "rulesErrorInvalidIpAddress": "Endereço IP inválido", + "rulesErrorInvalidIpAddressDescription": "Insira um endereço IPv4 ou IPv6 válido.", "rulesErrorUpdate": "Falha ao atualizar regras", "rulesErrorUpdateDescription": "Ocorreu um erro ao atualizar regras", "rulesUpdated": "Ativar Regras", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "Insira um endereço no formato CIDR (ex: 103.21.244.0/22)", "rulesMatchIpAddress": "Insira um endereço IP (ex: 103.21.244.12)", "rulesMatchUrl": "Insira um caminho URL ou padrão (ex: /api/v1/todos ou /api/v1/*)", - "rulesErrorInvalidPriority": "Prioridade Inválida", - "rulesErrorInvalidPriorityDescription": "Por favor, insira uma prioridade válida", - "rulesErrorDuplicatePriority": "Prioridades Duplicadas", - "rulesErrorDuplicatePriorityDescription": "Por favor, insira prioridades únicas", + "rulesErrorInvalidPriority": "Prioridade inválida", + "rulesErrorInvalidPriorityDescription": "Digite um número inteiro de 1 ou mais.", + "rulesErrorDuplicatePriority": "Prioridades duplicadas", + "rulesErrorDuplicatePriorityDescription": "Cada regra deve ter um número de prioridade único.", + "rulesErrorValidation": "Regras inválidas", + "rulesErrorValidationRuleDescription": "Regra {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Selecione um tipo de correspondência válido (caminho, IP, CIDR, país, região ou ASN).", + "rulesErrorValueRequired": "Digite um valor para esta regra.", + "rulesErrorInvalidCountry": "País inválido", + "rulesErrorInvalidCountryDescription": "Selecione um país válido.", + "rulesErrorInvalidAsn": "ASN inválido", + "rulesErrorInvalidAsnDescription": "Insira um ASN válido (ex.: AS15169).", "ruleUpdated": "Regras atualizadas", "ruleUpdatedDescription": "Regras atualizadas com sucesso", "ruleErrorUpdate": "Operação falhou", "ruleErrorUpdateDescription": "Ocorreu um erro durante a operação de salvamento", "rulesPriority": "Prioridade", + "rulesReorderDragHandle": "Arraste para reordenar a prioridade da regra", "rulesAction": "Ação", "rulesMatchType": "Tipo de Correspondência", "value": "Valor", @@ -792,7 +810,7 @@ "rulesResource": "Configuração de Regras do Recurso", "rulesResourceDescription": "Configurar regras para controlar o acesso ao recurso", "ruleSubmit": "Adicionar Regra", - "rulesNoOne": "Sem regras. Adicione uma regra usando o formulário.", + "rulesNoOne": "Ainda não há regras.", "rulesOrder": "As regras são avaliadas por prioridade em ordem ascendente.", "rulesSubmit": "Guardar Regras", "policyErrorCreate": "Erro ao criar política", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Ocorreu um erro inesperado", "policyCreatedSuccess": "Política de recurso criada com sucesso", "policyUpdatedSuccess": "Política de recurso atualizada com sucesso", - "authMethodsSave": "Salvar métodos de autenticação", + "authMethodsSave": "Salvar Configurações", + "policyAuthStackTitle": "Autenticação", + "policyAuthStackDescription": "Controle quais métodos de autenticação são necessários para acessar este recurso", + "policyAuthOrLogicTitle": "Vários métodos de autenticação ativos", + "policyAuthOrLogicBanner": "Os visitantes podem autenticar-se usando qualquer um dos métodos ativos abaixo. Eles não precisam completar todos eles.", + "policyAuthMethodActive": "Ativo", + "policyAuthMethodOff": "Desligado", + "policyAuthSsoTitle": "SSO da Plataforma", + "policyAuthSsoDescription": "Exigir login pelo provedor de identidade da sua organização", + "policyAuthSsoSummary": "{idp} · {users} usuários, {roles} funções", + "policyAuthSsoDefaultIdp": "Provedor padrão", + "policyAuthAddDefaultIdentityProvider": "Adicionar Provedor de Identidade Padrão", + "policyAuthOtherMethodsTitle": "Outros Métodos", + "policyAuthOtherMethodsDescription": "Métodos opcionais que os visitantes podem usar em vez da SSO da plataforma ou junto com ela", + "policyAuthPasscodeTitle": "Código de Acesso", + "policyAuthPasscodeDescription": "Requer um código de acesso alfanumérico compartilhado para acessar o recurso", + "policyAuthPasscodeSummary": "Código de acesso definido", + "policyAuthPincodeTitle": "Código PIN", + "policyAuthPincodeDescription": "Um código numérico curto necessário para acessar o recurso", + "policyAuthPincodeSummary": "Código PIN de 6 dígitos definido", + "policyAuthEmailTitle": "Lista de E-mails Permitidos", + "policyAuthEmailDescription": "Permitir endereços de e-mail listados com senhas temporárias", + "policyAuthEmailSummary": "{count} endereços permitidos", + "policyAuthEmailOtpCallout": "Ativar a lista de e-mails permitidos envia uma senha temporária para o e-mail do visitante no login.", + "policyAuthHeaderAuthTitle": "Autenticação de Cabeçalho Básico", + "policyAuthHeaderAuthDescription": "Valide um nome e valor de cabeçalho HTTP personalizado em cada solicitação", + "policyAuthHeaderAuthSummary": "Cabeçalho configurado", + "policyAuthHeaderName": "Nome do Cabeçalho", + "policyAuthHeaderValue": "Valor esperado", + "policyAuthSetPasscode": "Definir Código de Acesso", + "policyAuthSetPincode": "Definir Código PIN", + "policyAuthSetEmailWhitelist": "Definir Lista de E-mails Permitidos", + "policyAuthSetHeaderAuth": "Definir Autenticação de Cabeçalho Básico", + "policyAccessRulesTitle": "Regras de Acesso", + "policyAccessRulesEnableDescription": "Quando ativadas, as regras são avaliadas em ordem decrescente até que uma delas seja verdadeira.", + "policyAccessRulesFirstMatch": "As regras são avaliadas de cima para baixo. A primeira regra correspondente decide o resultado.", + "policyAccessRulesHowItWorks": "As regras correspondem a solicitações por caminho, endereço IP, localização ou outros critérios. Cada regra aplica uma ação: ignorar autenticação, bloquear acesso ou passar para autenticação. Se nenhuma regra corresponder, o tráfego continua até a autenticação.", + "policyAccessRulesFallthroughOff": "Quando as regras estão desativadas, todo o tráfego passa para a autenticação.", + "policyAccessRulesFallthroughOn": "Quando nenhuma regra corresponde, o tráfego passa para a autenticação.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Guardar Regras", "resourceErrorCreate": "Erro ao criar recurso", "resourceErrorCreateDescription": "Ocorreu um erro ao criar o recurso", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "access": "Acesso", "accessControl": "Controle de Acesso", - "shareLink": "Link de Compartilhamento {resource}", + "shareLink": "Link Compartilhável {resource}", "resourceSelect": "Selecionar recurso", - "shareLinks": "Links de Compartilhamento", + "shareLinks": "Links Compartilháveis", "share": "Links Compartilháveis", "shareDescription2": "Crie links compartilháveis para recursos. Links fornecem acesso temporário ou ilimitado ao seu recurso. Você pode configurar a duração de expiração do link quando você criar um.", "shareEasyCreate": "Fácil de criar e compartilhar", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Administradores sempre podem aceder este recurso.", "resourcePolicySelectTitle": "Política de Acesso ao Recurso", "resourcePolicySelectDescription": "Selecione o tipo de política de recurso para autenticação", + "resourcePolicyTypeLabel": "Tipo de política", + "resourcePolicyLabel": "Política de recurso", "resourcePolicyInline": "Política de Recurso Inline", "resourcePolicyInlineDescription": "Política de Acesso abrange apenas este recurso", "resourcePolicyShared": "Política de Recurso Compartilhada", - "resourcePolicySharedDescription": "Este recurso usa uma política compartilhada. As configurações a nível de política (métodos de autenticação, lista de emails permitidos) estão bloqueadas. Você pode adicionar regras, funções e usuários específicos ao recurso abaixo.", + "resourcePolicySharedDescription": "Este recurso usa uma política compartilhada.", + "sharedPolicy": "Política Compartilhada", + "sharedPolicyNoneDescription": "Este recurso tem sua própria política.", + "resourceSharedPolicyOwnDescription": "Este recurso possui seus próprios controles de autenticação e regras de acesso.", + "resourceSharedPolicyInheritedDescription": "Este recurso herda de {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Este recurso está usando uma política compartilhada. Algumas configurações de autenticação podem ser editadas neste recurso para adicionar à política. Para alterar a política subjacente, você deve editar para {policyName}.", + "resourceSharedPolicyRulesNotice": "Este recurso está usando uma política compartilhada. Algumas regras de acesso podem ser editadas neste recurso. Para alterar a política subjacente, você deve editar {policyName}.", "resourceUsersRoles": "Controlos de Acesso", "resourceUsersRolesDescription": "Configure quais utilizadores e funções podem visitar este recurso", "resourceUsersRolesSubmit": "Guardar Controlos de Acesso", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Visibilidade", "resourceVisibilityTitleDescription": "Ativar ou desativar completamente a visibilidade do recurso", "resourceGeneral": "Configurações Gerais", - "resourceGeneralDescription": "Configure as configurações gerais para este recurso", + "resourceGeneralDescription": "Configure o nome, endereço e política de acesso para este recurso.", + "resourceGeneralDetailsSubsection": "Detalhes do Recurso", + "resourceGeneralDetailsSubsectionDescription": "Defina o nome de exibição, identificador e domínio publicamente acessível para este recurso.", + "resourceGeneralDetailsSubsectionPortDescription": "Defina o nome de exibição, identificador e porta pública para este recurso.", + "resourceGeneralPublicAddressSubsection": "Endereço Público", + "resourceGeneralPublicAddressSubsectionDescription": "Configure como os usuários alcançarão este recurso.", + "resourceGeneralAuthenticationAccessSubsection": "Autenticação & Acesso", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Escolha se este recurso usa sua própria política ou herda de uma política compartilhada.", "resourceEnable": "Ativar Recurso", "resourceTransfer": "Transferir Recurso", "resourceTransferDescription": "Transferir este recurso para um site diferente", @@ -1220,11 +1294,14 @@ "addLabels": "Adicionar etiquetas", "siteLabelsTab": "Etiquetas", "siteLabelsDescription": "Gerencie etiquetas associadas a este site.", - "labelsNotFound": "Etiquetas não encontradas", + "labelsNotFound": "Nenhuma etiqueta encontrada.", + "labelsEmptyCreateHint": "Comece a digitar acima para criar uma etiqueta.", "labelSearch": "Pesquisar etiquetas", + "labelSearchOrCreate": "Pesquisar ou criar uma etiqueta", "accessLabelFilterCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "labelOverflowCount": "+{count, plural, one {# etiqueta} other {# etiquetas}}", "accessLabelFilterClear": "Limpar filtros de etiquetas", + "accessFilterClear": "Limpar filtros", "selectColor": "Selecionar cor", "createNewLabel": "Criar nova etiqueta na organização \"{label}\"", "inviteInvalidDescription": "O link do convite é inválido.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", - "sidebarPolicies": "Políticas", - "sidebarResourcePolicies": "Recursos", + "sidebarPolicies": "Políticas Compartilhadas", + "sidebarResourcePolicies": "Recursos Públicos", "sidebarAccessControl": "Controle de Acesso", "sidebarLogsAndAnalytics": "Registros e Análises", "sidebarTeam": "Equipe", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Administrador", "sidebarInvitations": "Convites", "sidebarRoles": "Papéis", - "sidebarShareableLinks": "Links", + "sidebarShareableLinks": "Links Compartilháveis", "sidebarApiKeys": "Chaves API", "sidebarProvisioning": "Provisionamento", "sidebarSettings": "Configurações", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Recurso {id}", "blueprints": "Diagramas", "blueprintsLog": "Registo dos Blueprint", - "blueprintsDescription": "Ver aplicações de blueprint passadas e seus resultados", + "blueprintsDescription": "Visualizar aplicações de blueprint passadas e seus resultados ou aplicar um novo blueprint", "blueprintAdd": "Adicionar Diagrama", "blueprintGoBack": "Ver todos os Diagramas", "blueprintCreate": "Criar Diagrama", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Habilitar o Diagrama Docker", "enableDockerSocketDescription": "Ative a raspagem de etiquetas do Docker Socket para etiquetas de modelo. O caminho do Socket deve ser fornecido ao conector do site. Leia sobre como isso funciona na documentação.", "newtAutoUpdate": "Ativar Atualização Automática do Site", - "newtAutoUpdateDescription": "Quando ativado, os conectores de site atualizarão automaticamente para a versão mais recente quando uma nova versão estiver disponível.", + "newtAutoUpdateDescription": "Quando ativada, os conectores do site baixarão automaticamente a versão mais recente e reiniciarão por conta própria. Isto pode ser sobrescrito com base em cada site.", "siteAutoUpdate": "Atualização Automática do Site", "siteAutoUpdateLabel": "Ativar Atualização Automática", - "siteAutoUpdateDescription": "Controle se o conector deste site baixa automaticamente a versão mais recente.", + "siteAutoUpdateDescription": "Quando ativado, o conector deste site baixará automaticamente a versão mais recente e reiniciará por si mesmo.", "siteAutoUpdateOrgDefault": "Padrão da organização: {state}", "siteAutoUpdateOverriding": "Substituindo configuração da organização", "siteAutoUpdateResetToOrg": "Redefinir para Padrão da Organização", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Configuração da conta concluída! Bem-vindo ao Pangolin!", "documentation": "Documentação", "saveAllSettings": "Guardar Todas as Configurações", - "saveResourceTargets": "Guardar Alvos", - "saveResourceHttp": "Guardar Configurações de Proxy", - "saveProxyProtocol": "Salvar configurações do protocolo de proxy", + "saveResourceTargets": "Salvar Configurações", + "saveResourceHttp": "Salvar Configurações", + "saveProxyProtocol": "Salvar Configurações", "settingsUpdated": "Configurações atualizadas", "settingsUpdatedDescription": "Configurações atualizadas com sucesso", "settingsErrorUpdate": "Falha ao atualizar configurações", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Desconhecido", "healthCheck": "Verificação de Saúde", "configureHealthCheck": "Configurar Verificação de Saúde", - "configureHealthCheckDescription": "Configure a monitorização de saúde para {target}", + "configureHealthCheckDescription": "Configure a monitorização para o seu recurso para garantir que ele esteja sempre disponível", "enableHealthChecks": "Ativar Verificações de Saúde", "healthCheckDisabledStateDescription": "Quando desativado, o site não realizará verificações de saúde e o estado será considerado desconhecido.", "enableHealthChecksDescription": "Monitore a saúde deste alvo. Você pode monitorar um ponto de extremidade diferente do alvo, se necessário.", "healthScheme": "Método", "healthSelectScheme": "Selecione o Método", - "healthCheckPortInvalid": "A porta do exame de saúde deve estar entre 1 e 65535", + "healthCheckPortInvalid": "A porta deve estar entre 1 e 65535", "healthCheckPath": "Caminho", "healthHostname": "IP / Nome do Host", "healthPort": "Porta", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Exigir aprovação do dispositivo", "requireDeviceApprovalDescription": "Usuários com esta função precisam de novos dispositivos aprovados por um administrador antes que eles possam se conectar e acessar recursos.", "sshSettings": "Configurações SSH", + "sshAccess": "Acesso SSH", "rdpSettings": "Configurações RDP", "vncSettings": "Configurações VNC", "sshServer": "Servidor SSH", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Certifique-se de que seu host de destino está devidamente configurado para executar o daemon de autenticação antes de concluir esta configuração, ou o provisionamento falhará.", "sshDaemonPort": "Porta do Daemon", "sshServerDestination": "Destino do Servidor", - "sshServerDestinationDescription": "Configure o destino e a porta do servidor SSH", + "sshServerDestinationDescription": "Configure o destino do servidor SSH", "destination": "Destino", + "destinationRequired": "Destino é obrigatório.", + "domainRequired": "Domínio é obrigatório.", + "proxyPortRequired": "Porta é obrigatória.", + "invalidPathConfiguration": "Configuração de caminho inválida.", + "invalidRewritePathConfiguration": "Configuração de caminho de reescrita inválida.", "bgTargetMultiSiteDisclaimer": "Selecionar vários sites permite roteamento resiliente e failover para alta disponibilidade.", "roleAllowSsh": "Permitir SSH", "roleAllowSshAllow": "Autorizar", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.", "sshSudo": "Permitir sudo", "sshSudoCommands": "Comandos Sudo", - "sshSudoCommandsDescription": "Lista separada por vírgulas de comandos que o usuário pode executar com sudo. Caminhos absolutos devem ser usados.", + "sshSudoCommandsDescription": "Lista de comandos que o usuário está autorizado a executar com sudo, separados por vírgulas, espaços ou novas linhas. Devem ser usados caminhos absolutos.", "sshCreateHomeDir": "Criar Diretório Inicial", "sshUnixGroups": "Grupos Unix", - "sshUnixGroupsDescription": "Grupos Unix separados por vírgulas para adicionar o usuário no host alvo.", + "sshUnixGroupsDescription": "Grupos Unix para adicionar o usuário no host de destino, separados por vírgulas, espaços ou novas linhas.", + "roleTextFieldPlaceholder": "Insira valores, ou solte um arquivo .txt ou .csv", + "roleTextImportTitle": "Importar de Arquivo", + "roleTextImportDescription": "Importando {fileName} para {fieldLabel}.", + "roleTextImportSkipHeader": "Pular Primeira Linha (Cabeçalho)", + "roleTextImportOverride": "Substituir Existente", + "roleTextImportAppend": "Anexar ao Existente", + "roleTextImportMode": "Modo de Importação", + "roleTextImportPreview": "Visualizar", + "roleTextImportItemCount": "{count, plural, =0 {Sem itens para importar} one {1 item para importar} other {# itens para importar}}", + "roleTextImportTotalCount": "{existing} existente + {imported} importado = {total} total", + "roleTextImportConfirm": "Importar", + "roleTextImportInvalidFile": "Tipo de arquivo não suportado", + "roleTextImportInvalidFileDescription": "Apenas arquivos .txt e .csv são suportados.", + "roleTextImportEmpty": "Nenhum item encontrado no arquivo", + "roleTextImportEmptyDescription": "O arquivo não contém quaisquer itens importáveis.", "retryAttempts": "Tentativas de Repetição", "expectedResponseCodes": "Códigos de Resposta Esperados", "expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Habilitar protocolo proxy", "proxyProtocolInfo": "Preservar endereços IP do cliente para backends TCP", "proxyProtocolVersion": "Versão do Protocolo Proxy", - "version1": " Versão 1 (recomendado)", + "version1": "Versão 1 (Recomendado)", "version2": "Versão 2", - "versionDescription": "A versão 1 é baseada em texto e amplamente suportada. A versão 2 é binária e mais eficiente, mas menos compatível.", + "version1Description": "Baseado em texto e amplamente suportado. Certifique-se de que o transporte dos servidores seja adicionado à configuração dinâmica.", + "version2Description": "Binário e mais eficiente, mas menos compatível. Certifique-se de que o transporte do servidor seja adicionado à configuração dinâmica.", "warning": "ATENÇÃO", "proxyProtocolWarning": "A aplicação de backend deve ser configurada para aceitar conexões de protocolo proxy. Se o seu backend não suporta o Protocolo de Proxy, habilitando isto quebrará todas as conexões, então só habilite isso se você souber o que está fazendo. Certifique-se de configurar seu backend para confiar nos cabeçalhos do protocolo proxy no Traefik.", "restarting": "Reiniciando...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Inserir confirmação", "blueprintViewDetails": "Detalhes", "defaultIdentityProvider": "Provedor de Identidade Padrão", - "defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.", + "defaultIdentityProviderDescription": "O usuário será redirecionado automaticamente para este provedor de identidade para autenticação.", "editInternalResourceDialogNetworkSettings": "Configurações de Rede", "editInternalResourceDialogAccessPolicy": "Política de Acesso", "editInternalResourceDialogAddRoles": "Adicionar Funções", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Tipo de Modo de Manutenção", "showMaintenancePage": "Mostrar uma página de manutenção para os visitantes", "enableMaintenanceMode": "Ativar Modo de Manutenção", + "enableMaintenanceModeDescription": "Quando ativado, os visitantes verãos uma página de manutenção em vez do seu recurso.", "automatic": "Automático", "automaticModeDescription": "Exibir página de manutenção apenas quando todos os destinos de back-end estiverem inativos ou não saudáveis. Seu recurso continua funcionando normalmente desde que pelo menos um destino esteja saudável.", "forced": "Forçado", @@ -3082,6 +3182,8 @@ "warning:": "Aviso:", "forcedeModeWarning": "Todo o tráfego será direcionado para a página de manutenção. Seus recursos de back-end não receberão nenhuma solicitação.", "pageTitle": "Título da Página", + "maintenancePageContentSubsection": "Conteúdo da Página", + "maintenancePageContentSubsectionDescription": "Personalize o conteúdo exibido na página de manutenção", "pageTitleDescription": "O título principal exibido na página de manutenção", "maintenancePageMessage": "Mensagem de Manutenção", "maintenancePageMessagePlaceholder": "Voltaremos em breve! Nosso site está passando por manutenção programada.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Tem certeza de que deseja desassociar este provedor de identidade desta organização?", "idpUnassociateDescription": "Todos os usuários associados a este provedor de identidade serão removidos desta organização, mas o provedor de identidade continuará a existir para outras organizações associadas.", "idpUnassociateConfirm": "Confirmar Desassociação do Provedor de Identidade", + "idpConfirmDeleteAndRemoveMeFromOrg": "DELETAR E REMOVER-ME DA ORGANIZAÇÃO", + "idpUnassociateAndRemoveMeFromOrg": "DESASSOCIAR E REMOVER-ME DA ORGANIZAÇÃO", "idpUnassociateWarning": "Isso não pode ser desfeito para esta organização.", "idpUnassociatedDescription": "Provedor de identidade desassociado desta organização com sucesso", "idpUnassociateMenu": "Desassociar", @@ -3439,18 +3543,58 @@ "sshConnecting": "A conectar…", "sshInitializing": "A iniciar…", "sshSignInTitle": "Entrar no SSH", - "sshSignInDescription": "Insira suas credenciais SSH", + "sshSignInDescription": "Digite suas credenciais SSH para conectar", "sshPasswordTab": "Palavra-passe", "sshPrivateKeyTab": "Chave Privada", "sshPrivateKeyField": "Chave Privada", "sshPrivateKeyDisclaimer": "Sua chave privada não é armazenada ou visível para Pangolin. Alternativamente, você pode usar certificados de curta duração para autenticação perfeita usando sua identidade Pangolin existente.", "sshLearnMore": "Saiba mais", "sshPrivateKeyFile": "Arquivo de Chave Privada", - "sshAuthenticate": "Autenticar", + "sshAuthenticate": "Conectar", "sshTerminate": "Terminar", "sshPoweredBy": "Desenvolvido por", "sshErrorNoTarget": "Nenhum alvo especificado", "sshErrorWebSocket": "Falha na conexão WebSocket", "sshErrorAuthFailed": "Falha na autenticação", - "sshErrorConnectionClosed": "Conexão encerrada antes de concluir a autenticação" + "sshErrorConnectionClosed": "Conexão encerrada antes de concluir a autenticação", + "sitePangolinSshDescription": "Permitir acesso SSH aos recursos deste site. Isso pode ser alterado mais tarde.", + "browserGatewayNoResourceForDomain": "Nenhum recurso encontrado para este domínio", + "browserGatewayNoTarget": "Sem alvo", + "browserGatewayConnect": "Conectar", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Falha ao assinar a chave SSH para autenticação PAM push. Você se conectou como um usuário?", + "sshTerminalError": "Erro: {error}", + "sshConnectionClosedCode": "Conexão encerrada (código {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Chave privada é necessária", + "vncTitle": "VNC", + "vncSignInDescription": "Digite sua senha VNC para conectar", + "vncPasswordOptional": "Senha (opcional)", + "vncNoResourceTarget": "Nenhum alvo de recurso disponível", + "vncFailedToLoadNovnc": "Falha ao carregar noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Colar conteúdo da área de transferência", + "rdpTitle": "RDP", + "rdpSignInTitle": "Conectar-se à Área de Trabalho Remota", + "rdpSignInDescription": "Digite as credenciais do Windows para conectar", + "rdpLoadingModule": "Carregando módulo...", + "rdpFailedToLoadModule": "Falha ao carregar módulo RDP", + "rdpNotReady": "Não está pronto", + "rdpModuleInitializing": "Módulo RDP ainda está inicializando", + "rdpDownloadingFiles": "Baixando {count} arquivo(s) do remoto…", + "rdpDownloadFailed": "Falha ao baixar: {fileName}", + "rdpUploaded": "Enviado: {fileName}", + "rdpNoConnectionTarget": "Nenhum alvo de conexão disponível", + "rdpConnectionFailed": "Conexão falhou", + "rdpFit": "Ajustar", + "rdpFull": "Completo", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Upload de arquivos", + "rdpFilesReadyToPaste": "Arquivos prontos para colar", + "rdpFilesReadyToPasteDescription": "{count} arquivo(s) copiado(s) para a área de transferência remota — pressione Ctrl+V na área de trabalho remota para colar.", + "rdpUploadFailed": "Falha no upload", + "rdpUnicodeKeyboardMode": "Modo de teclado Unicode", + "sessionToolbarShow": "Mostrar barra de ferramentas", + "sessionToolbarHide": "Ocultar barra de ferramentas" } From 08df4b93aace9fbb3391dbaf780f7871ca6738d2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:54 -0700 Subject: [PATCH 028/264] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 230 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 187 insertions(+), 43 deletions(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 702525a2f..cacc4b895 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Просмотр частных ресурсов", "siteInstallNewt": "Установить Newt", "siteInstallNewtDescription": "Запустите Newt в вашей системе", + "siteInstallKubernetesDocsDescription": "Для получения дополнительной информации об установке Kubernetes, см. docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "Для инструкций по установке модема Advantech, см. docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "Конфигурация WireGuard", "WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети", "operatingSystem": "Операционная система", @@ -157,7 +159,7 @@ "shareDeleted": "Ссылка удалена", "shareDeletedDescription": "Ссылка была успешно удалена", "shareDelete": "Удалить общую ссылку", - "shareDeleteConfirm": "Подтвердите удаление общей ссылки", + "shareDeleteConfirm": "Подтвердить удаление общей ссылки", "shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?", "shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.", "shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу", "shareTitleOptional": "Заголовок (необязательно)", "sharePathOptional": "Путь (необязательно)", + "sharePathDescription": "Ссылка перенаправит пользователей на этот путь после аутентификации.", "expireIn": "Срок действия", "neverExpire": "Бессрочный доступ", "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Пожалуйста, выберите ресурс", "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", - "publicResourcesBannerTitle": "Общедоступный доступ через веб", - "publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", + "publicResourcesBannerTitle": "Веб-доступ к публичным ресурсам", + "publicResourcesBannerDescription": "Публичные ресурсы — это HTTPS-прокси, доступные для любого пользователя Интернета через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать в себя политики доступа, учитывающие идентичность и контекст.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", @@ -209,15 +212,19 @@ "resourcesSearch": "Поиск ресурсов...", "resourceAdd": "Добавить ресурс", "resourceErrorDelte": "Ошибка при удалении ресурса", - "resourcePoliciesTitle": "Управление политиками ресурсов", - "resourcePoliciesAttachedResourcesColumnTitle": "Прикрепленные ресурсы", + "resourcePoliciesBannerTitle": "Повторное использование правил аутентификации и доступа", + "resourcePoliciesBannerDescription": "Политики общих ресурсов позволяют один раз определить методы аутентификации и правила доступа, а затем прикреплять их к нескольким публичным ресурсам. Когда вы обновляете политику, каждое связанное с ней наследует изменение автоматически.", + "resourcePoliciesBannerButtonText": "Узнать больше", + "resourcePoliciesTitle": "Управление политиками публичных ресурсов", + "resourcePoliciesAttachedResourcesColumnTitle": "Ресурсы", "resourcePoliciesAttachedResources": "{count} ресурс(ов)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурсов}}", "resourcePoliciesAttachedResourcesEmpty": "нет ресурсов", - "resourcePoliciesDescription": "Создавайте и управляйте политиками аутентификации для контроля доступа к вашим ресурсам", + "resourcePoliciesDescription": "Создание и управление политиками аутентификации для контроля доступа к вашим публичным ресурсам", "resourcePoliciesSearch": "Поиск политик...", "resourcePoliciesAdd": "Добавить политику", "resourcePoliciesDefaultBadgeText": "Политика по умолчанию", - "resourcePoliciesCreate": "Создать политику ресурса", + "resourcePoliciesCreate": "Создать политику публичного ресурса", "resourcePoliciesCreateDescription": "Следуйте шагам ниже, чтобы создать новую политику", "resourcePolicyName": "Имя политики", "resourcePolicyNameDescription": "Дайте этой политике имя для идентификации ее в ваших ресурсах", @@ -274,7 +281,7 @@ "back": "Назад", "cancel": "Отмена", "resourceConfig": "Фрагменты конфигурации", - "resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса", + "resourceConfigDescription": "Скопируйте и вставьте эти фрагменты конфигурации для настройки ресурса TCP/UDP.", "resourceAddEntrypoints": "Traefik: Добавить точки входа", "resourceExposePorts": "Gerbil: Открыть порты в Docker Compose", "resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы", @@ -287,6 +294,8 @@ "labelDelete": "Удалить метку", "labelAdd": "Добавить метку", "labelCreateSuccessMessage": "Метка успешно создана", + "labelDuplicateError": "Повторяющаяся метка", + "labelDuplicateErrorDescription": "Метка с таким именем уже существует.", "labelEditSuccessMessage": "Метка успешно изменена", "labelNameField": "Название метки", "labelColorField": "Цвет метки", @@ -311,7 +320,7 @@ "rules": "Правила", "resourceSettingDescription": "Настройка параметров ресурса", "resourceSetting": "Настройки {resourceName}", - "resourcePolicySettingDescription": "Настройка параметров политики ресурса", + "resourcePolicySettingDescription": "Настройте параметры этой политики публичного ресурса", "resourcePolicySetting": "Настройки {policyName}", "alwaysAllow": "Авторизация байпасса", "alwaysDeny": "Блокировать доступ", @@ -719,7 +728,7 @@ "targetSubmit": "Добавить цель", "targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.", "targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.", - "targetsSubmit": "Сохранить цели", + "targetsSubmit": "Сохранить настройки", "addTarget": "Добавить цель", "proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.", "targetErrorInvalidIp": "Неверный IP-адрес", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Дублирующее правило", "rulesErrorDuplicateDescription": "Правило с такими настройками уже существует", "rulesErrorInvalidIpAddressRange": "Неверный CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Пожалуйста, введите корректное значение CIDR", - "rulesErrorInvalidUrl": "Неверный URL путь", - "rulesErrorInvalidUrlDescription": "Пожалуйста, введите корректное значение URL пути", - "rulesErrorInvalidIpAddress": "Неверный IP", - "rulesErrorInvalidIpAddressDescription": "Пожалуйста, введите корректный IP адрес", + "rulesErrorInvalidIpAddressRangeDescription": "Введите действительный диапазон CIDR (например, 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Неверный путь", + "rulesErrorInvalidUrlDescription": "Введите действительный URL-путь или шаблон (например, /api/*).", + "rulesErrorInvalidIpAddress": "Недействительный IP адрес", + "rulesErrorInvalidIpAddressDescription": "Введите действительный адрес IPv4 или IPv6.", "rulesErrorUpdate": "Не удалось обновить правила", "rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил", "rulesUpdated": "Включить правила", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)", "rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)", "rulesErrorInvalidPriority": "Неверный приоритет", - "rulesErrorInvalidPriorityDescription": "Пожалуйста, введите корректный приоритет", - "rulesErrorDuplicatePriority": "Дублирующие приоритеты", - "rulesErrorDuplicatePriorityDescription": "Пожалуйста, введите уникальные приоритеты", + "rulesErrorInvalidPriorityDescription": "Введите целое число 1 или больше.", + "rulesErrorDuplicatePriority": "Повторяющиеся приоритеты", + "rulesErrorDuplicatePriorityDescription": "Каждое правило должно иметь уникальный номер приоритета.", + "rulesErrorValidation": "Неверные правила", + "rulesErrorValidationRuleDescription": "Правило {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Выберите действительный тип совпадения (путь, IP, CIDR, страна, регион или ASN).", + "rulesErrorValueRequired": "Введите значение для этого правила.", + "rulesErrorInvalidCountry": "Недействительная страна", + "rulesErrorInvalidCountryDescription": "Выберите правильную страну.", + "rulesErrorInvalidAsn": "Недействительный ASN", + "rulesErrorInvalidAsnDescription": "Введите действительный ASN (например, AS15169).", "ruleUpdated": "Правила обновлены", "ruleUpdatedDescription": "Правила успешно обновлены", "ruleErrorUpdate": "Операция не удалась", "ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения", "rulesPriority": "Приоритет", + "rulesReorderDragHandle": "Перетащите, чтобы изменить приоритет правила", "rulesAction": "Действие", "rulesMatchType": "Тип совпадения", "value": "Значение", @@ -792,7 +810,7 @@ "rulesResource": "Конфигурация правил ресурса", "rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу", "ruleSubmit": "Добавить правило", - "rulesNoOne": "Нет правил. Добавьте правило с помощью формы.", + "rulesNoOne": "Пока нет правил.", "rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.", "rulesSubmit": "Сохранить правила", "policyErrorCreate": "Ошибка создания политики", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Произошла неожиданная ошибка", "policyCreatedSuccess": "Политика ресурса успешно создана", "policyUpdatedSuccess": "Политика ресурса успешно обновлена", - "authMethodsSave": "Сохранить методы аутентификации", + "authMethodsSave": "Сохранить настройки", + "policyAuthStackTitle": "Аутентификация", + "policyAuthStackDescription": "Контроль, какие методы аутентификации требуются для доступа к этому ресурсу", + "policyAuthOrLogicTitle": "Несколько методов аутентификации активны", + "policyAuthOrLogicBanner": "Посетители могут аутентифицироваться, используя любой из активных методов ниже. Им не нужно выполнять все.", + "policyAuthMethodActive": "Активно", + "policyAuthMethodOff": "Отключено", + "policyAuthSsoTitle": "Платформа SSO", + "policyAuthSsoDescription": "Требуется войти через поставщика удостоверений вашей организации", + "policyAuthSsoSummary": "{idp} · {users} пользователей, {roles} ролей", + "policyAuthSsoDefaultIdp": "Поставщик по умолчанию", + "policyAuthAddDefaultIdentityProvider": "Добавить поставщика удостоверений по умолчанию", + "policyAuthOtherMethodsTitle": "Другие методы", + "policyAuthOtherMethodsDescription": "Дополнительные методы, которые посетители могут использовать вместо или вместе с платформой SSO", + "policyAuthPasscodeTitle": "Пароль", + "policyAuthPasscodeDescription": "Требуется общий буквенно-цифровой пароль для доступа к ресурсу", + "policyAuthPasscodeSummary": "Пароль установлен", + "policyAuthPincodeTitle": "ПИН-код", + "policyAuthPincodeDescription": "Краткий числовой код, необходимый для доступа к ресурсу", + "policyAuthPincodeSummary": "Установлен 6-значный PIN-код", + "policyAuthEmailTitle": "Белый список email", + "policyAuthEmailDescription": "Разрешить перечисленные email-адреса с одноразовыми паролями", + "policyAuthEmailSummary": "Разрешено адресов: {count}", + "policyAuthEmailOtpCallout": "Включение белого списка email отправляет одноразовый пароль на email посетителя при входе.", + "policyAuthHeaderAuthTitle": "Базовая аутентификация заголовка", + "policyAuthHeaderAuthDescription": "Проверка пользовательского имени и значения HTTP-заголовка для каждого запроса", + "policyAuthHeaderAuthSummary": "Заголовок настроен", + "policyAuthHeaderName": "Имя заголовка", + "policyAuthHeaderValue": "Ожидаемое значение", + "policyAuthSetPasscode": "Установить пароль", + "policyAuthSetPincode": "Установить ПИН-код", + "policyAuthSetEmailWhitelist": "Установить белый список email", + "policyAuthSetHeaderAuth": "Установить базовую аутентификацию заголовка", + "policyAccessRulesTitle": "Правила доступа", + "policyAccessRulesEnableDescription": "При включении правила оцениваются в порядке убывания до тех пор, пока одно из них не оценивается как истинное.", + "policyAccessRulesFirstMatch": "Правила оцениваются сверху вниз. Первое совпадающее правило определяет результат.", + "policyAccessRulesHowItWorks": "Правила сопоставляют запросы по пути, IP-адресу, местоположению или другим критериям. Каждое правило применяет действие: обойти аутентификацию, заблокировать доступ или передать для аутентификации. Если правило не подписано, трафик продолжается для аутентификации.", + "policyAccessRulesFallthroughOff": "Когда правила отключены, весь трафик проходит для аутентификации.", + "policyAccessRulesFallthroughOn": "Когда правило не совпадает, трафик проходит для аутентификации.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Сохранить правила", "resourceErrorCreate": "Ошибка при создании ресурса", "resourceErrorCreateDescription": "Произошла ошибка при создании ресурса", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.", "resourcePolicySelectTitle": "Политика доступа к ресурсам", "resourcePolicySelectDescription": "Выберите тип политики ресурса для аутентификации", + "resourcePolicyTypeLabel": "Тип политики", + "resourcePolicyLabel": "Политика ресурса", "resourcePolicyInline": "Политика ресурса на месте", "resourcePolicyInlineDescription": "Политика доступа ограничена только этим ресурсом", "resourcePolicyShared": "Общая политика ресурса", - "resourcePolicySharedDescription": "Этот ресурс использует общую политику. Настройки уровня политики (методы аутентификации, список разрешенных email) заблокированы. Вы можете добавить правила, роли и пользователей, специфичные для ресурса, ниже.", + "resourcePolicySharedDescription": "Этот ресурс использует общую политику.", + "sharedPolicy": "Общая политика", + "sharedPolicyNoneDescription": "У этого ресурса есть своя политика.", + "resourceSharedPolicyOwnDescription": "У этого ресурса есть собственные средства управления аутентификацией и правилами доступа.", + "resourceSharedPolicyInheritedDescription": "Этот ресурс наследует от {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Этот ресурс использует общую политику. Некоторые настройки аутентификации можно изменить в этом ресурсе, чтобы добавить их в политику. Чтобы изменить основную политику, отредактируйте {policyName}.", + "resourceSharedPolicyRulesNotice": "Этот ресурс использует общую политику. Некоторые правила доступа могут быть отредактированы для этого ресурса. Чтобы изменить основную политику, вы должны отредактировать {policyName}.", "resourceUsersRoles": "Контроль доступа", "resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу", "resourceUsersRolesSubmit": "Сохранить контроль доступа", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Видимость", "resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса", "resourceGeneral": "Общие настройки", - "resourceGeneralDescription": "Настройте общие параметры этого ресурса", + "resourceGeneralDescription": "Настройте имя, адрес и политику доступа для этого ресурса.", + "resourceGeneralDetailsSubsection": "Детали ресурса", + "resourceGeneralDetailsSubsectionDescription": "Установите отображаемое имя, идентификатор и публично доступный домен для этого ресурса.", + "resourceGeneralDetailsSubsectionPortDescription": "Установите отображаемое имя, идентификатор и публичный порт для этого ресурса.", + "resourceGeneralPublicAddressSubsection": "Публичный адрес", + "resourceGeneralPublicAddressSubsectionDescription": "Настройте, как пользователи будут получать доступ к этому ресурсу.", + "resourceGeneralAuthenticationAccessSubsection": "Аутентификация и доступ", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Выберите, будет ли этот ресурс использовать собственную политику или наследовать от общей политики.", "resourceEnable": "Ресурс активен", "resourceTransfer": "Перенести ресурс", "resourceTransferDescription": "Перенесите этот ресурс на другой сайт", @@ -1220,11 +1294,14 @@ "addLabels": "Добавить метки", "siteLabelsTab": "Метки", "siteLabelsDescription": "Управляйте метками, связанными с этим сайтом.", - "labelsNotFound": "Метки не найдены", + "labelsNotFound": "Метки не найдены.", + "labelsEmptyCreateHint": "Начните печатать выше, чтобы создать метку.", "labelSearch": "Поиск меток", + "labelSearchOrCreate": "Найти или создать метку", "accessLabelFilterCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", "labelOverflowCount": "+{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", "accessLabelFilterClear": "Очистить фильтры меток", + "accessFilterClear": "Очистить фильтры", "selectColor": "Выберите цвет", "createNewLabel": "Создать новую метку организации \"{label}\"", "inviteInvalidDescription": "Ссылка на приглашение недействительна.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Ресурсы", "sidebarProxyResources": "Публичный", "sidebarClientResources": "Приватный", - "sidebarPolicies": "Политики", - "sidebarResourcePolicies": "Ресурсы", + "sidebarPolicies": "Общие политики", + "sidebarResourcePolicies": "Публичные ресурсы", "sidebarAccessControl": "Контроль доступа", "sidebarLogsAndAnalytics": "Журналы и аналитика", "sidebarTeam": "Команда", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Админ", "sidebarInvitations": "Приглашения", "sidebarRoles": "Роли", - "sidebarShareableLinks": "Ссылки", + "sidebarShareableLinks": "Общие ссылки", "sidebarApiKeys": "API ключи", "sidebarProvisioning": "Подготовка", "sidebarSettings": "Настройки", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Ресурс {id}", "blueprints": "Чертежи", "blueprintsLog": "Журнал чертежей", - "blueprintsDescription": "Просмотр прошлых применений чертежа и их результатов", + "blueprintsDescription": "Просмотреть предыдущие приложения с чертежами и их результаты или применить новый чертеж", "blueprintAdd": "Добавить чертёж", "blueprintGoBack": "Посмотреть все чертежи", "blueprintCreate": "Создать чертёж", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Включить чертёж Docker", "enableDockerSocketDescription": "Включить сбор меток Docker Socket для чертежей. Путь сокета должен быть предоставлен подключателю сайта. Прочтите о том, как это работает, в документации.", "newtAutoUpdate": "Включить автообновление сайта", - "newtAutoUpdateDescription": "При включении, коннекторы сайта будут автоматически обновляться до последней версии, когда доступен новый выпуск.", + "newtAutoUpdateDescription": "При включении разъемы сайта автоматически загрузят последнюю версию и перезапустятся. Это можно переопределить на уровне каждого сайта.", "siteAutoUpdate": "Автообновление сайта", "siteAutoUpdateLabel": "Включить автообновление", - "siteAutoUpdateDescription": "Контролировать, будет ли коннектор этого сайта автоматически загружать последнюю версию.", + "siteAutoUpdateDescription": "При включении разъем этого сайта автоматически скачает последнюю версию и перезапустится.", "siteAutoUpdateOrgDefault": "Значение по умолчанию для организации: {state}", "siteAutoUpdateOverriding": "Переопределение настройки организации", "siteAutoUpdateResetToOrg": "Сброс до значения по умолчанию для организации", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!", "documentation": "Документация", "saveAllSettings": "Сохранить все настройки", - "saveResourceTargets": "Сохранить цели", - "saveResourceHttp": "Сохранить настройки прокси", - "saveProxyProtocol": "Сохранить настройки прокси-протокола", + "saveResourceTargets": "Сохранить настройки", + "saveResourceHttp": "Сохранить настройки", + "saveProxyProtocol": "Сохранить настройки", "settingsUpdated": "Настройки обновлены", "settingsUpdatedDescription": "Настройки успешно обновлены", "settingsErrorUpdate": "Не удалось обновить настройки", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Неизвестно", "healthCheck": "Проверка здоровья", "configureHealthCheck": "Настроить проверку здоровья", - "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}", + "configureHealthCheckDescription": "Настройте мониторинг вашего ресурса, чтобы обеспечить его постоянную доступность", "enableHealthChecks": "Включить проверки здоровья", "healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.", "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", "healthScheme": "Метод", "healthSelectScheme": "Выберите метод", - "healthCheckPortInvalid": "Порт проверки здоровья должен быть от 1 до 65535", + "healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535", "healthCheckPath": "Путь", "healthHostname": "IP / хост", "healthPort": "Порт", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Требовать подтверждения устройства", "requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.", "sshSettings": "Настройки SSH", + "sshAccess": "Доступ по SSH", "rdpSettings": "Настройки RDP", "vncSettings": "Настройки VNC", "sshServer": "SSH сервер", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Убедитесь, что целевой хост правильно настроен для запуска демона аутентификации перед завершением этой настройки, иначе предоставление не удастся.", "sshDaemonPort": "Порт демона", "sshServerDestination": "Пункт назначения сервера", - "sshServerDestinationDescription": "Настройте пункт назначения и порт SSH-сервера", + "sshServerDestinationDescription": "Настройте адрес сервера SSH", "destination": "Пункт назначения", + "destinationRequired": "Требуется указание пункта назначения.", + "domainRequired": "Требуется домен.", + "proxyPortRequired": "Требуется порт.", + "invalidPathConfiguration": "Недействительная конфигурация пути.", + "invalidRewritePathConfiguration": "Недействительная конфигурация пути переписывания.", "bgTargetMultiSiteDisclaimer": "Выбор нескольких сайтов включает в себя устойчивую маршрутизацию и автоматический отказ для обеспечения высокой доступности.", "roleAllowSsh": "Разрешить SSH", "roleAllowSshAllow": "Разрешить", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.", "sshSudo": "Разрешить sudo", "sshSudoCommands": "Sudo Команды", - "sshSudoCommandsDescription": "Список команд, которые пользователь может выполнять с sudo, через запятую. Должны использоваться абсолютные пути.", + "sshSudoCommandsDescription": "Список команд, которые пользователь может запускать с sudo, разделенный запятыми, пробелами или новыми строками. Должны использоваться абсолютные пути.", "sshCreateHomeDir": "Создать домашний каталог", "sshUnixGroups": "Unix группы", - "sshUnixGroupsDescription": "Группы Unix через запятую, чтобы добавить пользователя на целевой хост.", + "sshUnixGroupsDescription": "Группы Unix, к которым пользователь добавляется на целевом хосте, разделяются запятыми, пробелами или новыми строками.", + "roleTextFieldPlaceholder": "Введите значения или перетащите файл .txt или .csv", + "roleTextImportTitle": "Импорт из файла", + "roleTextImportDescription": "Импортирую {fileName} в {fieldLabel}.", + "roleTextImportSkipHeader": "Пропустить первую строку (заголовок)", + "roleTextImportOverride": "Заменить существующее", + "roleTextImportAppend": "Добавить к существующему", + "roleTextImportMode": "Режим импорта", + "roleTextImportPreview": "Предпросмотр", + "roleTextImportItemCount": "{count, plural, =0 {Нет элементов для импорта} one {# элемент для импорта} few {# элемента для импорта} many {# элементов для импорта} other {# элементов для импорта}}", + "roleTextImportTotalCount": "{existing} существующих + {imported} импортированных = {total} всего", + "roleTextImportConfirm": "Импортировать", + "roleTextImportInvalidFile": "Неподдерживаемый тип файла", + "roleTextImportInvalidFileDescription": "Поддерживаются только файлы .txt и .csv.", + "roleTextImportEmpty": "Элементы в файле не найдены", + "roleTextImportEmptyDescription": "Файл не содержит элементов, которые можно импортировать.", "retryAttempts": "Количество попыток повторного запроса", "expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Включить Прокси Протокол", "proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP", "proxyProtocolVersion": "Версия протокола прокси", - "version1": " Версия 1 (рекомендуется)", + "version1": "Версия 1 (рекомендуется)", "version2": "Версия 2", - "versionDescription": "Версия 1 основана на тексте и широко поддерживается. Версия 2 является бинарной и более эффективной, но менее совместимой.", + "version1Description": "Основано на тексте и широко поддерживается. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.", + "version2Description": "Бинарная и более эффективная, но менее совместимая. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.", "warning": "Предупреждение", "proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.", "restarting": "Перезапуск...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Введите подтверждение", "blueprintViewDetails": "Подробности", "defaultIdentityProvider": "Поставщик удостоверений по умолчанию", - "defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.", + "defaultIdentityProviderDescription": "Пользователь будет автоматически перенаправлен к этому поставщику удостоверений для аутентификации.", "editInternalResourceDialogNetworkSettings": "Настройки сети", "editInternalResourceDialogAccessPolicy": "Политика доступа", "editInternalResourceDialogAddRoles": "Добавить роли", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Тип режима обслуживания", "showMaintenancePage": "Показать страницу обслуживания посетителям", "enableMaintenanceMode": "Включить режим обслуживания", + "enableMaintenanceModeDescription": "Когда включено, посетители увидят страницу обслуживания вместо вашего ресурса.", "automatic": "Автоматический", "automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.", "forced": "Принудительно", @@ -3082,6 +3182,8 @@ "warning:": "Предупреждение:", "forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.", "pageTitle": "Заголовок страницы", + "maintenancePageContentSubsection": "Содержимое страницы", + "maintenancePageContentSubsectionDescription": "Настройте содержимое, отображаемое на странице обслуживания", "pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания", "maintenancePageMessage": "Сообщение об обслуживании", "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?", "idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.", "idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений", + "idpConfirmDeleteAndRemoveMeFromOrg": "УДАЛИТЬ И ИЗВЛЕЧЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ", + "idpUnassociateAndRemoveMeFromOrg": "РАЗОРВАТЬ СВЯЗЬ И УДАЛИТЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ", "idpUnassociateWarning": "Это не может быть отменено для этой организации.", "idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией", "idpUnassociateMenu": "Рассоединить", @@ -3439,18 +3543,58 @@ "sshConnecting": "Подключение…", "sshInitializing": "Инициализация…", "sshSignInTitle": "Вход в SSH", - "sshSignInDescription": "Введите свои учетные данные SSH", + "sshSignInDescription": "Введите свои учетные данные SSH для подключения", "sshPasswordTab": "Пароль", "sshPrivateKeyTab": "Закрытый ключ", "sshPrivateKeyField": "Закрытый ключ", "sshPrivateKeyDisclaimer": "Ваш закрытый ключ не хранится и не виден для Pangolin. Вместо этого вы можете использовать краткосрочные сертификаты для бесшовной аутентификации с использованием вашей текущей идентификации Pangolin.", "sshLearnMore": "Узнать больше", "sshPrivateKeyFile": "Файл закрытого ключа", - "sshAuthenticate": "Аутентификация", + "sshAuthenticate": "Подключиться", "sshTerminate": "Завершить", "sshPoweredBy": "Разработано", "sshErrorNoTarget": "Цель не указана", "sshErrorWebSocket": "Подключение WebSocket не удалось", "sshErrorAuthFailed": "Ошибка аутентификации", - "sshErrorConnectionClosed": "Подключение закрыто до завершения аутентификации" + "sshErrorConnectionClosed": "Подключение закрыто до завершения аутентификации", + "sitePangolinSshDescription": "Разрешить доступ по SSH к ресурсам на этом сайте. Это можно изменить позже.", + "browserGatewayNoResourceForDomain": "Ресурс для этого домена не найден", + "browserGatewayNoTarget": "Нет цели", + "browserGatewayConnect": "Подключиться", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Не удалось подписать ключ SSH для аутентификации через PAM push. Проверьте, вошли ли вы как пользователь?", + "sshTerminalError": "Ошибка: {error}", + "sshConnectionClosedCode": "Соединение закрыто (код {code})", + "sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----", + "sshPrivateKeyRequired": "Требуется личный ключ", + "vncTitle": "VNC", + "vncSignInDescription": "Введите пароль VNC для подключения", + "vncPasswordOptional": "Пароль (необязательно)", + "vncNoResourceTarget": "Отсутствует целевой ресурс", + "vncFailedToLoadNovnc": "Не удалось загрузить noVNC", + "vncAuthFailedStatus": "Статус {status}", + "vncPasteClipboard": "Вставить из буфера обмена", + "rdpTitle": "RDP", + "rdpSignInTitle": "Вход в удаленный рабочий стол", + "rdpSignInDescription": "Введите учетные данные Windows для подключения", + "rdpLoadingModule": "Загрузка модуля...", + "rdpFailedToLoadModule": "Не удалось загрузить модуль RDP", + "rdpNotReady": "Не готово", + "rdpModuleInitializing": "Модуль RDP все еще инициализируется", + "rdpDownloadingFiles": "Загрузка {count} файлов с удалённого сервера…", + "rdpDownloadFailed": "Ошибка загрузки: {fileName}", + "rdpUploaded": "Загружено: {fileName}", + "rdpNoConnectionTarget": "Доступная цель подключения отсутствует", + "rdpConnectionFailed": "Ошибка соединения", + "rdpFit": "Подгонка", + "rdpFull": "Полный", + "rdpReal": "Настоящий", + "rdpMeta": "Метаданные", + "rdpUploadFiles": "Загрузить файлы", + "rdpFilesReadyToPaste": "Файлы готовы к вставке", + "rdpFilesReadyToPasteDescription": "{count, plural, one {# файл скопирован в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} few {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} many {# файлов скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} other {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.}}", + "rdpUploadFailed": "Ошибка загрузки", + "rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode", + "sessionToolbarShow": "Показать панель инструментов", + "sessionToolbarHide": "Скрыть панель инструментов" } From 70d5f554374e219ed7e74a40514844654ab2e898 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:55 -0700 Subject: [PATCH 029/264] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 244 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 194 insertions(+), 50 deletions(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 236adf4d4..d5fd66262 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Özel Kaynakları Görüntüle", "siteInstallNewt": "Newt Yükle", "siteInstallNewtDescription": "Newt'i sisteminizde çalıştırma", + "siteInstallKubernetesDocsDescription": "Daha fazla ve güncel Kubernetes kurulum bilgileri için docs.pangolin.net/manage/sites/install-kubernetes adresini inceleyin.", + "siteInstallAdvantechDocsDescription": "Advantech modem kurulum talimatları için docs.pangolin.net/manage/sites/install-advantech adresini inceleyin.", "WgConfiguration": "WireGuard Yapılandırması", "WgConfigurationDescription": "Ağınıza bağlanmak için aşağıdaki yapılandırmayı kullanın", "operatingSystem": "İşletim Sistemi", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", "siteInfo": "Site Bilgilendirmesi", "status": "Durum", - "shareTitle": "Paylaşım Bağlantılarını Yönet", + "shareTitle": "Paylaşılabilir Bağlantıları Yönet", "shareDescription": "Kaynaklarınıza geçici veya kalıcı erişim sağlamak için paylaşılabilir bağlantılar oluşturun", - "shareSearch": "Paylaşım bağlantılarını ara...", - "shareCreate": "Paylaşım Bağlantısı Oluştur", + "shareSearch": "Paylaşılabilir bağlantıları ara...", + "shareCreate": "Paylaşılabilir Bağlantı Oluştur", "shareErrorDelete": "Bağlantı silinirken hata oluştu", "shareErrorDeleteMessage": "Bağlantı silinirken bir hata oluştu", "shareDeleted": "Bağlantı silindi", "shareDeletedDescription": "Bağlantı silindi", - "shareDelete": "Paylaşım Bağlantısını Sil", - "shareDeleteConfirm": "Paylaşım Bağlantısının Silinmesini Onayla", + "shareDelete": "Paylaşılabilir Bağlantıyı Sil", + "shareDeleteConfirm": "Paylaşılabilir Bağlantıyı Silmeyi Onayla", "shareQuestionRemove": "Bu paylaşım bağlantısını silmek istediğinizden emin misiniz?", "shareMessageRemove": "Silindikten sonra, bağlantı artık çalışmayacak ve kullanan herkes kaynağa erişimini kaybedecek.", "shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Bu bağlantıya sahip olan herkes kaynağa erişebilir", "shareTitleOptional": "Başlık (isteğe bağlı)", "sharePathOptional": "Yol (isteğe bağlı)", + "sharePathDescription": "Bağlantıdan sonra kullanıcıları bu yola yönlendirecek bağlantıyı tanımlayın.", "expireIn": "Süresi Dolacak", "neverExpire": "Hiçbir Zaman Sona Ermez", "shareExpireDescription": "Son kullanma süresi, bağlantının kullanılabilir ve kaynağa erişim sağlayacak süresidir. Bu süreden sonra bağlantı çalışmayı durduracak ve bu bağlantıyı kullanan kullanıcılar kaynağa erişimini kaybedecektir.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Lütfen bir kaynak seçin", "proxyResourceTitle": "Herkese Açık Kaynakları Yönet", "proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin", - "publicResourcesBannerTitle": "Web Tabanlı Genel Erişim", - "publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", + "publicResourcesBannerTitle": "Web tabanlı Açık Erişim", + "publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla internette herkesin erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine istemci tarafı yazılım gerektirmezler ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", "clientResourceTitle": "Özel Kaynakları Yönet", "clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin", "privateResourcesBannerTitle": "Sıfır Güven Özel Erişim", @@ -209,15 +212,19 @@ "resourcesSearch": "Kaynakları ara...", "resourceAdd": "Kaynak Ekle", "resourceErrorDelte": "Kaynak silinirken hata", - "resourcePoliciesTitle": "Kaynak Politikalarını Yönet", - "resourcePoliciesAttachedResourcesColumnTitle": "Ekteki kaynaklar", + "resourcePoliciesBannerTitle": "Kimlik Doğrulama ve Erişim Kurallarını Yeniden Kullan", + "resourcePoliciesBannerDescription": "Paylaşılan kaynak politikaları, kimlik doğrulama yöntemlerini ve erişim kurallarını bir kez tanımlamanıza ve ardından bunları birden fazla genel kaynağa bağlamanıza olanak tanır. Bir politikayı güncellediğinizde, bağlı her kaynak değişikliği otomatik olarak devralır.", + "resourcePoliciesBannerButtonText": "Daha fazla bilgi", + "resourcePoliciesTitle": "Herkese Açık Kaynak Politikalarını Yönetin", + "resourcePoliciesAttachedResourcesColumnTitle": "Kaynaklar", "resourcePoliciesAttachedResources": "{count} kaynak", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# kaynak} other {# kaynaklar}}", "resourcePoliciesAttachedResourcesEmpty": "hiçbir kaynak", - "resourcePoliciesDescription": "Kaynaklarınıza erişimi kontrol etmek için kimlik doğrulama politikaları oluşturun ve yönetin", + "resourcePoliciesDescription": "Genel kaynaklarınıza erişimi kontrol etmek için kimlik doğrulama politikalarını oluşturun ve yönetin", "resourcePoliciesSearch": "Politikaları ara...", "resourcePoliciesAdd": "Politika Ekle", "resourcePoliciesDefaultBadgeText": "Varsayılan politika", - "resourcePoliciesCreate": "Kaynak Politikası Oluştur", + "resourcePoliciesCreate": "Genel Kaynak Politikası Oluştur", "resourcePoliciesCreateDescription": "Yeni bir politika oluşturmak için aşağıdaki adımları izleyin", "resourcePolicyName": "Politika Adı", "resourcePolicyNameDescription": "Bu politikaya kaynaklarınız arasında kolayca tanımlayabilmek için bir ad verin", @@ -274,7 +281,7 @@ "back": "Geri", "cancel": "İptal", "resourceConfig": "Yapılandırma Parçaları", - "resourceConfigDescription": "TCP/UDP kaynağınızı kurmak için bu yapılandırma parçalarını kopyalayıp yapıştırın", + "resourceConfigDescription": "TCP/UDP kaynağınızı kurmak için bu yapılandırma parçalarını kopyalayıp yapıştırın.", "resourceAddEntrypoints": "Traefik: Başlangıç Noktaları Ekleyin", "resourceExposePorts": "Gerbil: Docker Compose'da Portları Açın", "resourceLearnRaw": "TCP/UDP kaynaklarını nasıl yapılandıracağınızı öğrenin", @@ -287,6 +294,8 @@ "labelDelete": "Etiketi Sil", "labelAdd": "Etiket Ekle", "labelCreateSuccessMessage": "Etiket Başarıyla Oluşturuldu", + "labelDuplicateError": "Yinelenen Etiket", + "labelDuplicateErrorDescription": "Bu isimle bir etiket zaten var.", "labelEditSuccessMessage": "Etiket Başarıyla Değiştirildi", "labelNameField": "Etiket Adı", "labelColorField": "Etiket Rengi", @@ -311,7 +320,7 @@ "rules": "Kurallar", "resourceSettingDescription": "Kaynağınızdaki ayarları yapılandırın", "resourceSetting": "{resourceName} Ayarları", - "resourcePolicySettingDescription": "Kaynak politikası üzerindeki ayarları yapılandır", + "resourcePolicySettingDescription": "Bu açık kaynak politikasının ayarlarını yapılandırın", "resourcePolicySetting": "{policyName} Ayarları", "alwaysAllow": "Kimlik Doğrulamayı Atla", "alwaysDeny": "Erişimi Engelle", @@ -719,7 +728,7 @@ "targetSubmit": "Hedef Ekle", "targetNoOne": "Bu kaynağın hedefleri yok. Arka uca gönderilecek istekleri yapılandırmak için bir hedef ekleyin.", "targetNoOneDescription": "Yukarıdaki birden fazla hedef ekleyerek yük dengeleme etkinleştirilecektir.", - "targetsSubmit": "Hedefleri Kaydet", + "targetsSubmit": "Ayarları Kaydet", "addTarget": "Hedef Ekle", "proxyMultiSiteRoundRobinNodeHelp": "Round robin yönlendirme, aynı düğüme bağlı olmayan siteler arasında çalışmayacaktır, ancak failover çalışacaktır.", "targetErrorInvalidIp": "Geçersiz IP adresi", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Yinelenen kural", "rulesErrorDuplicateDescription": "Bu ayarlara sahip bir kural zaten mevcut", "rulesErrorInvalidIpAddressRange": "Geçersiz CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Lütfen geçerli bir CIDR değeri girin", - "rulesErrorInvalidUrl": "Geçersiz URL yolu", - "rulesErrorInvalidUrlDescription": "Lütfen geçerli bir URL yolu değeri girin", - "rulesErrorInvalidIpAddress": "Geçersiz IP", - "rulesErrorInvalidIpAddressDescription": "Lütfen geçerli bir IP adresi girin", + "rulesErrorInvalidIpAddressRangeDescription": "Geçerli bir CIDR aralığı girin (örneğin, 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Geçersiz yol", + "rulesErrorInvalidUrlDescription": "Geçerli bir URL yolu veya deseni girin (örneğin, /api/*).", + "rulesErrorInvalidIpAddress": "Geçersiz IP adresi", + "rulesErrorInvalidIpAddressDescription": "Geçerli bir IPv4 veya IPv6 adresi girin.", "rulesErrorUpdate": "Kurallar güncellenemedi", "rulesErrorUpdateDescription": "Kurallar güncellenirken bir hata oluştu", "rulesUpdated": "Kuralları Etkinleştir", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "CIDR formatında bir adres girin (örneğin, 103.21.244.0/22)", "rulesMatchIpAddress": "Bir IP adresi girin (örneğin, 103.21.244.12)", "rulesMatchUrl": "Bir URL yolu veya deseni girin (örneğin, /api/v1/todos veya /api/v1/*)", - "rulesErrorInvalidPriority": "Geçersiz Öncelik", - "rulesErrorInvalidPriorityDescription": "Lütfen geçerli bir öncelik girin", - "rulesErrorDuplicatePriority": "Yinelenen Öncelikler", - "rulesErrorDuplicatePriorityDescription": "Lütfen benzersiz öncelikler girin", + "rulesErrorInvalidPriority": "Geçersiz öncelik", + "rulesErrorInvalidPriorityDescription": "1 veya daha büyük bir tamsayı girin.", + "rulesErrorDuplicatePriority": "Yinelenen öncelikler", + "rulesErrorDuplicatePriorityDescription": "Her kuralın benzersiz bir öncelik numarası olmalıdır.", + "rulesErrorValidation": "Geçersiz kurallar", + "rulesErrorValidationRuleDescription": "Kural {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Geçerli bir eşleşme türünü seçin (yol, IP, CIDR, ülke, bölge veya ASN).", + "rulesErrorValueRequired": "Bu kural için bir değer girin.", + "rulesErrorInvalidCountry": "Geçersiz ülke", + "rulesErrorInvalidCountryDescription": "Geçerli bir ülke seçin.", + "rulesErrorInvalidAsn": "Geçersiz ASN", + "rulesErrorInvalidAsnDescription": "Geçerli bir ASN girin (örneğin, AS15169).", "ruleUpdated": "Kurallar güncellendi", "ruleUpdatedDescription": "Kurallar başarıyla güncellendi", "ruleErrorUpdate": "Operasyon başarısız oldu", "ruleErrorUpdateDescription": "Kaydetme operasyonu sırasında bir hata oluştu", "rulesPriority": "Öncelik", + "rulesReorderDragHandle": "Kural önceliğini yeniden sıralamak için sürükleyin", "rulesAction": "Aksiyon", "rulesMatchType": "Eşleşme Türü", "value": "Değer", @@ -792,7 +810,7 @@ "rulesResource": "Kaynak Kuralları Yapılandırması", "rulesResourceDescription": "Kaynağa erişimi kontrol etmek için kuralları yapılandırın", "ruleSubmit": "Kural Ekle", - "rulesNoOne": "Kural yok. Formu kullanarak bir kural ekleyin.", + "rulesNoOne": "Henüz kural yok.", "rulesOrder": "Kurallar, artan öncelik sırasına göre değerlendirilir.", "rulesSubmit": "Kuralları Kaydet", "policyErrorCreate": "Politika oluşturulurken hata oluştu", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "Beklenmeyen bir hata oluştu", "policyCreatedSuccess": "Kaynak politikası başarıyla oluşturuldu", "policyUpdatedSuccess": "Kaynak politikası başarıyla güncellendi", - "authMethodsSave": "Kimlik doğrulama yöntemlerini kaydet", + "authMethodsSave": "Ayarları Kaydet", + "policyAuthStackTitle": "Kimlik Doğrulama", + "policyAuthStackDescription": "Bu kaynağa erişim için hangi kimlik doğrulama yöntemlerinin gerekli olduğuna karar verin", + "policyAuthOrLogicTitle": "Birden fazla kimlik doğrulama yöntemi etkin", + "policyAuthOrLogicBanner": "Ziyaretçiler aşağıdaki etkin yöntemlerden herhangi birini kullanarak kimlik doğrulaması yapabilirler. Hepsini tamamlamaları gerekmez.", + "policyAuthMethodActive": "Etkin", + "policyAuthMethodOff": "Kapalı", + "policyAuthSsoTitle": "Platform SSO", + "policyAuthSsoDescription": "Organizasyonunuzun kimlik sağlayıcısı üzerinden oturum açmayı zorunlu kılın", + "policyAuthSsoSummary": "{idp} · {users} kullanıcısı, {roles} rolü", + "policyAuthSsoDefaultIdp": "Varsayılan sağlayıcı", + "policyAuthAddDefaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı Ekle", + "policyAuthOtherMethodsTitle": "Diğer Yöntemler", + "policyAuthOtherMethodsDescription": "Ziyaretçilerin platform SSO yerine veya yanı sıra kullanabileceği isteğe bağlı yöntemler", + "policyAuthPasscodeTitle": "Şifre", + "policyAuthPasscodeDescription": "Kaynağa erişim için paylaşılan bir alfasayısal şifre gerektir", + "policyAuthPasscodeSummary": "Şifre ayarlandı", + "policyAuthPincodeTitle": "PIN Kodu", + "policyAuthPincodeDescription": "Kaynağa erişim için kısa bir sayısal kod gereklidir", + "policyAuthPincodeSummary": "6 haneli PIN ayarlandı", + "policyAuthEmailTitle": "E-posta Beyaz Listesi", + "policyAuthEmailDescription": "Listelenen e-posta adreslerine tek kullanımlık parolalarla izin verin", + "policyAuthEmailSummary": "{count} adres izinli", + "policyAuthEmailOtpCallout": "E-posta beyaz listesinin etkinleştirilmesiyle ziyaretçinin girişinde bir kereye mahsus parola e-postasına gönderilecektir.", + "policyAuthHeaderAuthTitle": "Temel Başlık Kimlik Doğrulama", + "policyAuthHeaderAuthDescription": "Her istekte özel bir HTTP başlık adını ve değerini doğrulayın", + "policyAuthHeaderAuthSummary": "Başlık yapılandırıldı", + "policyAuthHeaderName": "Başlık adı", + "policyAuthHeaderValue": "Beklenen değer", + "policyAuthSetPasscode": "Şifreyi Ayarla", + "policyAuthSetPincode": "PIN Kodunu Ayarla", + "policyAuthSetEmailWhitelist": "E-posta Beyaz Listesini Ayarla", + "policyAuthSetHeaderAuth": "Temel Başlık Kimlik Doğrulamasını Ayarla", + "policyAccessRulesTitle": "Erişim Kuralları", + "policyAccessRulesEnableDescription": "Etkinleştirildiğinde, kurallar azalan sırayla değerlendirilecektir ve biri doğru olarak değerlendirildiğinde diğerine geçilecektir.", + "policyAccessRulesFirstMatch": "Kurallar yukarıdan aşağıya doğru değerlendirilir. İlk eşleşen kural sonucu belirler.", + "policyAccessRulesHowItWorks": "Kurallar, yol, IP adresi, konum veya başka kriterlere göre talepleri eşleştirir. Her kural bir eylem uygular: kimlik doğrulamayı atla, erişimi engelle veya kimlik doğrulaması için geçici olarak geç.", + "policyAccessRulesFallthroughOff": "Kurallar devre dışı bırakıldığında, tüm trafik kimlik doğrulamasına geçer.", + "policyAccessRulesFallthroughOn": "Herhangi bir kural eşleşmediğinde trafik kimlik doğrulamasına geçer.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Kuralları Kaydet", "resourceErrorCreate": "Kaynak oluşturma hatası", "resourceErrorCreateDescription": "Kaynak oluşturulurken bir hata oluştu", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu", "access": "Erişim", "accessControl": "Erişim Kontrolü", - "shareLink": "{resource} Paylaşım Bağlantısı", + "shareLink": "{resource} Paylaşılabilir Bağlantı", "resourceSelect": "Kaynak seçin", - "shareLinks": "Paylaşım Bağlantıları", + "shareLinks": "Paylaşılabilir Bağlantılar", "share": "Paylaşılabilir Bağlantılar", "shareDescription2": "Kaynaklarınıza geçici veya sınırsız erişim sağlamak için paylaşılabilir bağlantılar oluşturun. Bağlantı oluştururken sona erme süresini yapılandırabilirsiniz.", "shareEasyCreate": "Kolayca oluştur ve paylaş", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Yöneticiler her zaman bu kaynağa erişebilir.", "resourcePolicySelectTitle": "Kaynak Erişim Politikası", "resourcePolicySelectDescription": "Kimlik doğrulama için kaynak politika türünü seçin", + "resourcePolicyTypeLabel": "Politika türü", + "resourcePolicyLabel": "Kaynak politikası", "resourcePolicyInline": "Satır İçi Kaynak Politikası", "resourcePolicyInlineDescription": "Erişim Politikası sadece bu kaynağa yönelik", "resourcePolicyShared": "Paylaşılan Kaynak Politikası", - "resourcePolicySharedDescription": "Bu kaynak paylaşılan bir politika kullanır. Politika düzeyindeki ayarlar (kimlik doğrulama yöntemleri, e-posta beyaz listesi) kilitlidir. Aşağıda, kaynakla ilgili özel kurallar, roller ve kullanıcılar ekleyebilirsiniz.", + "resourcePolicySharedDescription": "Bu kaynak bir paylaşılan politika kullanıyor.", + "sharedPolicy": "Paylaşılan Politika", + "sharedPolicyNoneDescription": "Bu kaynağın kendi politikası var.", + "resourceSharedPolicyOwnDescription": "Bu kaynak, kendi kimlik doğrulama ve erişim kuralları denetimlerine sahiptir.", + "resourceSharedPolicyInheritedDescription": "Bu kaynak {policyName}'dan devralmaktadır.", + "resourceSharedPolicyAuthenticationNotice": "Bu kaynak bir ortak politika kullanıyor. Politikayı eklemek için kimlik doğrulama ayarlarını bu kaynakta düzenleyebilirsiniz. Altta yatan politikayı değiştirmek için {policyName} düzenlemelisiniz.", + "resourceSharedPolicyRulesNotice": "Bu kaynak bir paylaşılan politika kullanıyor. Bazı erişim kuralları bu kaynakta düzenlenebilir. Temel politikayı değiştirmek için, {policyName} düzenlemeniz gerekecektir.", "resourceUsersRoles": "Erişim Kontrolleri", "resourceUsersRolesDescription": "Bu kaynağı kimlerin ziyaret edebileceği kullanıcıları ve rolleri yapılandırın", "resourceUsersRolesSubmit": "Erişim Kontrollerini Kaydet", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Görünürlük", "resourceVisibilityTitleDescription": "Kaynak görünürlüğünü tamamen etkinleştirin veya devre dışı bırakın", "resourceGeneral": "Genel Ayarlar", - "resourceGeneralDescription": "Bu kaynak için genel ayarları yapılandırın", + "resourceGeneralDescription": "Bu kaynak için ad, adres ve erişim politikası yapılandırın.", + "resourceGeneralDetailsSubsection": "Kaynak Detayları", + "resourceGeneralDetailsSubsectionDescription": "Bu kaynak için görüntülenen adı, tanıtıcıyı ve herkesin erişebileceği alan adını belirleyin.", + "resourceGeneralDetailsSubsectionPortDescription": "Bu kaynak için görüntülenen adı, tanıtıcıyı ve halka açık portu ayarlayın.", + "resourceGeneralPublicAddressSubsection": "Genel Adres", + "resourceGeneralPublicAddressSubsectionDescription": "Kullanıcıların bu kaynağa nasıl ulaşacağını yapılandırın.", + "resourceGeneralAuthenticationAccessSubsection": "Kimlik Doğrulama ve Erişim", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Bu kaynağın kendi politikasını mı yoksa ortak bir politikadan mı devralacağını seçin.", "resourceEnable": "Kaynağı Etkinleştir", "resourceTransfer": "Kaynağı Aktar", "resourceTransferDescription": "Bu kaynağı farklı bir siteye aktarın", @@ -1220,11 +1294,14 @@ "addLabels": "Etiketler ekle", "siteLabelsTab": "Etiketler", "siteLabelsDescription": "Bu siteyle ilişkili etiketleri yönetin.", - "labelsNotFound": "Etiketler bulunamadı", + "labelsNotFound": "Etiket bulunamadı.", + "labelsEmptyCreateHint": "Etiket oluşturmak için yukarıdan yazmaya başlayın.", "labelSearch": "Etiket ara", + "labelSearchOrCreate": "Etiket arayın veya oluşturun", "accessLabelFilterCount": "{count, plural, one {# etiket} other {# etiketler}}", "labelOverflowCount": "+{count, plural, one {# etiket} other {# etiketler}}", "accessLabelFilterClear": "Etiket filtrelerini temizle", + "accessFilterClear": "Filtreleri temizle", "selectColor": "Renk seç", "createNewLabel": "Yeni kuruluş etiketi \"{label}\" oluştur", "inviteInvalidDescription": "Davet bağlantısı geçersiz.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Kaynaklar", "sidebarProxyResources": "Herkese Açık", "sidebarClientResources": "Özel", - "sidebarPolicies": "Politikalar", - "sidebarResourcePolicies": "Kaynaklar", + "sidebarPolicies": "Paylaşılan Politikalar", + "sidebarResourcePolicies": "Açık Kaynaklar", "sidebarAccessControl": "Erişim Kontrolü", "sidebarLogsAndAnalytics": "Kayıtlar & Analitik", "sidebarTeam": "Ekip", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Yönetici", "sidebarInvitations": "Davetiye", "sidebarRoles": "Roller", - "sidebarShareableLinks": "Bağlantılar", + "sidebarShareableLinks": "Paylaşılabilir Bağlantılar", "sidebarApiKeys": "API Anahtarları", "sidebarProvisioning": "Tedarik", "sidebarSettings": "Ayarlar", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Kaynak {id}", "blueprints": "Planlar", "blueprintsLog": "Şablonlar Günlüğü", - "blueprintsDescription": "Geçmiş şablon uygulamalarını ve sonuçlarını görüntüleyin", + "blueprintsDescription": "Geçmiş plan uygulamalarını ve sonuçlarını görüntüleyin veya yeni bir plan uygulayın", "blueprintAdd": "Plan Ekle", "blueprintGoBack": "Tüm Planları Gör", "blueprintCreate": "Plan Oluştur", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Docker Soketini Etkinleştir", "enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Site bağlantısına soket yolu sağlanmalıdır. Bunun nasıl çalıştığını belgelemede okuyun.", "newtAutoUpdate": "Site Otomatik-Güncellemesini Etkinleştir", - "newtAutoUpdateDescription": "Etkinleştirildiğinde, site bağdaştırıcıları yeni sürüm mevcut olduğunda otomatik olarak en son sürüme güncellenecek.", + "newtAutoUpdateDescription": "Etkinleştirildiğinde, site konektörleri en son versiyonu otomatik olarak indirir ve yeniden başlar. Bu, site bazında geçersiz kılınabilir.", "siteAutoUpdate": "Site Otomatik-Güncellemesi", "siteAutoUpdateLabel": "Otomatik Güncellemeyi Etkinleştir", - "siteAutoUpdateDescription": "Bu sitenin bağdaştırıcısının en son sürümü otomatik olarak indirip indirmeyeceğini kontrol edin.", + "siteAutoUpdateDescription": "Etkinleştirildiğinde, bu sitenin konektörü en son versiyonu otomatik olarak indirir ve kendini yeniden başlatır.", "siteAutoUpdateOrgDefault": "Kuruluş varsayılanı: {state}", "siteAutoUpdateOverriding": "Kuruluş ayarını geçersiz kılıyor", "siteAutoUpdateResetToOrg": "Kuruluş Varsayılanına Sıfırla", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Hesap kurulumu tamamlandı! Pangolin'e hoş geldiniz!", "documentation": "Dokümantasyon", "saveAllSettings": "Tüm Ayarları Kaydet", - "saveResourceTargets": "Hedefleri Kaydet", - "saveResourceHttp": "Proxy Ayarlarını Kaydet", - "saveProxyProtocol": "Proxy protokol ayarlarını kaydet", + "saveResourceTargets": "Ayarları Kaydet", + "saveResourceHttp": "Ayarları Kaydet", + "saveProxyProtocol": "Ayarları Kaydet", "settingsUpdated": "Ayarlar güncellendi", "settingsUpdatedDescription": "Ayarlar başarıyla güncellendi", "settingsErrorUpdate": "Ayarlar güncellenemedi", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Bilinmiyor", "healthCheck": "Sağlık Kontrolü", "configureHealthCheck": "Sağlık Kontrolünü Yapılandır", - "configureHealthCheckDescription": "{hedef} için sağlık izleme kurun", + "configureHealthCheckDescription": "Kaynağınızın her zaman erişilebilir olduğundan emin olmak için izleme kurun", "enableHealthChecks": "Sağlık Kontrollerini Etkinleştir", "healthCheckDisabledStateDescription": "Devre dışı bırakıldığında, site sağlık kontrolleri yapmaz ve durum bilinmeyen olarak kabul edilecektir.", "enableHealthChecksDescription": "Bu hedefin sağlığını izleyin. Gerekirse hedef dışındaki bir son noktayı izleyebilirsiniz.", "healthScheme": "Yöntem", "healthSelectScheme": "Yöntem Seç", - "healthCheckPortInvalid": "Sağlık Kontrolü portu 1 ile 65535 arasında olmalıdır", + "healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır", "healthCheckPath": "Yol", "healthHostname": "IP / Hostname", "healthPort": "Bağlantı Noktası", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Cihaz Onaylarını Gerektir", "requireDeviceApprovalDescription": "Bu role sahip kullanıcıların yeni cihazlarının bağlanabilmesi ve kaynaklara erişebilmesi için bir yönetici tarafından onaylanması gerekiyor.", "sshSettings": "SSH Ayarları", + "sshAccess": "SSH Erişimi", "rdpSettings": "RDP Ayarları", "vncSettings": "VNC Ayarları", "sshServer": "SSH Sunucusu", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Bu kurulumu tamamlamadan önce hedef ana bilgisayarınızın kimlik doğrulama daemonunu çalıştıracak şekilde düzgün yapılandırıldığından emin olun, aksi takdirde sağlama başarısız olur.", "sshDaemonPort": "Daemon Bağlantı Noktası", "sshServerDestination": "Sunucu Hedefi", - "sshServerDestinationDescription": "SSH sunucusunun hedefini ve bağlantı noktasını yapılandırın", + "sshServerDestinationDescription": "SSH sunucusunun hedefini yapılandırın", "destination": "Hedef", + "destinationRequired": "Hedef gereklidir.", + "domainRequired": "Alan adı gereklidir.", + "proxyPortRequired": "Bağlantı noktası gereklidir.", + "invalidPathConfiguration": "Geçersiz yol yapılandırması.", + "invalidRewritePathConfiguration": "Geçersiz yol yeniden yazma yapılandırması.", "bgTargetMultiSiteDisclaimer": "Birden fazla site seçmek, yüksek erişilebilirlik için dayanıklı yönlendirme ve failover sağlar.", "roleAllowSsh": "SSH'a İzin Ver", "roleAllowSshAllow": "İzin Ver", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.", "sshSudo": "Sudo'ya izin ver", "sshSudoCommands": "Sudo Komutları", - "sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların virgülle ayrılmış listesi. Mutlak yollar kullanılmalıdır.", + "sshSudoCommandsDescription": "Kullanıcının 'sudo' ile çalıştırmasına izin verilen komutlar listesi noktalı virgülle, boşluk veya yeni satırla ayrılmalıdır. Mutlak yollar kullanılmalıdır.", "sshCreateHomeDir": "Ev Dizini Oluştur", "sshUnixGroups": "Unix Grupları", - "sshUnixGroupsDescription": "Hedef konakta kullanıcıya eklenecek Unix gruplarının virgülle ayrılmış listesi.", + "sshUnixGroupsDescription": "Hedef ana bilgisayardaki kullanıcıya eklemek için Unix grupları, noktalı virgülle, boşluk veya yeni satırla ayrılmalıdır.", + "roleTextFieldPlaceholder": "Değerleri girin veya bir .txt veya .csv dosyası bırakın", + "roleTextImportTitle": "Dosyadan İçe Aktar", + "roleTextImportDescription": "{fileName} dosyası {fieldLabel} alanına içe aktarılıyor.", + "roleTextImportSkipHeader": "İlk Satırı Atla (Başlık)", + "roleTextImportOverride": "Mevcut Olanın Yerine Yaz", + "roleTextImportAppend": "Mevcut olana Ekle", + "roleTextImportMode": "İçe Aktarma Modu", + "roleTextImportPreview": "Seçilen Dosya", + "roleTextImportItemCount": "{count, plural, =0 {İçe aktarılacak öğe yok} one {İçe aktarılacak 1 öğe} other {İçe aktarılacak # öğe}}", + "roleTextImportTotalCount": "{existing} mevcut + {imported} ithal = {total} toplam", + "roleTextImportConfirm": "İçe Aktar", + "roleTextImportInvalidFile": "Desteklenmeyen dosya türü", + "roleTextImportInvalidFileDescription": "Yalnızca .txt ve .csv dosyaları desteklenir.", + "roleTextImportEmpty": "Dosyada öğe bulunamadı", + "roleTextImportEmptyDescription": "Dosya, içe aktarılabilir öğe içermiyor.", "retryAttempts": "Tekrar Deneme Girişimleri", "expectedResponseCodes": "Beklenen Yanıt Kodları", "expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Proxy Protokolünü Etkinleştir", "proxyProtocolInfo": "TCP ara yüzlerini koruyarak istemci IP adreslerini saklayın", "proxyProtocolVersion": "Proxy Protokol Versiyonu", - "version1": " Versiyon 1 (Önerilen)", + "version1": "Versiyon 1 (Önerilen)", "version2": "Versiyon 2", - "versionDescription": "Versiyon 1 metin tabanlı ve yaygın olarak desteklenir. Versiyon 2 ise ikili ve daha verimlidir ama daha az uyumludur.", + "version1Description": "Metin tabanlı ve yaygın olarak desteklenmektedir. Sunucu taşımacılığının dinamik yapılandırmaya eklenmiş olduğundan emin olun.", + "version2Description": "İkili ve daha verimli ama daha az uyumlu. Sunucu taşımasının dinamik yapılandırmaya eklendiğinden emin olun.", "warning": "Uyarı", "proxyProtocolWarning": "Arka uç uygulamanız, Proxy Protokol bağlantılarını kabul etmek üzere yapılandırılmalıdır. Arka ucunuz Proxy Protokolünü desteklemiyorsa, bunu etkinleştirmek tüm bağlantıları koparır. Traefik'ten gelen Proxy Protokol başlıklarına güvenecek şekilde arka ucunuzu yapılandırdığınızdan emin olun.", "restarting": "Yeniden Başlatılıyor...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Onayı girin", "blueprintViewDetails": "Detaylar", "defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı", - "defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.", + "defaultIdentityProviderDescription": "Kullanıcı, kimlik doğrulama için bu kimlik sağlayıcısına otomatik olarak yönlendirilecektir.", "editInternalResourceDialogNetworkSettings": "Ağ Ayarları", "editInternalResourceDialogAccessPolicy": "Erişim Politikası", "editInternalResourceDialogAddRoles": "Roller Ekle", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Bakım Modu Türü", "showMaintenancePage": "Ziyaretçilere bir bakım sayfası gösterin", "enableMaintenanceMode": "Bakım Modunu Etkinleştir", + "enableMaintenanceModeDescription": "Etkinleştirildiğinde, ziyaretçiler kaynak yerine bir bakım sayfası görecekler.", "automatic": "Otomatik", "automaticModeDescription": "Tüm arka uç hedefleri kapalı veya sağlıksız olduğunda yalnızca bakım sayfasını gösterin. Sağlıklı en az bir hedef olduğu sürece kaynağınız normal şekilde çalışmaya devam eder.", "forced": "Zorunlu", @@ -3082,6 +3182,8 @@ "warning:": "Uyarı:", "forcedeModeWarning": "Tüm trafik bakım sayfasına yönlendirilecek. Arka plan kaynaklarınız herhangi bir isteği almayacaktır.", "pageTitle": "Sayfa Başlığı", + "maintenancePageContentSubsection": "Sayfa İçeriği", + "maintenancePageContentSubsectionDescription": "Bakım sayfasında gösterilen içeriği özelleştirin", "pageTitleDescription": "Bakım sayfasında gösterilen ana başlık", "maintenancePageMessage": "Bakım Mesajı", "maintenancePageMessagePlaceholder": "Yakında geri döneceğiz! Sitemiz şu anda planlı bakım altındadır.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Bu kimlik sağlayıcının bu kuruluştan ilişiğini kesmek istediğinizden emin misiniz?", "idpUnassociateDescription": "Bu kimlik sağlayıcı ile ilişkilendirilen tüm kullanıcılar bu kuruluştan kaldırılacaktır, ancak kimlik sağlayıcı diğer ilişkilendirilen kuruluşlar için var olmaya devam edecektir.", "idpUnassociateConfirm": "Kimlik Sağlayıcının İlişkisinin Kesilmesini Onayla", + "idpConfirmDeleteAndRemoveMeFromOrg": "BENİ SİL VE ORGANİZASYONDAN ÇIKAR", + "idpUnassociateAndRemoveMeFromOrg": "BENİ İLİŞKİLENDİRMEYİ BIRAK VE ORGANİZASYONDAN ÇIKAR", "idpUnassociateWarning": "Bu işlem bu kuruluş için geri alınamaz.", "idpUnassociatedDescription": "Kimlik sağlayıcı bu kuruluştan başarıyla ayrıldı", "idpUnassociateMenu": "İlişkiyi Kes", @@ -3439,18 +3543,58 @@ "sshConnecting": "Bağlanılıyor…", "sshInitializing": "Başlatılıyor…", "sshSignInTitle": "SSH'a Giriş Yap", - "sshSignInDescription": "SSH kimlik bilgilerinizi girin", + "sshSignInDescription": "Bağlanmak için SSH kimlik bilgilerinizi girin", "sshPasswordTab": "Şifre", "sshPrivateKeyTab": "Özel Anahtar", "sshPrivateKeyField": "Özel Anahtar", "sshPrivateKeyDisclaimer": "Özel anahtarınız Pangolin'de saklanmaz veya görünmez. Alternatif olarak, mevcut Pangolin kimliğinizle sorunsuz kimlik doğrulama için kısa ömürlü sertifikalar kullanabilirsiniz.", "sshLearnMore": "Daha fazla bilgi", "sshPrivateKeyFile": "Özel Anahtar Dosyası", - "sshAuthenticate": "Kimlik Doğrulama", + "sshAuthenticate": "Bağlan", "sshTerminate": "Sonlandır", "sshPoweredBy": "Tarafından sağlanmaktadır", "sshErrorNoTarget": "Belirtilen hedef yok", "sshErrorWebSocket": "WebSocket bağlantısı başarısız oldu", "sshErrorAuthFailed": "Kimlik doğrulama başarısız", - "sshErrorConnectionClosed": "Kimlik doğrulama tamamlanmadan bağlantı kapandı" + "sshErrorConnectionClosed": "Kimlik doğrulama tamamlanmadan bağlantı kapandı", + "sitePangolinSshDescription": "Bu site üzerindeki kaynaklara SSH erişimine izin verin. Bu ayar sonradan değiştirilebilir.", + "browserGatewayNoResourceForDomain": "Bu etki alanı için kaynak bulunamadı", + "browserGatewayNoTarget": "Hedef Yok", + "browserGatewayConnect": "Bağlan", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "PAM itmeli kimlik doğrulama için SSH anahtarı imzalanamadı. Kullanıcı olarak oturum açtınız mı?", + "sshTerminalError": "Hata: {error}", + "sshConnectionClosedCode": "Bağlantı kapandı (kod {code})", + "sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----", + "sshPrivateKeyRequired": "Özel anahtar gereklidir", + "vncTitle": "VNC", + "vncSignInDescription": "Bağlanmak için VNC parolanızı girin", + "vncPasswordOptional": "Parola (isteğe bağlı)", + "vncNoResourceTarget": "Kaynak hedefi mevcut değil", + "vncFailedToLoadNovnc": "NoVNC yüklenemedi", + "vncAuthFailedStatus": "Durum {status}", + "vncPasteClipboard": "Panoya yapıştır", + "rdpTitle": "RDP", + "rdpSignInTitle": "Uzak Masaüstü'ne Giriş Yap", + "rdpSignInDescription": "Bağlanmak için Windows kimlik bilgilerinizi girin", + "rdpLoadingModule": "Modül yükleniyor...", + "rdpFailedToLoadModule": "RDP modülü yüklenemedi", + "rdpNotReady": "Hazır değil", + "rdpModuleInitializing": "RDP modülü hala başlatılıyor", + "rdpDownloadingFiles": "Uzak {count, plural, one {dosya} other {dosya}} indiriliyor...", + "rdpDownloadFailed": "İndirme başarısız: {fileName}", + "rdpUploaded": "Yüklendi: {fileName}", + "rdpNoConnectionTarget": "Bağlantı hedefi yok", + "rdpConnectionFailed": "Bağlantı başarısız oldu", + "rdpFit": "Sığdır", + "rdpFull": "Tam", + "rdpReal": "Gerçek", + "rdpMeta": "Meta", + "rdpUploadFiles": "Dosya yükle", + "rdpFilesReadyToPaste": "Yapıştırmak üzere dosyalar hazır", + "rdpFilesReadyToPasteDescription": "{count} dosya uzak panoya kopyalandı — yapıştırmak için uzak masaüstünde Ctrl+V tuşlarına basın.", + "rdpUploadFailed": "Yükleme başarısız", + "rdpUnicodeKeyboardMode": "Unicode klavye modu", + "sessionToolbarShow": "Araç çubuğunu göster", + "sessionToolbarHide": "Araç çubuğunu gizle" } From ae4f5aa58d7a749e8a6c7e974c05c3d1dea99dd8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:57 -0700 Subject: [PATCH 030/264] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 244 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 194 insertions(+), 50 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index cabee95be..e25c82e52 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "查看私有资源", "siteInstallNewt": "安装 Newt", "siteInstallNewtDescription": "在您的系统中运行 Newt", + "siteInstallKubernetesDocsDescription": "有关最新的 Kubernetes 安装信息,请参阅docs.pangolin.net/manage/sites/install-kubernetes。", + "siteInstallAdvantechDocsDescription": "有关 Advantech 调制解调器安装说明,请参阅docs.pangolin.net/manage/sites/install-advantech。", "WgConfiguration": "WireGuard 配置", "WgConfigurationDescription": "使用以下配置连接到网络", "operatingSystem": "操作系统", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。", "siteInfo": "站点信息", "status": "状态", - "shareTitle": "管理共享链接", + "shareTitle": "管理可共享链接", "shareDescription": "创建可共享的链接,允许临时或永久访问代理资源", - "shareSearch": "搜索共享链接...", - "shareCreate": "创建共享链接", + "shareSearch": "搜索可共享链接……", + "shareCreate": "创建可共享链接", "shareErrorDelete": "删除链接失败", "shareErrorDeleteMessage": "删除链接时出错", "shareDeleted": "链接已删除", "shareDeletedDescription": "链接已删除", - "shareDelete": "删除共享链接", - "shareDeleteConfirm": "确认删除共享链接", + "shareDelete": "删除可共享链接", + "shareDeleteConfirm": "确认删除可共享链接", "shareQuestionRemove": "您确定要删除这个共享链接吗?", "shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。", "shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。", @@ -177,6 +179,7 @@ "shareCreateDescription": "任何具有此链接的人都可以访问资源", "shareTitleOptional": "标题 (可选)", "sharePathOptional": "路径(可选)", + "sharePathDescription": "认证后,链接将把用户重定向到此路径。", "expireIn": "过期时间", "neverExpire": "永不过期", "shareExpireDescription": "过期时间是链接可以使用并提供对资源的访问时间。 此时间后,链接将不再工作,使用此链接的用户将失去对资源的访问。", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "请选择一个资源", "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", - "publicResourcesBannerTitle": "基于Web的公共访问", - "publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", + "publicResourcesBannerTitle": "基于 Web 的公共访问", + "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", "privateResourcesBannerTitle": "零信任的私人访问", @@ -209,15 +212,19 @@ "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", - "resourcePoliciesTitle": "管理资源策略", - "resourcePoliciesAttachedResourcesColumnTitle": "附加资源", + "resourcePoliciesBannerTitle": "重用身份验证和访问规则", + "resourcePoliciesBannerDescription": "共享资源策略可让您定义身份验证方法和访问规则,然后将其应用到多个公共资源。当您更新策略时,每个链接的资源都会自动继承更改。", + "resourcePoliciesBannerButtonText": "了解更多", + "resourcePoliciesTitle": "管理公共资源策略", + "resourcePoliciesAttachedResourcesColumnTitle": "资源", "resourcePoliciesAttachedResources": "{count} 个资源", + "resourcePoliciesAttachedResourcesCount": "{count, plural, other {# 个资源}}", "resourcePoliciesAttachedResourcesEmpty": "没有资源", - "resourcePoliciesDescription": "创建和管理身份验证策略以控制对资源的访问", + "resourcePoliciesDescription": "创建和管理身份验证策略以控制对公共资源的访问", "resourcePoliciesSearch": "搜索策略……", "resourcePoliciesAdd": "添加策略", "resourcePoliciesDefaultBadgeText": "默认策略", - "resourcePoliciesCreate": "创建资源策略", + "resourcePoliciesCreate": "创建公共资源策略", "resourcePoliciesCreateDescription": "按照以下步骤创建新策略", "resourcePolicyName": "策略名称", "resourcePolicyNameDescription": "给此策略命名,以便在您的资源中识别它", @@ -274,7 +281,7 @@ "back": "后退", "cancel": "取消", "resourceConfig": "配置片段", - "resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源", + "resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源。", "resourceAddEntrypoints": "Traefik: 添加入口点", "resourceExposePorts": "Gerbil:在 Docker Compose 中显示端口", "resourceLearnRaw": "学习如何配置 TCP/UDP 资源", @@ -287,6 +294,8 @@ "labelDelete": "删除标签", "labelAdd": "添加标签", "labelCreateSuccessMessage": "标签创建成功", + "labelDuplicateError": "标签重复", + "labelDuplicateErrorDescription": "已存在具有该名称的标签。", "labelEditSuccessMessage": "标签修改成功", "labelNameField": "标签名称", "labelColorField": "标签颜色", @@ -311,7 +320,7 @@ "rules": "规则", "resourceSettingDescription": "配置资源上的设置", "resourceSetting": "{resourceName} 设置", - "resourcePolicySettingDescription": "配置资源策略上的设置", + "resourcePolicySettingDescription": "配置此公共资源策略上的设置", "resourcePolicySetting": "{policyName} 设置", "alwaysAllow": "旁路认证", "alwaysDeny": "屏蔽访问", @@ -719,7 +728,7 @@ "targetSubmit": "添加目标", "targetNoOne": "此资源没有任何目标。添加目标来配置向后端发送请求的位置。", "targetNoOneDescription": "在上面添加多个目标将启用负载平衡。", - "targetsSubmit": "保存目标", + "targetsSubmit": "保存设置", "addTarget": "添加目标", "proxyMultiSiteRoundRobinNodeHelp": "轮询路由在未连接到相同节点的站点之间将不起作用,但故障转移会生效。", "targetErrorInvalidIp": "无效的 IP 地址", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "复制规则", "rulesErrorDuplicateDescription": "带有这些设置的规则已存在", "rulesErrorInvalidIpAddressRange": "无效的 CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "请输入一个有效的 CIDR 值", - "rulesErrorInvalidUrl": "无效的 URL 路径", - "rulesErrorInvalidUrlDescription": "请输入一个有效的 URL 路径值", - "rulesErrorInvalidIpAddress": "无效的 IP", - "rulesErrorInvalidIpAddressDescription": "请输入一个有效的IP地址", + "rulesErrorInvalidIpAddressRangeDescription": "输入有效的 CIDR 范围(例如,10.0.0.0/8)。", + "rulesErrorInvalidUrl": "无效路径", + "rulesErrorInvalidUrlDescription": "输入有效的 URL 路径或模式(例如,/api/*)。", + "rulesErrorInvalidIpAddress": "无效 IP 地址", + "rulesErrorInvalidIpAddressDescription": "输入有效的 IPv4 或 IPv6 地址。", "rulesErrorUpdate": "更新规则失败", "rulesErrorUpdateDescription": "更新规则时出错", "rulesUpdated": "启用规则", @@ -765,15 +774,24 @@ "rulesMatchIpAddressRangeDescription": "以 CIDR 格式输入地址(如:103.21.244.0/22)", "rulesMatchIpAddress": "输入IP地址(例如,103.21.244.12)", "rulesMatchUrl": "输入一个 URL 路径或模式(例如/api/v1/todos 或 /api/v1/*)", - "rulesErrorInvalidPriority": "无效的优先级", - "rulesErrorInvalidPriorityDescription": "请输入一个有效的优先级", - "rulesErrorDuplicatePriority": "重复的优先级", - "rulesErrorDuplicatePriorityDescription": "请输入唯一的优先级", + "rulesErrorInvalidPriority": "优先级无效", + "rulesErrorInvalidPriorityDescription": "输入一个大于或等于 1 的整数。", + "rulesErrorDuplicatePriority": "优先级重复", + "rulesErrorDuplicatePriorityDescription": "每条规则必须拥有唯一的优先级号。", + "rulesErrorValidation": "规则无效", + "rulesErrorValidationRuleDescription": "规则 {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "选择有效的匹配类型(路径、IP、CIDR、国家、地区或 ASN)。", + "rulesErrorValueRequired": "为此规则输入一个值。", + "rulesErrorInvalidCountry": "国家无效", + "rulesErrorInvalidCountryDescription": "选择一个有效的国家。", + "rulesErrorInvalidAsn": "ASN 无效", + "rulesErrorInvalidAsnDescription": "输入有效的 ASN(例如,AS15169)。", "ruleUpdated": "规则已更新", "ruleUpdatedDescription": "规则更新成功", "ruleErrorUpdate": "操作失败", "ruleErrorUpdateDescription": "保存过程中发生错误", "rulesPriority": "优先权", + "rulesReorderDragHandle": "拖动以重新排序规则优先级", "rulesAction": "行为", "rulesMatchType": "匹配类型", "value": "值", @@ -792,7 +810,7 @@ "rulesResource": "资源规则配置", "rulesResourceDescription": "配置规则来控制对资源的访问", "ruleSubmit": "添加规则", - "rulesNoOne": "没有规则。使用表单添加规则。", + "rulesNoOne": "尚无规则。", "rulesOrder": "规则按优先顺序评定。", "rulesSubmit": "保存规则", "policyErrorCreate": "创建策略时出错", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "发生意外错误", "policyCreatedSuccess": "资源策略创建成功", "policyUpdatedSuccess": "资源策略更新成功", - "authMethodsSave": "保存身份验证方法", + "authMethodsSave": "保存设置", + "policyAuthStackTitle": "身份验证", + "policyAuthStackDescription": "控制哪些身份验证方法需由用户执行才能访问资源", + "policyAuthOrLogicTitle": "多种身份验证方法处于激活状态", + "policyAuthOrLogicBanner": "访问者可以使用下列任意激活的方法进行身份验证。无需完成全部过程。", + "policyAuthMethodActive": "激活", + "policyAuthMethodOff": "关闭", + "policyAuthSsoTitle": "平台单点登录 SSO", + "policyAuthSsoDescription": "要求通过您组织的身份提供商登录", + "policyAuthSsoSummary": "{idp} · {users} 个用户, {roles} 个角色", + "policyAuthSsoDefaultIdp": "默认提供商", + "policyAuthAddDefaultIdentityProvider": "添加默认身份提供商", + "policyAuthOtherMethodsTitle": "其他方法", + "policyAuthOtherMethodsDescription": "访问者可以使用以下备用或联合平台 SSO 的方法", + "policyAuthPasscodeTitle": "密码", + "policyAuthPasscodeDescription": "要求使用共享字母数字密码以访资源问", + "policyAuthPasscodeSummary": "密码已设定", + "policyAuthPincodeTitle": "PIN 码", + "policyAuthPincodeDescription": "要求使用短数字代码以访问资源", + "policyAuthPincodeSummary": "6 位数字 PIN 码已设定", + "policyAuthEmailTitle": "电子邮件白名单", + "policyAuthEmailDescription": "允许列出的电子邮件地址使用一次性密码", + "policyAuthEmailSummary": "允许 {count} 个地址", + "policyAuthEmailOtpCallout": "启用电子邮件白名单将在登录时向访问者的电子邮件发送一次性密码。", + "policyAuthHeaderAuthTitle": "基础标题认证", + "policyAuthHeaderAuthDescription": "在每次请求上验证自定义 HTTP 标头名称和值", + "policyAuthHeaderAuthSummary": "标头已配置", + "policyAuthHeaderName": "标头名称", + "policyAuthHeaderValue": "预期值", + "policyAuthSetPasscode": "设定密码", + "policyAuthSetPincode": "设置 PIN 码", + "policyAuthSetEmailWhitelist": "设置电子邮件白名单", + "policyAuthSetHeaderAuth": "设置基础标题认证", + "policyAccessRulesTitle": "访问规则", + "policyAccessRulesEnableDescription": "启用后,规则将按下降顺序进行评估,直到某条规则评估结果为真。", + "policyAccessRulesFirstMatch": "规则将自上而下进行评估。首次匹配的规则决定结果。", + "policyAccessRulesHowItWorks": "规则通过路径、IP 地址、位置或其他标准匹配请求。每个规则都会应用操作:绕过身份验证、阻止访问或通过身份验证。如果没有规则匹配,流量将继续通过身份验证。", + "policyAccessRulesFallthroughOff": "禁用规则后,所有流量将通过身份验证。", + "policyAccessRulesFallthroughOn": "没有规则匹配时,流量将通过身份验证。", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "保存规则", "resourceErrorCreate": "创建资源时出错", "resourceErrorCreateDescription": "创建资源时出错", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", "accessControl": "访问控制", - "shareLink": "{resource} 的分享链接", + "shareLink": "{resource} 可共享链接", "resourceSelect": "选择资源", - "shareLinks": "分享链接", + "shareLinks": "可共享链接", "share": "分享链接", "shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", "shareEasyCreate": "轻松创建和分享", @@ -916,10 +975,18 @@ "resourceRoleDescription": "管理员总是可以访问此资源。", "resourcePolicySelectTitle": "资源访问策略", "resourcePolicySelectDescription": "选择用于认证的资源策略类型", + "resourcePolicyTypeLabel": "策略类型", + "resourcePolicyLabel": "资源策略", "resourcePolicyInline": "内联资源策略", "resourcePolicyInlineDescription": "仅限于此资源的访问策略", "resourcePolicyShared": "共享资源策略", - "resourcePolicySharedDescription": "此资源使用共享策略。策略级设置(身份验证方法、电子邮件白名单)被锁定。您可以在下方添加特定资源的规则、角色和用户。", + "resourcePolicySharedDescription": "此资源使用共享策略。", + "sharedPolicy": "共享策略", + "sharedPolicyNoneDescription": "此资源有自己的策略。", + "resourceSharedPolicyOwnDescription": "此资源具有自己的身份验证和访问规则控制。", + "resourceSharedPolicyInheritedDescription": "此资源继承自{policyName}。", + "resourceSharedPolicyAuthenticationNotice": "此资源使用共享策略。一些身份验证设置可以在此资源上编辑以添加到策略。要更改基础策略,您必须编辑{policyName}。", + "resourceSharedPolicyRulesNotice": "此资源正在使用一个共享策略。某些访问规则可以在此资源上编辑。要更改基础策略,您必须编辑{policyName}。", "resourceUsersRoles": "访问控制", "resourceUsersRolesDescription": "配置用户和角色可以访问此资源", "resourceUsersRolesSubmit": "保存访问控制", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "可见性", "resourceVisibilityTitleDescription": "完全启用或禁用资源可见性", "resourceGeneral": "常规设置", - "resourceGeneralDescription": "配置此资源的常规设置", + "resourceGeneralDescription": "配置资源的名称、地址和访问策略。", + "resourceGeneralDetailsSubsection": "资源详情", + "resourceGeneralDetailsSubsectionDescription": "设置资源的显示名称、标识符和公共访问域名。", + "resourceGeneralDetailsSubsectionPortDescription": "设置资源的显示名称、标识符和公共端口。", + "resourceGeneralPublicAddressSubsection": "公共地址", + "resourceGeneralPublicAddressSubsectionDescription": "配置用户如何访问该资源。", + "resourceGeneralAuthenticationAccessSubsection": "身份验证与访问", + "resourceGeneralAuthenticationAccessSubsectionDescription": "选择该资源是使用其自己的策略还是从共享策略继承。", "resourceEnable": "启用资源", "resourceTransfer": "转移资源", "resourceTransferDescription": "将此资源转移到另一个站点", @@ -1220,11 +1294,14 @@ "addLabels": "添加标签", "siteLabelsTab": "标签", "siteLabelsDescription": "管理与此网站相关的标签。", - "labelsNotFound": "找不到标签", + "labelsNotFound": "未找到标签。", + "labelsEmptyCreateHint": "在上方输入以创建标签。", "labelSearch": "搜索标签", + "labelSearchOrCreate": "搜索或创建标签", "accessLabelFilterCount": "{count, plural, other {# 标签}}", "labelOverflowCount": "+{count, plural, other {# 标签}}", "accessLabelFilterClear": "清除标签过滤器", + "accessFilterClear": "清除筛选器", "selectColor": "选择颜色", "createNewLabel": "创建新的组织标签 \"{label}\"", "inviteInvalidDescription": "邀请链接无效。", @@ -1461,8 +1538,8 @@ "sidebarResources": "资源", "sidebarProxyResources": "公开的", "sidebarClientResources": "非公开的", - "sidebarPolicies": "策略", - "sidebarResourcePolicies": "资源", + "sidebarPolicies": "共享策略", + "sidebarResourcePolicies": "公共资源", "sidebarAccessControl": "访问控制", "sidebarLogsAndAnalytics": "日志与分析", "sidebarTeam": "团队", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "管理员", "sidebarInvitations": "邀请", "sidebarRoles": "角色", - "sidebarShareableLinks": "链接", + "sidebarShareableLinks": "可共享链接", "sidebarApiKeys": "API密钥", "sidebarProvisioning": "置备中", "sidebarSettings": "设置", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "资源 {id}", "blueprints": "蓝图", "blueprintsLog": "蓝图日志", - "blueprintsDescription": "查看过去的蓝图应用及其结果", + "blueprintsDescription": "查看过去的蓝图应用及其结果或应用一个新的蓝图", "blueprintAdd": "添加蓝图", "blueprintGoBack": "查看所有蓝图", "blueprintCreate": "创建蓝图", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "启用 Docker 蓝图", "enableDockerSocketDescription": "启用用于蓝图标签的 Docker 套接字标签擦除。必须为站点连接器提供套接字路径。阅读文档以了解相关工作原理。", "newtAutoUpdate": "启用站点自动更新", - "newtAutoUpdateDescription": "启用时,站点连接器将在有新版本发布时自动更新到最新版本。", + "newtAutoUpdateDescription": "启用后,站点连接器将自动下载最新版本并重新启动。可以针对每个站点进行覆盖。", "siteAutoUpdate": "站点自动更新", "siteAutoUpdateLabel": "启用自动更新", - "siteAutoUpdateDescription": "控制此站点连接器是否自动下载最新版本。", + "siteAutoUpdateDescription": "启用后,该站点的连接器将自动下载最新版本并重新启动。", "siteAutoUpdateOrgDefault": "组织默认设置:{state}", "siteAutoUpdateOverriding": "覆盖组织设置", "siteAutoUpdateResetToOrg": "重置为组织默认设置", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "账号设置完成!欢迎来到 Pangolin!", "documentation": "文档", "saveAllSettings": "保存所有设置", - "saveResourceTargets": "保存目标", - "saveResourceHttp": "保存代理设置", - "saveProxyProtocol": "保存代理协议设置", + "saveResourceTargets": "保存设置", + "saveResourceHttp": "保存设置", + "saveProxyProtocol": "保存设置", "settingsUpdated": "设置已更新", "settingsUpdatedDescription": "设置更新成功", "settingsErrorUpdate": "设置更新失败", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "未知", "healthCheck": "健康检查", "configureHealthCheck": "配置健康检查", - "configureHealthCheckDescription": "为 {target} 设置健康监控", + "configureHealthCheckDescription": "为您的资源设置监控以确保其始终可用", "enableHealthChecks": "启用健康检查", "healthCheckDisabledStateDescription": "禁用后,站点不会进行健康检查,状态将被视为未知。", "enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。", "healthScheme": "方法", "healthSelectScheme": "选择方法", - "healthCheckPortInvalid": "健康检查端口必须介于 1 到 65535 之间", + "healthCheckPortInvalid": "端口必须在 1 和 65535 之间", "healthCheckPath": "路径", "healthHostname": "IP / 主机", "healthPort": "端口", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "需要设备批准", "requireDeviceApprovalDescription": "具有此角色的用户需要管理员批准的新设备才能连接和访问资源。", "sshSettings": "SSH 设置", + "sshAccess": "SSH 访问", "rdpSettings": "RDP 设置", "vncSettings": "VNC 设置", "sshServer": "SSH 服务器", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "在完成本设置之前,请确保您的目标主机已经正确配置以运行身份验证守护程序,否则配置将失败。", "sshDaemonPort": "守护程序端口", "sshServerDestination": "服务器目标", - "sshServerDestinationDescription": "配置 SSH 服务器的目标和端口", + "sshServerDestinationDescription": "配置 SSH 服务器的目的地", "destination": "目标", + "destinationRequired": "需要目的地。", + "domainRequired": "需要域。", + "proxyPortRequired": "需要端口。", + "invalidPathConfiguration": "路径配置无效。", + "invalidRewritePathConfiguration": "重写路径配置无效。", "bgTargetMultiSiteDisclaimer": "选择多个站点可实现高可用性的弹性路由和故障转移。", "roleAllowSsh": "允许 SSH", "roleAllowSshAllow": "允许", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。", "sshSudo": "允许Sudo", "sshSudoCommands": "Sudo 命令", - "sshSudoCommandsDescription": "逗号分隔的命令列表,用户可以通过sudo运行。必须使用绝对路径。", + "sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。", "sshCreateHomeDir": "创建主目录", "sshUnixGroups": "Unix 组", - "sshUnixGroupsDescription": "用逗号分隔了Unix组,将用户添加到目标主机上。", + "sshUnixGroupsDescription": "在目标主机上将用户添加到的 Unix 组,以逗号、空格或新行分隔。", + "roleTextFieldPlaceholder": "输入值,或放入 .txt 或 .csv 文件", + "roleTextImportTitle": "从文件导入", + "roleTextImportDescription": "将 {fileName} 导入到 {fieldLabel}。", + "roleTextImportSkipHeader": "跳过第一行(标题)", + "roleTextImportOverride": "替换现有", + "roleTextImportAppend": "附加到现有", + "roleTextImportMode": "导入模式", + "roleTextImportPreview": "预览", + "roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}", + "roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计", + "roleTextImportConfirm": "导入", + "roleTextImportInvalidFile": "不支持的文件类型", + "roleTextImportInvalidFileDescription": "仅支持 .txt 和 .csv 文件。", + "roleTextImportEmpty": "文件中未找到项目", + "roleTextImportEmptyDescription": "文件不包含任何可导入的项目。", "retryAttempts": "重试次数", "expectedResponseCodes": "期望响应代码", "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "启用代理协议", "proxyProtocolInfo": "为TCP后端保留客户端IP地址", "proxyProtocolVersion": "代理协议版本", - "version1": " 版本 1 (推荐)", + "version1": "版本 1(推荐)", "version2": "版本 2", - "versionDescription": "版本 1 是基于文本和广泛支持的版本。版本 2 是二进制和更有效率但不那么兼容。", + "version1Description": "基于文本和广泛支持。确保服务器传输添加到动态配置。", + "version2Description": "二进制且更有效率但兼容性较低。确保服务器传输添加到动态配置。", "warning": "警告", "proxyProtocolWarning": "后端应用程序必须配置为接受代理协议连接。 如果您的后端不支持代理协议,启用此功能将会中断所有连接,只有当您知道自己在做什么时才能启用此功能。 请务必从Traefik配置您的后端到信任代理协议标题。", "restarting": "正在重启...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "输入确认", "blueprintViewDetails": "详细信息", "defaultIdentityProvider": "默认身份提供商", - "defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。", + "defaultIdentityProviderDescription": "用户将自动重定向到此身份提供者进行身份验证。", "editInternalResourceDialogNetworkSettings": "网络设置", "editInternalResourceDialogAccessPolicy": "访问策略", "editInternalResourceDialogAddRoles": "添加角色", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "维护模式类型", "showMaintenancePage": "只在所有后端目标都故障或不健康时显示维护页面。只要至少一个目标健康,您的资源将正常工作。", "enableMaintenanceMode": "启用维护模式", + "enableMaintenanceModeDescription": "启用后,访问者将看到维护页面而不是您的资源。", "automatic": "自动", "automaticModeDescription": "如果所有后端目标都故障或不健康,则仅显示维护页面。只要至少一个目标健康,您的资源将正常工作。", "forced": "强制", @@ -3082,6 +3182,8 @@ "warning:": "警告:", "forcedeModeWarning": "所有流量将被引导到维护页面。您的后端资源不会收到任何请求。", "pageTitle": "页面标题", + "maintenancePageContentSubsection": "页面内容", + "maintenancePageContentSubsectionDescription": "自定义维护页面上显示的内容", "pageTitleDescription": "维护页面显示的主标题", "maintenancePageMessage": "维护信息", "maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "您确定要将此身份提供者从此组织中取消关联吗?", "idpUnassociateDescription": "与此身份提供者关联的所有用户将从该组织中移除,但身份提供者仍会继续存在于关联的其他组织中。", "idpUnassociateConfirm": "确认取消关联身份提供者", + "idpConfirmDeleteAndRemoveMeFromOrg": "删除并将我从组织中移除", + "idpUnassociateAndRemoveMeFromOrg": "解除关联并将我从组织中移除", "idpUnassociateWarning": "此操作无法对该组织撤销。", "idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联", "idpUnassociateMenu": "取消关联", @@ -3439,18 +3543,58 @@ "sshConnecting": "正在连接…", "sshInitializing": "初始化中…", "sshSignInTitle": "登录 SSH", - "sshSignInDescription": "输入您的 SSH 凭据", + "sshSignInDescription": "输入您的 SSH 凭据以进行连接", "sshPasswordTab": "密码", "sshPrivateKeyTab": "私钥", "sshPrivateKeyField": "私钥", "sshPrivateKeyDisclaimer": "您的私钥不会被 Pangolin 存储或显示。或者,您可以使用短期证书,使用您现有的 Pangolin 身份无缝认证。", "sshLearnMore": "了解更多", "sshPrivateKeyFile": "私钥文件", - "sshAuthenticate": "验证", + "sshAuthenticate": "连接", "sshTerminate": "终止", "sshPoweredBy": "支持者", "sshErrorNoTarget": "未指定目标", "sshErrorWebSocket": "WebSocket 连接失败", "sshErrorAuthFailed": "身份验证失败", - "sshErrorConnectionClosed": "认证完成前连接已关闭" + "sshErrorConnectionClosed": "认证完成前连接已关闭", + "sitePangolinSshDescription": "允许对该站点的资源进行 SSH 访问。可稍后更改。", + "browserGatewayNoResourceForDomain": "未找到该域的资源", + "browserGatewayNoTarget": "没有目标", + "browserGatewayConnect": "连接", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "签署 SSH 密钥以进行 PAM 推送身份验证失败。您以用户身份登录了吗?", + "sshTerminalError": "错误: {error}", + "sshConnectionClosedCode": "连接关闭 (代码 {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "需要私钥", + "vncTitle": "VNC", + "vncSignInDescription": "输入您的 VNC 密码以连接", + "vncPasswordOptional": "密码 (可选)", + "vncNoResourceTarget": "没有可用的资源目标", + "vncFailedToLoadNovnc": "加载 noVNC 失败", + "vncAuthFailedStatus": "状态 {status}", + "vncPasteClipboard": "粘贴剪贴板", + "rdpTitle": "RDP", + "rdpSignInTitle": "登录到远程桌面", + "rdpSignInDescription": "输入 Windows 凭据以连接", + "rdpLoadingModule": "加载模块中……", + "rdpFailedToLoadModule": "加载 RDP 模块失败", + "rdpNotReady": "未准备好", + "rdpModuleInitializing": "RDP 模块仍在初始化中", + "rdpDownloadingFiles": "正在从远程下载 {count} 个文件...", + "rdpDownloadFailed": "下载失败: {fileName}", + "rdpUploaded": "已上传: {fileName}", + "rdpNoConnectionTarget": "没有可用的连接目标", + "rdpConnectionFailed": "连接失败", + "rdpFit": "适合", + "rdpFull": "完整", + "rdpReal": "真实", + "rdpMeta": "元数据", + "rdpUploadFiles": "上传文件", + "rdpFilesReadyToPaste": "文件准备好粘贴", + "rdpFilesReadyToPasteDescription": "已将 {count} 个文件复制到远程剪贴板——在远程桌面上按 Ctrl+V 进行粘贴", + "rdpUploadFailed": "上传失败", + "rdpUnicodeKeyboardMode": "Unicode 键盘模式", + "sessionToolbarShow": "显示工具栏", + "sessionToolbarHide": "隐藏工具栏" } From aab51a999c71d39eae20adadbe62ce77272d2ea5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 12 Jun 2026 13:53:58 -0700 Subject: [PATCH 031/264] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 242 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 49 deletions(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 22ad13c05..52ddf8e33 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "Vis private ressurser", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til å kjøre på systemet ditt", + "siteInstallKubernetesDocsDescription": "For mer og oppdatert informasjon om Kubernetes-installasjon, se docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "For installasjonsinstruksjoner for Advantech-modem, se docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard Konfigurasjon", "WgConfigurationDescription": "Bruk følgende konfigurasjon for å koble til nettverket", "operatingSystem": "Operativsystem", @@ -148,16 +150,16 @@ "siteCredentialsSaveDescription": "Du vil kun kunne se dette én gang. Sørg for å kopiere det til et sikkert sted.", "siteInfo": "Områdeinformasjon", "status": "Status", - "shareTitle": "Administrer delingslenker", + "shareTitle": "Administrer delbare lenker", "shareDescription": "Opprett delbare lenker for å gi midlertidige eller permanent tilgang til proxyressurser", - "shareSearch": "Søk delingslenker...", - "shareCreate": "Opprett delingslenke", + "shareSearch": "Søk delbare lenker...", + "shareCreate": "Opprett delbar lenke", "shareErrorDelete": "Klarte ikke å slette lenke", "shareErrorDeleteMessage": "En feil oppstod ved sletting av lenke", "shareDeleted": "Lenke slettet", "shareDeletedDescription": "Lenken har blitt slettet", - "shareDelete": "Slett delingslenke", - "shareDeleteConfirm": "Bekreft sletting av delingslenke", + "shareDelete": "Slett delbar lenke", + "shareDeleteConfirm": "Bekreft sletting av delbar lenke", "shareQuestionRemove": "Er du sikker på at du vil slette denne delingslenken?", "shareMessageRemove": "Når slettet, vil lenken ikke lenger fungere, og alle som bruker den vil miste tilgang til ressursen.", "shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.", @@ -177,6 +179,7 @@ "shareCreateDescription": "Alle med denne lenken får tilgang til ressursen", "shareTitleOptional": "Tittel (valgfritt)", "sharePathOptional": "Bane (valgfritt)", + "sharePathDescription": "Lenken vil videresende brukere til denne stien etter autentisering.", "expireIn": "Utløper om", "neverExpire": "Utløper aldri", "shareExpireDescription": "Utløpstid er hvor lenge lenken vil være brukbar og gi tilgang til ressursen. Etter denne tiden vil lenken ikke lenger fungere, og brukere som brukte denne lenken vil miste tilgangen til ressursen.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Vennligst velg en ressurs", "proxyResourceTitle": "Administrere offentlige ressurser", "proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser", - "publicResourcesBannerTitle": "Nettbasert offentlig tilgang", - "publicResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.", + "publicResourcesBannerTitle": "Web-basert offentlig tilgang", + "publicResourcesBannerDescription": "Offentlige ressurser er HTTPS-proxyer som er tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klientprogramvare og kan inkludere identitets- og kontekstsensitive tilgangspolicyer.", "clientResourceTitle": "Administrer private ressurser", "clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient", "privateResourcesBannerTitle": "Zero-Trust privat tilgang", @@ -209,15 +212,19 @@ "resourcesSearch": "Søk i ressurser...", "resourceAdd": "Legg til ressurs", "resourceErrorDelte": "Feil ved sletting av ressurs", - "resourcePoliciesTitle": "Administrer Ressurspolitikk", - "resourcePoliciesAttachedResourcesColumnTitle": "Vedlagte ressurser", + "resourcePoliciesBannerTitle": "Gjenbruk autentisering og tilgangsregler", + "resourcePoliciesBannerDescription": "Delte ressursretningslinjer lar deg definere autentiseringsmetoder og tilgangsregler en gang, for deretter å knytte dem til flere offentlige ressurser. Når du oppdaterer en policy, arver alle tilknyttede ressurser endringen automatisk.", + "resourcePoliciesBannerButtonText": "Lær mer", + "resourcePoliciesTitle": "Administrer offentlige ressursretningslinjer", + "resourcePoliciesAttachedResourcesColumnTitle": "Ressurser", "resourcePoliciesAttachedResources": "{count} ressurs(er)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ressurs} other {# ressurser}}", "resourcePoliciesAttachedResourcesEmpty": "ingen ressurser", - "resourcePoliciesDescription": "Opprett og administrer autentiseringsregler for å kontrollere tilgang til dine ressurser", + "resourcePoliciesDescription": "Opprett og administrer autentiseringsretningslinjer for å kontrollere tilgang til dine offentlige ressurser", "resourcePoliciesSearch": "Søk etter regler...", "resourcePoliciesAdd": "Legg til policy", "resourcePoliciesDefaultBadgeText": "Standard politisk", - "resourcePoliciesCreate": "Opprett Ressurspolitikk", + "resourcePoliciesCreate": "Opprett offentlig ressursretningslinje", "resourcePoliciesCreateDescription": "Følg trinnene nedenfor for å lage en ny policy", "resourcePolicyName": "Polisnavn", "resourcePolicyNameDescription": "Gi denne policynavnet for å identifisere den på tvers av dine ressurser", @@ -274,7 +281,7 @@ "back": "Tilbake", "cancel": "Avbryt", "resourceConfig": "Konfigurasjonsutdrag", - "resourceConfigDescription": "Kopier og lim inn disse konfigurasjons-øyeblikkene for å sette opp TCP/UDP ressursen", + "resourceConfigDescription": "Kopier og lim inn disse konfigurasjonsbitene for å sette opp TCP/UDP ressursen.", "resourceAddEntrypoints": "Traefik: Legg til inngangspunkter", "resourceExposePorts": "Gerbil: Eksponer Porter i Docker Compose", "resourceLearnRaw": "Lær hvordan å konfigurere TCP/UDP-ressurser", @@ -287,6 +294,8 @@ "labelDelete": "Slett etikett", "labelAdd": "Legg til etikett", "labelCreateSuccessMessage": "Etikett opprettet vellykket", + "labelDuplicateError": "Dupliser etikett", + "labelDuplicateErrorDescription": "En etikett med dette navnet finnes allerede.", "labelEditSuccessMessage": "Etikett endret vellykket", "labelNameField": "Etikettnavn", "labelColorField": "Etikettfarge", @@ -311,7 +320,7 @@ "rules": "Regler", "resourceSettingDescription": "Konfigurere innstillingene på ressursen", "resourceSetting": "{resourceName} Innstillinger", - "resourcePolicySettingDescription": "Konfigurer innstillingene på ressurspolitikken", + "resourcePolicySettingDescription": "Konfigurer innstillingene for denne offentlige ressursretningslinjen", "resourcePolicySetting": "{policyName} Innstillinger", "alwaysAllow": "Omgå Auth", "alwaysDeny": "Blokker tilgang", @@ -719,7 +728,7 @@ "targetSubmit": "Legg til mål", "targetNoOne": "Denne ressursen har ikke noen mål. Legg til et mål for å konfigurere hvor du vil sende forespørsler til backend.", "targetNoOneDescription": "Å legge til mer enn ett mål ovenfor vil aktivere lastbalansering.", - "targetsSubmit": "Lagre mål", + "targetsSubmit": "Lagre innstillinger", "addTarget": "Legg til mål", "proxyMultiSiteRoundRobinNodeHelp": "Rundkjøringrutefordeling vil ikke fungere mellom steder som ikke er koblet til samme node, men failover vil fungere.", "targetErrorInvalidIp": "Ugyldig IP-adresse", @@ -753,11 +762,11 @@ "rulesErrorDuplicate": "Duplisert regel", "rulesErrorDuplicateDescription": "En regel med disse innstillingene finnes allerede", "rulesErrorInvalidIpAddressRange": "Ugyldig CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Vennligst skriv inn en gyldig CIDR-verdi", - "rulesErrorInvalidUrl": "Ugyldig URL-sti", - "rulesErrorInvalidUrlDescription": "Skriv inn en gyldig verdi for URL-sti", - "rulesErrorInvalidIpAddress": "Ugyldig IP", - "rulesErrorInvalidIpAddressDescription": "Skriv inn en gyldig IP-adresse", + "rulesErrorInvalidIpAddressRangeDescription": "Skriv inn et gyldig CIDR-område (f.eks. 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Ugyldig sti", + "rulesErrorInvalidUrlDescription": "Skriv inn en gyldig URL-sti eller et mønster (f.eks., /api/*).", + "rulesErrorInvalidIpAddress": "Ugyldig IP-adresse", + "rulesErrorInvalidIpAddressDescription": "Skriv inn en gyldig IPv4 eller IPv6 adresse.", "rulesErrorUpdate": "Kunne ikke oppdatere regler", "rulesErrorUpdateDescription": "Det oppsto en feil under oppdatering av regler", "rulesUpdated": "Aktiver Regler", @@ -766,14 +775,23 @@ "rulesMatchIpAddress": "Angi en IP-adresse (f.eks. 103.21.244.12)", "rulesMatchUrl": "Skriv inn en URL-sti eller et mønster (f.eks. /api/v1/todos eller /api/v1/*)", "rulesErrorInvalidPriority": "Ugyldig prioritet", - "rulesErrorInvalidPriorityDescription": "Vennligst skriv inn en gyldig prioritet", - "rulesErrorDuplicatePriority": "Dupliserte prioriteringer", - "rulesErrorDuplicatePriorityDescription": "Vennligst angi unike prioriteringer", + "rulesErrorInvalidPriorityDescription": "Skriv inn et heltall på 1 eller høyere.", + "rulesErrorDuplicatePriority": "Dupliserte prioriteter", + "rulesErrorDuplicatePriorityDescription": "Hver regel må ha et unikt prioritetstall.", + "rulesErrorValidation": "Ugyldige regler", + "rulesErrorValidationRuleDescription": "Regel {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Velg en gyldig samsvarstype (sti, IP, CIDR, land, region eller ASN).", + "rulesErrorValueRequired": "Skriv inn en verdi for denne regelen.", + "rulesErrorInvalidCountry": "Ugyldig land", + "rulesErrorInvalidCountryDescription": "Velg et gyldig land.", + "rulesErrorInvalidAsn": "Ugyldig ASN", + "rulesErrorInvalidAsnDescription": "Skriv inn en gyldig ASN (f.eks., AS15169).", "ruleUpdated": "Regler oppdatert", "ruleUpdatedDescription": "Reglene er oppdatert", "ruleErrorUpdate": "Operasjon mislyktes", "ruleErrorUpdateDescription": "En feil oppsto under lagringsoperasjonen", "rulesPriority": "Prioritet", + "rulesReorderDragHandle": "Dra for å omorganisere regelprioriteringen", "rulesAction": "Handling", "rulesMatchType": "Trefftype", "value": "Verdi", @@ -792,7 +810,7 @@ "rulesResource": "Konfigurasjon av ressursregler", "rulesResourceDescription": "Konfigurer regler for å kontrollere tilgang til ressursen", "ruleSubmit": "Legg til regel", - "rulesNoOne": "Ingen regler. Legg til en regel ved å bruke skjemaet.", + "rulesNoOne": "Ingen regler ennå.", "rulesOrder": "Regler evalueres etter prioritet i stigende rekkefølge.", "rulesSubmit": "Lagre regler", "policyErrorCreate": "Feil ved opprettelse av policy", @@ -803,7 +821,48 @@ "policyErrorUpdateMessageDescription": "En uventet feil oppstod", "policyCreatedSuccess": "Ressurspolitikken ble opprettet vellykket", "policyUpdatedSuccess": "Ressurspolitikken ble oppdatert vellykket", - "authMethodsSave": "Lagre autentiseringsmetoder", + "authMethodsSave": "Lagre innstillinger", + "policyAuthStackTitle": "Autentisering", + "policyAuthStackDescription": "Kontroller hvilke autentiseringsmetoder som kreves for å få tilgang til denne ressursen", + "policyAuthOrLogicTitle": "Flere autentiseringsmetoder aktive", + "policyAuthOrLogicBanner": "Besøkende kan autentisere ved bruk av en hvilken som helst av de aktive metodene nedenfor. De trenger ikke å fullføre alle.", + "policyAuthMethodActive": "Aktiv", + "policyAuthMethodOff": "Av", + "policyAuthSsoTitle": "Plattform SSO", + "policyAuthSsoDescription": "Krev pålogging gjennom din organisasjons identitetsleverandør", + "policyAuthSsoSummary": "{idp} · {users} brukere, {roles} roller", + "policyAuthSsoDefaultIdp": "Standardleverandør", + "policyAuthAddDefaultIdentityProvider": "Legg til standard identitetsleverandør", + "policyAuthOtherMethodsTitle": "Andre metoder", + "policyAuthOtherMethodsDescription": "Valgfrie metoder som besøkende kan bruke i stedet for eller i tillegg til plattform SSO", + "policyAuthPasscodeTitle": "Kodeord", + "policyAuthPasscodeDescription": "Krev en delt alfanumerisk kodeord for å få tilgang til ressursen", + "policyAuthPasscodeSummary": "Kodeord satt", + "policyAuthPincodeTitle": "PIN-kode", + "policyAuthPincodeDescription": "En kort numerisk kode kreves for å få tilgang til ressursen", + "policyAuthPincodeSummary": "6-sifret PIN satt", + "policyAuthEmailTitle": "E-post hviteliste", + "policyAuthEmailDescription": "Tillat oppførte e-postadresser med engangspassord", + "policyAuthEmailSummary": "{count} adresser tillatt", + "policyAuthEmailOtpCallout": "Aktivering av e-post hviteliste sender en engangskode til den besøkendes e-post ved innlogging.", + "policyAuthHeaderAuthTitle": "Grunnleggende Header Autentisering", + "policyAuthHeaderAuthDescription": "Bekreft et tilpasset HTTP-headernavn og verdi ved hver forespørsel", + "policyAuthHeaderAuthSummary": "Header konfigurert", + "policyAuthHeaderName": "Headernavn", + "policyAuthHeaderValue": "Forventet verdi", + "policyAuthSetPasscode": "Angi passordkode", + "policyAuthSetPincode": "Sett PIN-kode", + "policyAuthSetEmailWhitelist": "Angi e-post hviteliste", + "policyAuthSetHeaderAuth": "Sett grunnleggende Header Autentisering", + "policyAccessRulesTitle": "Tilgangsregler", + "policyAccessRulesEnableDescription": "Når aktivert, blir regler evaluert i synkende rekkefølge til en evaluerer til sann.", + "policyAccessRulesFirstMatch": "Regler evalueres ovenfra og ned. Den første samsvarande regeln bestemmer utfall.", + "policyAccessRulesHowItWorks": "Regler samsvarer forespørsler etter sti, IP-adresse, lokasjon eller andre kriterier. Hver regel anvender en handling: omgå autentisering, blokkere tilgang, eller sende til autentisering. Hvis ingen regler samsvarer, fortsetter trafikken til autentisering.", + "policyAccessRulesFallthroughOff": "Når regler er deaktivert, går all trafikk gjennom til autentisering.", + "policyAccessRulesFallthroughOn": "Når ingen regler samsvarer, fortsetter trafikken til autentisering.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Lagre Regler", "resourceErrorCreate": "Feil under oppretting av ressurs", "resourceErrorCreateDescription": "Det oppstod en feil under oppretting av ressursen", @@ -824,9 +883,9 @@ "resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen", "access": "Tilgang", "accessControl": "Tilgangskontroll", - "shareLink": "{resource} Del Lenke", + "shareLink": "{resource} Delbar lenke", "resourceSelect": "Velg ressurs", - "shareLinks": "Del lenker", + "shareLinks": "Delbare lenker", "share": "Delbare lenker", "shareDescription2": "Opprett delbare lenker til ressurser. Lenker gir midlertidig eller ubegrenset tilgang til din ressurs. Du kan konfigurere utløpsvarigheten på lenken når du oppretter en.", "shareEasyCreate": "Enkelt å lage og dele", @@ -916,10 +975,18 @@ "resourceRoleDescription": "Administratorer har alltid tilgang til denne ressursen.", "resourcePolicySelectTitle": "Ressurstilgangspolitikk", "resourcePolicySelectDescription": "Velg policytype for autentisering", + "resourcePolicyTypeLabel": "Policy-type", + "resourcePolicyLabel": "Ressurspolicy", "resourcePolicyInline": "Inline Ressursregler", "resourcePolicyInlineDescription": "Tilgangspolitikk som kun er gyldig for denne ressursen", "resourcePolicyShared": "Delte Ressursregler", - "resourcePolicySharedDescription": "Denne ressursen bruker en delt policy. Policyinnstillinger (autentiseringsmetoder, e-post whitelist) er låst. Du kan legge til ressurs-spesifikke regler, roller, og brukere nedenfor.", + "resourcePolicySharedDescription": "Denne ressursen bruker en delt policy.", + "sharedPolicy": "Delt policy", + "sharedPolicyNoneDescription": "Denne ressursen har sin egen policy.", + "resourceSharedPolicyOwnDescription": "Denne ressursen har sine egne autentiserings- og tilgangskontroller.", + "resourceSharedPolicyInheritedDescription": "Denne ressursen arver fra {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Denne ressursen bruker en delt policy. Noen autentiseringsinnstillinger kan redigeres på denne ressursen for å legge til policyen. For å endre den underliggende policyen, må du redigere til {policyName}.", + "resourceSharedPolicyRulesNotice": "Denne ressursen bruker en delt policy. Noen tilgangsregler kan redigeres på denne ressursen. For å endre den underliggende policyen, må du redigere {policyName}.", "resourceUsersRoles": "Tilgangskontroller", "resourceUsersRolesDescription": "Konfigurer hvilke brukere og roller som har tilgang til denne ressursen", "resourceUsersRolesSubmit": "Lagre tilgangskontroller", @@ -944,7 +1011,14 @@ "resourceVisibilityTitle": "Synlighet", "resourceVisibilityTitleDescription": "Fullstendig aktiver eller deaktiver ressursynlighet", "resourceGeneral": "Generelle innstillinger", - "resourceGeneralDescription": "Konfigurer de generelle innstillingene for denne ressursen", + "resourceGeneralDescription": "Konfigurer navn, adresse og tilgangspolicy for denne ressursen.", + "resourceGeneralDetailsSubsection": "Ressursdetaljer", + "resourceGeneralDetailsSubsectionDescription": "Angi visningsnavn, identifikator og offentlig tilgjengelig domene for denne ressursen.", + "resourceGeneralDetailsSubsectionPortDescription": "Angi visningsnavn, identifikator og offentlig port for denne ressursen.", + "resourceGeneralPublicAddressSubsection": "Offentlig adresse", + "resourceGeneralPublicAddressSubsectionDescription": "Konfigurer hvordan brukere får tilgang til denne ressursen.", + "resourceGeneralAuthenticationAccessSubsection": "Autentisering og tilgang", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Velg om denne ressursen bruker sin egen policy eller arver fra en delt policy.", "resourceEnable": "Aktiver ressurs", "resourceTransfer": "Overfør Ressurs", "resourceTransferDescription": "Overfør denne ressursen til et annet område", @@ -1220,11 +1294,14 @@ "addLabels": "Legg til etiketter", "siteLabelsTab": "Etiketter", "siteLabelsDescription": "Administrer etiketter knyttet til dette nettstedet.", - "labelsNotFound": "Etiketter ikke funnet", + "labelsNotFound": "Ingen etiketter funnet.", + "labelsEmptyCreateHint": "Start å skrive ovenfor for å lage en etikett.", "labelSearch": "Søk etter etiketter", + "labelSearchOrCreate": "Søk eller opprett en etikett", "accessLabelFilterCount": "{count, plural, one {en etikett} other {# etiketter}}", "labelOverflowCount": "+{count, plural, one {en etikett} other {# etiketter}}", "accessLabelFilterClear": "Fjern etikettfiltre", + "accessFilterClear": "Fjern filtre", "selectColor": "Velg farge", "createNewLabel": "Opprett ny org-etikett \"{label}\"", "inviteInvalidDescription": "Invitasjonslenken er ugyldig.", @@ -1461,8 +1538,8 @@ "sidebarResources": "Ressurser", "sidebarProxyResources": "Offentlig", "sidebarClientResources": "Privat", - "sidebarPolicies": "Retningslinjer", - "sidebarResourcePolicies": "Ressurser", + "sidebarPolicies": "Delte policies", + "sidebarResourcePolicies": "Offentlige ressurser", "sidebarAccessControl": "Tilgangskontroll", "sidebarLogsAndAnalytics": "Logger og analyser", "sidebarTeam": "Lag", @@ -1470,7 +1547,7 @@ "sidebarAdmin": "Administrator", "sidebarInvitations": "Invitasjoner", "sidebarRoles": "Roller", - "sidebarShareableLinks": "Lenker", + "sidebarShareableLinks": "Delbare lenker", "sidebarApiKeys": "API-nøkler", "sidebarProvisioning": "Levering", "sidebarSettings": "Innstillinger", @@ -1647,7 +1724,7 @@ "standaloneHcFilterResourceIdFallback": "Ressurs {id}", "blueprints": "Tegninger", "blueprintsLog": "Blåkopieringslogg", - "blueprintsDescription": "Vis tidligere applikasjoner av blåkopier og deres resultater", + "blueprintsDescription": "Se tidligere blueprint-applikasjoner og deres resultater, eller bruk et nytt blueprint", "blueprintAdd": "Legg til blåkopi", "blueprintGoBack": "Se alle blåkopier", "blueprintCreate": "Opprette mal", @@ -1667,10 +1744,10 @@ "enableDockerSocket": "Aktiver Docker blåkopi", "enableDockerSocketDescription": "Aktiver Docker Socket etikett skrubbing for blueprint etiketter. Socket bane må oppgis til nettstedkobleren. Les om hvordan dette fungerer i dokumentasjonen.", "newtAutoUpdate": "Aktiver Automatisk Oppdatering av Nettsted", - "newtAutoUpdateDescription": "Når aktivert, vil nettstedskoblere automatisk oppdatere til nyeste versjon når en ny utgave er tilgjengelig.", + "newtAutoUpdateDescription": "Når aktivert, vil nettstedskoblinger automatisk laste ned den nyeste versjonen og starte seg selv på nytt. Dette kan overstyres på basis per nettsted.", "siteAutoUpdate": "Automatisk Oppdatering av Nettsted", "siteAutoUpdateLabel": "Aktiver Automatisk Oppdatering", - "siteAutoUpdateDescription": "Kontroller om denne sidens kobler automatisk laster ned den nyeste versjonen.", + "siteAutoUpdateDescription": "Når aktivert, vil denne nettstedets kobling automatisk laste ned den nyeste versjonen og starte seg selv på nytt.", "siteAutoUpdateOrgDefault": "Organisasjon standard: {state}", "siteAutoUpdateOverriding": "Overstyrer organisasjonens innstilling", "siteAutoUpdateResetToOrg": "Tilbakestill til Organisasjonsstandard", @@ -1768,9 +1845,9 @@ "accountSetupSuccess": "Kontooppsett fullført! Velkommen til Pangolin!", "documentation": "Dokumentasjon", "saveAllSettings": "Lagre alle innstillinger", - "saveResourceTargets": "Lagre mål", - "saveResourceHttp": "Lagre proxy-innstillinger", - "saveProxyProtocol": "Lagre proxy-protokollinnstillinger", + "saveResourceTargets": "Lagre innstillinger", + "saveResourceHttp": "Lagre innstillinger", + "saveProxyProtocol": "Lagre innstillinger", "settingsUpdated": "Innstillinger oppdatert", "settingsUpdatedDescription": "Innstillinger oppdatert vellykket", "settingsErrorUpdate": "Klarte ikke å oppdatere innstillinger", @@ -2027,13 +2104,13 @@ "healthCheckUnknown": "Ukjent", "healthCheck": "Helsekontroll", "configureHealthCheck": "Konfigurer Helsekontroll", - "configureHealthCheckDescription": "Sett opp helsekontroll for {target}", + "configureHealthCheckDescription": "Sett opp overvåking av ressursen din for å sikre at den alltid er tilgjengelig", "enableHealthChecks": "Aktiver Helsekontroller", "healthCheckDisabledStateDescription": "Når deaktivert, vil ikke nettstedet utføre helsekontroller, og tilstanden vil anses som ukjent.", "enableHealthChecksDescription": "Overvåk helsen til dette målet. Du kan overvåke et annet endepunkt enn målet hvis nødvendig.", "healthScheme": "Metode", "healthSelectScheme": "Velg metode", - "healthCheckPortInvalid": "Helsekontrollporten må være mellom 1 og 65535", + "healthCheckPortInvalid": "Porten må være mellom 1 og 65535", "healthCheckPath": "Sti", "healthHostname": "IP / Vert", "healthPort": "Port", @@ -2046,6 +2123,7 @@ "requireDeviceApproval": "Krev enhetsgodkjenning", "requireDeviceApprovalDescription": "Brukere med denne rollen trenger nye enheter godkjent av en admin før de kan koble seg og få tilgang til ressurser.", "sshSettings": "SSH Innstillinger", + "sshAccess": "SSH-tilgang", "rdpSettings": "RDP Innstillinger", "vncSettings": "VNC Innstillinger", "sshServer": "SSH-server", @@ -2072,8 +2150,13 @@ "sshDaemonDisclaimer": "Sørg for at målenheten din er riktig konfigurert for å kjøre autentiseringsdaemon før du fullfører denne oppsettet, eller klargjøring vil mislykkes.", "sshDaemonPort": "Daemon-port", "sshServerDestination": "Serverens Destinasjon", - "sshServerDestinationDescription": "Konfigurer destinasjonen og porten til SSH-serveren", + "sshServerDestinationDescription": "Konfigurer destinasjonen for SSH-serveren", "destination": "Destinasjon", + "destinationRequired": "Destinasjon er påkrevd.", + "domainRequired": "Domene er påkrevd.", + "proxyPortRequired": "Port er påkrevd.", + "invalidPathConfiguration": "Ugyldig sti-konfigurasjon.", + "invalidRewritePathConfiguration": "Ugyldig omskrivingssti-konfigurasjon.", "bgTargetMultiSiteDisclaimer": "Ved å velge flere nettsteder aktiveres robust ruting og feilaktig avbrudd for høy tilgjengelighet.", "roleAllowSsh": "Tillat SSH", "roleAllowSshAllow": "Tillat", @@ -2088,10 +2171,25 @@ "sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.", "sshSudo": "Tillat sudo", "sshSudoCommands": "Sudo kommandoer", - "sshSudoCommandsDescription": "Kommaseparert liste over kommandoer brukeren tillates å kjøre med sudo. Absolutte stier må brukes.", + "sshSudoCommandsDescription": "Liste over kommandoer brukeren har lov til å kjøre med sudo, separert med komma, mellomrom eller nye linjer. Absolutte stier må brukes.", "sshCreateHomeDir": "Opprett hjemmappe", "sshUnixGroups": "Unix grupper", - "sshUnixGroupsDescription": "Kommaseparerte Unix grupper for å legge brukeren til på mål-verten.", + "sshUnixGroupsDescription": "Unix-grupper å legge til brukeren i på målverten, separert med komma, mellomrom eller nye linjer.", + "roleTextFieldPlaceholder": "Skriv inn verdier, eller slipp en .txt eller .csv fil", + "roleTextImportTitle": "Importer fra fil", + "roleTextImportDescription": "Importerer {fileName} til {fieldLabel}.", + "roleTextImportSkipHeader": "Hopp over første rad (header)", + "roleTextImportOverride": "Erstatte eksisterende", + "roleTextImportAppend": "Legg til eksisterende", + "roleTextImportMode": "Importmodus", + "roleTextImportPreview": "Forhåndsvisning", + "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer å importere} one {ett element å importere} other {# elementer å importere}}", + "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", + "roleTextImportConfirm": "Import", + "roleTextImportInvalidFile": "Ustøttet filtype", + "roleTextImportInvalidFileDescription": "Bare .txt og .csv filer er støttet.", + "roleTextImportEmpty": "Ingen elementer funnet i filen", + "roleTextImportEmptyDescription": "Filen inneholder ingen importerbare elementer.", "retryAttempts": "Forsøk på nytt", "expectedResponseCodes": "Forventede svarkoder", "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.", @@ -2875,9 +2973,10 @@ "enableProxyProtocol": "Aktiver Proxy-protokoll", "proxyProtocolInfo": "Bevar klientens IP-adresser for TCP backends", "proxyProtocolVersion": "Proxy protokoll versjon", - "version1": " Versjon 1 (Anbefalt)", + "version1": "Versjon 1 (Anbefalt)", "version2": "Versjon 2", - "versionDescription": "Versjon 1 er tekstbasert og støttet. Versjon 2 er binært og mer effektivt, men mindre kompatibel.", + "version1Description": "Tekstbasert og bredt støttet. Sørg for at servertransport er lagt til dynamisk konfigurasjon.", + "version2Description": "Binært og mer effektivt, men mindre kompatibel. Sørg for at servertransport er lagt til dynamisk konfigurasjon.", "warning": "Advarsel", "proxyProtocolWarning": "backend-programmet må konfigureres til å akseptere forbindelser i Proxy Protokoll. Hvis backend ikke støtter Proxy Beskyttelse vil aktivering av dette ødelegge alle tilkoblinger så bare dette hvis du vet hva du gjør. Sørg for å konfigurere backend til å stole på Proxy Protokoll overskrifter fra Traefik.", "restarting": "Restarter...", @@ -3034,7 +3133,7 @@ "enterConfirmation": "Skriv inn bekreftelse", "blueprintViewDetails": "Detaljer", "defaultIdentityProvider": "Standard identitetsleverandør", - "defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.", + "defaultIdentityProviderDescription": "Brukeren vil automatisk bli videresendt til denne identitetsleverandøren for autentisering.", "editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger", "editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang", "editInternalResourceDialogAddRoles": "Legg til roller", @@ -3075,6 +3174,7 @@ "maintenanceModeType": "Vedlikeholdsmodus type", "showMaintenancePage": "Vis en vedlikeholdsside til besøkende", "enableMaintenanceMode": "Aktiver vedlikeholdsmodus", + "enableMaintenanceModeDescription": "Når aktivert, vil besøkende se en vedlikeholdsside i stedet for ressursen din.", "automatic": "Automatisk", "automaticModeDescription": "Vis vedlikeholdsside kun når alle serverens mål er nede eller usunne. Ressursen din fortsetter å fungere normalt så lenge minst ett mål er sunt.", "forced": "Tvunget", @@ -3082,6 +3182,8 @@ "warning:": "Advarsel:", "forcedeModeWarning": "All trafikk vil bli dirigeres til vedlikeholdssiden. Serverens ressurser vil ikke motta noen forespørsler.", "pageTitle": "Sidetittel", + "maintenancePageContentSubsection": "Sideinnhold", + "maintenancePageContentSubsectionDescription": "Tilpass innholdet som vises på vedlikeholdssiden", "pageTitleDescription": "Hovedoverskriften vist på vedlikeholdssiden", "maintenancePageMessage": "Vedlikeholdsbeskjed", "maintenancePageMessagePlaceholder": "Vi kommer snart tilbake! Vårt nettsted gjennomgår for øyeblikket planlagt vedlikehold.", @@ -3346,6 +3448,8 @@ "idpUnassociateQuestion": "Er du sikker på at du vil frakoble denne identitetsleverandøren fra denne organisasjonen?", "idpUnassociateDescription": "Alle brukere knyttet til denne identitetsleverandøren vil bli fjernet fra denne organisasjonen, men identitetsleverandøren vil fortsatt eksistere for andre tilknyttede organisasjoner.", "idpUnassociateConfirm": "Bekreft frakobling av identitetsleverandør", + "idpConfirmDeleteAndRemoveMeFromOrg": "SLETT OG FJERN MEG FRA ORGANISASJONEN", + "idpUnassociateAndRemoveMeFromOrg": "AVKOBLE OG FJERN MEG FRA ORGANISASJONEN", "idpUnassociateWarning": "Dette kan ikke angres for denne organisasjonen.", "idpUnassociatedDescription": "Identitetsleverandør er vellykket frakoblet fra denne organisasjonen", "idpUnassociateMenu": "Frakoble", @@ -3439,18 +3543,58 @@ "sshConnecting": "Kobler til…", "sshInitializing": "Initialiserer…", "sshSignInTitle": "Logg inn på SSH", - "sshSignInDescription": "Oppgi dine SSH-legitimasjoner", + "sshSignInDescription": "Skriv inn dine SSH-legitimasjon for å koble til", "sshPasswordTab": "Passord", "sshPrivateKeyTab": "Privat Nøkkel", "sshPrivateKeyField": "Privat Nøkkel", "sshPrivateKeyDisclaimer": "Din private nøkkel er ikke lagret eller synlig for Pangolin. Alternativt kan du bruke kortevaliderte sertifikater for sømløs autentisering ved å bruke din eksisterende Pangolin-identitet.", "sshLearnMore": "Lær mer", "sshPrivateKeyFile": "Privat Nøkkelfil", - "sshAuthenticate": "Autentiser", + "sshAuthenticate": "Koble til", "sshTerminate": "Avslutt", "sshPoweredBy": "Drevet av", "sshErrorNoTarget": "Ingen mål spesifisert", "sshErrorWebSocket": "WebSocket-tilkobling mislyktes", "sshErrorAuthFailed": "Autentisering mislyktes", - "sshErrorConnectionClosed": "Tilkobling avsluttet før autentisering ble fullført" + "sshErrorConnectionClosed": "Tilkobling avsluttet før autentisering ble fullført", + "sitePangolinSshDescription": "Tillat SSH-tilgang til ressurser på dette nettstedet. Dette kan endres senere.", + "browserGatewayNoResourceForDomain": "Ingen ressurser funnet for dette domenet", + "browserGatewayNoTarget": "Ingen mål", + "browserGatewayConnect": "Koble til", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Kunne ikke signere SSH-nøkkel for PAM-påloggingsautentisering. Logget du inn som bruker?", + "sshTerminalError": "Feil: {error}", + "sshConnectionClosedCode": "Tilkoblingen ble lukket (kode {code})", + "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----", + "sshPrivateKeyRequired": "Privat nøkkel er påkrevd", + "vncTitle": "VNC", + "vncSignInDescription": "Skriv inn VNC-passordet for å koble til", + "vncPasswordOptional": "Passord (valgfritt)", + "vncNoResourceTarget": "Ingen ressursemål tilgjengelig", + "vncFailedToLoadNovnc": "Klarte ikke å laste noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Lim inn utklippstavle", + "rdpTitle": "RDP", + "rdpSignInTitle": "Logg på Fjernskrivebord", + "rdpSignInDescription": "Skriv inn Windows-legitimasjon for å koble til", + "rdpLoadingModule": "Laster modul...", + "rdpFailedToLoadModule": "Kunne ikke laste RDP-modul", + "rdpNotReady": "Ikke klar", + "rdpModuleInitializing": "RDP-modulen er fortsatt under initialisering", + "rdpDownloadingFiles": "Laster ned {count} fil(er) fra fjern…", + "rdpDownloadFailed": "Nedlasting feilet: {fileName}", + "rdpUploaded": "Opplastet: {fileName}", + "rdpNoConnectionTarget": "Ingen tilkoblingsmål tilgjengelig", + "rdpConnectionFailed": "Tilkoblingen feilet", + "rdpFit": "Tilpass", + "rdpFull": "Full", + "rdpReal": "Ekte", + "rdpMeta": "Meta", + "rdpUploadFiles": "Last opp filer", + "rdpFilesReadyToPaste": "Filer klare til å limes inn", + "rdpFilesReadyToPasteDescription": "{count} fil(er) kopiert til fjernutklippstavlen — trykk Ctrl+V på fjernskrivebordet for å lime inn.", + "rdpUploadFailed": "Opplastningen mislyktes", + "rdpUnicodeKeyboardMode": "Unicode tastaturmodus", + "sessionToolbarShow": "Vis verktøylinje", + "sessionToolbarHide": "Skjul verktøylinje" } From 471ae982042d7711fcb57f56dea6d7a674e08438 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 12 Jun 2026 14:06:57 -0700 Subject: [PATCH 032/264] Pull roles from resource policies Fixes #3256 --- server/auth/canUserAccessResource.ts | 120 ++++++++++++++---- server/private/routers/ssh/signSshKey.ts | 147 ++++++++++++++++------- 2 files changed, 198 insertions(+), 69 deletions(-) diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index 2c8911490..e7d6307cf 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,6 +1,12 @@ import { db } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; -import { roleResources, userResources } from "@server/db"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { + rolePolicies, + roleResources, + resources, + userPolicies, + userResources +} from "@server/db"; export async function canUserAccessResource({ userId, @@ -11,9 +17,14 @@ export async function canUserAccessResource({ resourceId: number; roleIds: number[]; }): Promise { - const roleResourceAccess = + const [ + roleResourceAccess, + rolePolicyAccess, + userResourceAccess, + userPolicyAccess + ] = await Promise.all([ roleIds.length > 0 - ? await db + ? db .select() .from(roleResources) .where( @@ -23,26 +34,87 @@ export async function canUserAccessResource({ ) ) .limit(1) - : []; - - if (roleResourceAccess.length > 0) { - return true; - } - - const userResourceAccess = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.userId, userId), - eq(userResources.resourceId, resourceId) + : [], + roleIds.length > 0 + ? db + .select({ + roleId: rolePolicies.roleId, + resourcePolicyId: rolePolicies.resourcePolicyId + }) + .from(rolePolicies) + .innerJoin( + resources, + // Shared policy wins; only use default policy when no shared + // policy is assigned to the resource. + or( + eq( + resources.resourcePolicyId, + rolePolicies.resourcePolicyId + ), + and( + isNull(resources.resourcePolicyId), + eq( + resources.defaultResourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + ) + ) + .where( + and( + eq(resources.resourceId, resourceId), + inArray(rolePolicies.roleId, roleIds) + ) + ) + .limit(1) + : [], + db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resourceId) + ) ) - ) - .limit(1); + .limit(1), + db + .select({ + userId: userPolicies.userId, + resourcePolicyId: userPolicies.resourcePolicyId + }) + .from(userPolicies) + .innerJoin( + resources, + // Shared policy wins; only use default policy when no shared + // policy is assigned to the resource. + or( + eq( + resources.resourcePolicyId, + userPolicies.resourcePolicyId + ), + and( + isNull(resources.resourcePolicyId), + eq( + resources.defaultResourcePolicyId, + userPolicies.resourcePolicyId + ) + ) + ) + ) + .where( + and( + eq(resources.resourceId, resourceId), + eq(userPolicies.userId, userId) + ) + ) + .limit(1) + ]); - if (userResourceAccess.length > 0) { - return true; - } - - return false; + return ( + roleResourceAccess.length > 0 || + rolePolicyAccess.length > 0 || + userResourceAccess.length > 0 || + userPolicyAccess.length > 0 + ); } diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index bcf8beab7..fc2319d53 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -20,6 +20,7 @@ import { logsDb, newts, roles, + rolePolicies, roleResources, roleSiteResources, resources, @@ -40,7 +41,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { and, eq, inArray, or } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; @@ -435,50 +436,106 @@ export async function signSshKey( usernameToUse = userOrg.pamUsername; } - const roleRows = - type === "private" - ? await db - .select({ - sshSudoCommands: roles.sshSudoCommands, - sshUnixGroups: roles.sshUnixGroups, - sshCreateHomeDir: roles.sshCreateHomeDir, - sshSudoMode: roles.sshSudoMode - }) - .from(roles) - .innerJoin( - roleSiteResources, - eq(roleSiteResources.roleId, roles.roleId) - ) - .where( - and( - inArray(roles.roleId, roleIds), - eq( - roleSiteResources.siteResourceId, - (resource as SiteResource).siteResourceId - ) - ) - ) - : await db - .select({ - sshSudoCommands: roles.sshSudoCommands, - sshUnixGroups: roles.sshUnixGroups, - sshCreateHomeDir: roles.sshCreateHomeDir, - sshSudoMode: roles.sshSudoMode - }) - .from(roles) - .innerJoin( - roleResources, - eq(roleResources.roleId, roles.roleId) - ) - .where( - and( - inArray(roles.roleId, roleIds), - eq( - roleResources.resourceId, - (resource as Resource).resourceId - ) - ) - ); + type RoleSshMeta = { + roleId: number; + sshSudoCommands: string | null; + sshUnixGroups: string | null; + sshCreateHomeDir: boolean | null; + sshSudoMode: string | null; + }; + + let roleRows: RoleSshMeta[] = []; + + if (type === "private") { + roleRows = await db + .select({ + roleId: roles.roleId, + sshSudoCommands: roles.sshSudoCommands, + sshUnixGroups: roles.sshUnixGroups, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshSudoMode: roles.sshSudoMode + }) + .from(roles) + .innerJoin( + roleSiteResources, + eq(roleSiteResources.roleId, roles.roleId) + ) + .where( + and( + inArray(roles.roleId, roleIds), + eq( + roleSiteResources.siteResourceId, + (resource as SiteResource).siteResourceId + ) + ) + ); + } else { + const publicResourceId = (resource as Resource).resourceId; + const [directRoleRows, policyRoleRows] = await Promise.all([ + db + .select({ + roleId: roles.roleId, + sshSudoCommands: roles.sshSudoCommands, + sshUnixGroups: roles.sshUnixGroups, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshSudoMode: roles.sshSudoMode + }) + .from(roles) + .innerJoin( + roleResources, + eq(roleResources.roleId, roles.roleId) + ) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roleResources.resourceId, publicResourceId) + ) + ), + db + .select({ + roleId: roles.roleId, + sshSudoCommands: roles.sshSudoCommands, + sshUnixGroups: roles.sshUnixGroups, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshSudoMode: roles.sshSudoMode + }) + .from(roles) + .innerJoin( + rolePolicies, + eq(rolePolicies.roleId, roles.roleId) + ) + .innerJoin( + resources, + or( + eq( + resources.resourcePolicyId, + rolePolicies.resourcePolicyId + ), + and( + isNull(resources.resourcePolicyId), + eq( + resources.defaultResourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + ) + ) + .where( + and( + inArray(roles.roleId, roleIds), + eq(resources.resourceId, publicResourceId) + ) + ) + ]); + + const uniqueByRoleId = new Map(); + for (const row of [...directRoleRows, ...policyRoleRows]) { + if (!uniqueByRoleId.has(row.roleId)) { + uniqueByRoleId.set(row.roleId, row); + } + } + roleRows = Array.from(uniqueByRoleId.values()); + } const parsedSudoCommands: string[] = []; const parsedGroupsSet = new Set(); From 5a8a48f9bf8d0acf746465a9cabca8afd9724000 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 12 Jun 2026 14:22:17 -0700 Subject: [PATCH 033/264] Enforce the action inside of the function --- server/private/routers/external.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 881ba2277..3cecd2ebb 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -612,7 +612,7 @@ authenticated.post( verifyValidSubscription(tierMatrix.advancedPrivateResources), verifyOrgAccess, verifyLimits, - verifyUserHasAction(ActionsEnum.signSshKey), + // verifyUserHasAction(ActionsEnum.signSshKey), // this check happens inside of the function now // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata ssh.signSshKey ); diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index fc2319d53..99fedc944 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -20,6 +20,7 @@ import { logsDb, newts, roles, + roleActions, rolePolicies, roleResources, roleSiteResources, @@ -141,6 +142,27 @@ export async function signSshKey( ); } + const roleActionPermission = await db + .select({ roleId: roleActions.roleId }) + .from(roleActions) + .where( + and( + eq(roleActions.actionId, ActionsEnum.signSshKey), + inArray(roleActions.roleId, roleIds), + eq(roleActions.orgId, orgId) + ) + ) + .limit(1); + + if (roleActionPermission.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + const isLicensed = await isLicensedOrSubscribed( orgId, tierMatrix.advancedPrivateResources From 3fd5c98def8c86fd9b867d028a2f66aa8a66a3af Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 12 Jun 2026 14:44:45 -0700 Subject: [PATCH 034/264] Fix #3252 --- .../blueprints/applyNewtDockerBlueprint.ts | 5 ++- server/lib/blueprints/publicResources.ts | 40 ++++++++++++++++++- src/app/ssh/page.tsx | 10 +++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts index 93794461c..6b91b3462 100644 --- a/server/lib/blueprints/applyNewtDockerBlueprint.ts +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -71,7 +71,10 @@ export async function applyNewtDockerBlueprint( let skippedKeys: string[] = []; try { - const blueprint = processContainerLabels(containers); + // Some Newt clients can report null/undefined containers when Docker + // labels are unavailable. Treat that as an empty blueprint payload. + const safeContainers = Array.isArray(containers) ? containers : []; + const blueprint = processContainerLabels(safeContainers); logger.debug( `Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)` diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index b60970310..1bbe0a4f1 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -945,7 +945,45 @@ export async function updatePublicResources( } } else { // INLINE POLICY MODE: sync rules into policy-level table - const inlinePolicyId = resource!.defaultResourcePolicyId!; + let inlinePolicyId = resource!.defaultResourcePolicyId; + + // Targets-only updates skip the auth/policy update branch above, + // so pre-1.19 resources can still have no inline policy linked. + if (!inlinePolicyId) { + const [adminRole] = await trx + .select() + .from(roles) + .where( + and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)) + ) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found`); + } + + inlinePolicyId = await ensureInlinePolicy( + existingResource.defaultResourcePolicyId, + orgId, + resourceNiceId, + adminRole.roleId, + trx + ); + + [resource] = await trx + .update(resources) + .set({ + resourcePolicyId: null, + defaultResourcePolicyId: inlinePolicyId + }) + .where( + eq( + resources.resourceId, + existingResource.resourceId + ) + ) + .returning(); + } // Clear the old resource-level rules table await trx diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index aa1758f7e..ad65bd804 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -3,7 +3,7 @@ import { priv } from "@app/lib/api"; import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding"; -import { AxiosResponse } from "axios"; +import axios, { AxiosResponse } from "axios"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import SshClient from "./SshClient"; import crypto from "crypto"; @@ -152,8 +152,12 @@ export default async function SshPage() { await waitForRoundTripCompletion(messageIds, cookieHeader); } catch (err) { console.error("Error signing SSH key:", err); - const detail = err instanceof Error ? err.message : String(err); - error = `${t("sshErrorSignKeyFailed")}: ${detail}`; + if (axios.isAxiosError(err) && err.response?.status === 403) { + error = t("accessDeniedDescription"); + } else { + const detail = err instanceof Error ? err.message : String(err); + error = `${t("sshErrorSignKeyFailed")}: ${detail}`; + } } } From 39f40e516064eff4442c0955e59a596db95eb6a9 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 12 Jun 2026 15:01:09 -0700 Subject: [PATCH 035/264] Allow missing agent host --- server/private/routers/ssh/signSshKey.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 99fedc944..ae74a07a0 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -644,14 +644,6 @@ export async function signSshKey( messageIds.push(message.messageId); const agentHost = siteAgentHostMap.get(siteId); - if (!agentHost) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - `Unable to determine agent host for site ${siteId}` - ) - ); - } await sendToClient(newt.newtId, { type: `newt/pam/connection`, From cedccd8cdbc7bc0739f40c20c96b2cf94a86e1e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:23:46 +0000 Subject: [PATCH 036/264] Bump esbuild from 0.28.0 to 0.28.1 Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 396 ++++++++++++++++++++-------------------------- package.json | 4 +- 2 files changed, 176 insertions(+), 224 deletions(-) diff --git a/package-lock.json b/package-lock.json index 821e8a95e..d43ed4f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -142,7 +142,7 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", @@ -1248,9 +1248,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1265,9 +1265,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1282,9 +1282,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1299,9 +1299,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1316,9 +1316,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1333,9 +1333,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1350,9 +1350,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1367,9 +1367,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1384,9 +1384,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1401,9 +1401,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1418,9 +1418,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1435,9 +1435,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1452,9 +1452,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1469,9 +1469,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1486,9 +1486,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1503,9 +1503,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1520,9 +1520,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1537,9 +1537,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1554,9 +1554,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1571,9 +1571,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1588,9 +1588,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1605,9 +1605,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1622,9 +1622,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1639,9 +1639,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1656,9 +1656,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1673,9 +1673,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2076,9 +2076,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2095,9 +2092,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2114,9 +2108,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2133,9 +2124,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2152,9 +2140,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2187,9 +2172,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2222,9 +2204,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2247,9 +2226,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2272,9 +2248,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2297,9 +2270,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2322,9 +2292,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2369,9 +2336,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2664,9 +2628,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2683,9 +2644,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2941,9 +2899,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2960,9 +2915,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3200,9 +3152,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3219,9 +3168,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3582,9 +3528,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3605,9 +3548,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3628,9 +3568,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3651,9 +3588,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6873,9 +6807,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6892,9 +6823,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6911,9 +6839,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -6930,9 +6855,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7200,9 +7122,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7220,9 +7139,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7296,6 +7212,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -8448,9 +8430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8465,9 +8444,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8482,9 +8458,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8499,9 +8472,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8516,9 +8486,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8533,9 +8500,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -11261,9 +11225,9 @@ ] }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11274,32 +11238,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/esbuild-node-externals": { @@ -13767,9 +13731,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13791,9 +13752,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15001,9 +14959,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -15020,9 +14975,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index a0637aaa5..d30eccf88 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", @@ -179,7 +179,7 @@ "typescript-eslint": "8.60.0" }, "overrides": { - "esbuild": "0.28.0", + "esbuild": "0.28.1", "dompurify": "3.4.0", "postcss": "8.5.15" } From 0fb5ace9c7f31da1af615c2faa6a4b5f6f07cd6a Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 13 Jun 2026 14:08:03 -0700 Subject: [PATCH 037/264] Support the browser gateways on the remote nodes --- server/lib/traefik/TraefikConfigManager.ts | 9 ++++++++- server/lib/traefik/getTraefikConfig.ts | 2 +- server/private/lib/traefik/getTraefikConfig.ts | 12 ++++-------- server/private/routers/hybrid.ts | 4 +++- server/routers/traefik/traefikConfigProvider.ts | 7 ++++++- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 42baf41b5..5f5a539ca 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -511,6 +511,12 @@ export class TraefikConfigManager { let traefikConfig; try { const currentExitNode = await getCurrentExitNodeId(); + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + const browserGatewayUiUrl = `http://${maintenanceHost}:${maintenancePort}`; + // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); traefikConfig = await getTraefikConfig( // this is called by the local exit node to get its own config @@ -521,7 +527,8 @@ export class TraefikConfigManager { build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config - build != "oss" // generate browser gateway targets on cloud and enterprise + build != "oss", // generate maintenance pages on cloud and hybrid + browserGatewayUiUrl // generate browser gateway targets on cloud and hybrid ); const domains = new Set(); diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 48eb03638..c11a0c1e0 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -45,7 +45,7 @@ export async function getTraefikConfig( generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE - allowBrowserGatewayResources = true + browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index e81715d3b..4395bc259 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -85,7 +85,7 @@ export async function getTraefikConfig( generateLoginPageRouters = false, allowRawResources = true, allowMaintenancePage = true, - allowBrowserGatewayResources = true + browserGatewayUiUrl: string | null = null ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources @@ -317,7 +317,7 @@ export async function getTraefikConfig( BrowserGatewayResourceEntry >(); - if (allowBrowserGatewayResources) { + if (browserGatewayUiUrl) { for (const row of resourcesWithTargetsAndSites) { if (!["ssh", "vnc", "rdp"].includes(row.mode)) { continue; @@ -1027,7 +1027,7 @@ export async function getTraefikConfig( } } - if (allowBrowserGatewayResources) { + if (browserGatewayUiUrl) { // Generate Traefik config for browser gateway resources const browserGatewayPort = 39999; for (const [, bgResource] of browserGatewayResourcesMap.entries()) { @@ -1129,10 +1129,6 @@ export async function getTraefikConfig( const entrypointHttps = config.getRawConfig().traefik.https_entrypoint; - const maintenancePort = config.getRawConfig().server.next_port; - const maintenanceHost = - config.getRawConfig().server.internal_hostname; - if (!config_output.http.services) config_output.http.services = {}; if (!config_output.http.middlewares) @@ -1144,7 +1140,7 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${maintenanceHost}:${maintenancePort}` + url: browserGatewayUiUrl } ], passHostHeader: true diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index c6be3e7d1..c6245fc08 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -277,6 +277,8 @@ hybridRouter.get( ); } + const browserGatewayUiUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there + try { const traefikConfig = await getTraefikConfig( remoteExitNode.exitNodeId, @@ -285,7 +287,7 @@ hybridRouter.get( false, // Dont include login pages, true, // allow raw resources false, // dont generate maintenance page - false // dont generate browser gateway targets + browserGatewayUiUrl // generate browser gateway targets ); return response(res, { diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 5da8eba4b..adc60ff12 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -17,13 +17,18 @@ export async function traefikConfigProvider( // Get the current exit node name from config const currentExitNodeId = await getCurrentExitNodeId(); + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = config.getRawConfig().server.internal_hostname; + const browserGatewayUiUrl = `http://${maintenanceHost}:${maintenancePort}`; + const traefikConfig = await getTraefikConfig( currentExitNodeId, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source build != "oss", // generate the login pages on the cloud and and enterprise, config.getRawConfig().traefik.allow_raw_resources, - build != "oss" // generate browser gateway resources on cloud and enterprise + build != "oss", // generate maintenance page on cloud and enterprise + browserGatewayUiUrl ); if (traefikConfig?.http?.middlewares) { From c6ddd5c402ebb7cafeaa81aeba96f435edf02988 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 13 Jun 2026 14:14:34 -0700 Subject: [PATCH 038/264] Open up holepunch requirements --- server/routers/newt/handleNewtGetConfigMessage.ts | 2 +- server/routers/olm/handleOlmRegisterMessage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index d78fa6f71..ff5d83799 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -54,7 +54,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { // TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly) } - if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { + if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 12) { logger.warn( `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 3b0e1637a..9fe09736f 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -348,7 +348,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // this prevents us from accepting a register from an olm that has not hole punched yet. // the olm will pump the register so we can keep checking // TODO: I still think there is a better way to do this rather than locking it out here but ??? - if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { + if (now - (client.lastHolePunch || 0) > 12 && sitesCount > 0) { logger.warn( `[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`, { orgId: client.orgId, clientId: client.clientId } From 50da863bb78094f05f42e1841bbd751e844c54d1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 13 Jun 2026 21:45:52 -0700 Subject: [PATCH 039/264] Add maintence page support for remote nodes --- server/lib/traefik/TraefikConfigManager.ts | 2 +- server/private/lib/traefik/getTraefikConfig.ts | 14 +++++--------- server/private/routers/hybrid.ts | 6 +++--- server/routers/traefik/traefikConfigProvider.ts | 6 +++--- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 5f5a539ca..ea9d4907a 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -527,7 +527,7 @@ export class TraefikConfigManager { build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config - build != "oss", // generate maintenance pages on cloud and hybrid + build != "oss" ? browserGatewayUiUrl : null, // generate maintenance pages on cloud and hybrid browserGatewayUiUrl // generate browser gateway targets on cloud and hybrid ); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 4395bc259..c188178a3 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -84,7 +84,7 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, generateLoginPageRouters = false, allowRawResources = true, - allowMaintenancePage = true, + maintenancePageUiUrl: string | null = null, browserGatewayUiUrl: string | null = null ): Promise { // Get resources with their targets and sites in a single optimized query @@ -630,7 +630,7 @@ export async function getTraefikConfig( } } - if (showMaintenancePage && allowMaintenancePage) { + if (showMaintenancePage && maintenancePageUiUrl) { const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceRouterName = `${key}-maintenance-router`; const rewriteMiddlewareName = `${key}-maintenance-rewrite`; @@ -646,15 +646,11 @@ export async function getTraefikConfig( ? `*.${domainParts.slice(1).join(".")}` : fullDomain; - const maintenancePort = config.getRawConfig().server.next_port; - const maintenanceHost = - config.getRawConfig().server.internal_hostname; - config_output.http.services[maintenanceServiceName] = { loadBalancer: { servers: [ { - url: `http://${maintenanceHost}:${maintenancePort}` + url: maintenancePageUiUrl } ], passHostHeader: true @@ -1119,7 +1115,7 @@ export async function getTraefikConfig( } } - if (showBgMaintenancePage && allowMaintenancePage) { + if (showBgMaintenancePage && maintenancePageUiUrl) { const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; @@ -1140,7 +1136,7 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: browserGatewayUiUrl + url: maintenancePageUiUrl } ], passHostHeader: true diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index c6245fc08..8beea35f0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -277,7 +277,7 @@ hybridRouter.get( ); } - const browserGatewayUiUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there + const pangolinUIUrl = config.getRawConfig().app.dashboard_url; // points to the dashboard to serve from there try { const traefikConfig = await getTraefikConfig( @@ -286,8 +286,8 @@ hybridRouter.get( true, // But don't allow domain namespace resources false, // Dont include login pages, true, // allow raw resources - false, // dont generate maintenance page - browserGatewayUiUrl // generate browser gateway targets + pangolinUIUrl, // dont generate maintenance page + pangolinUIUrl // generate browser gateway targets ); return response(res, { diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index adc60ff12..04cb30530 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -19,7 +19,7 @@ export async function traefikConfigProvider( const maintenancePort = config.getRawConfig().server.next_port; const maintenanceHost = config.getRawConfig().server.internal_hostname; - const browserGatewayUiUrl = `http://${maintenanceHost}:${maintenancePort}`; + const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; const traefikConfig = await getTraefikConfig( currentExitNodeId, @@ -27,8 +27,8 @@ export async function traefikConfigProvider( build == "oss", // filter out the namespace domains in open source build != "oss", // generate the login pages on the cloud and and enterprise, config.getRawConfig().traefik.allow_raw_resources, - build != "oss", // generate maintenance page on cloud and enterprise - browserGatewayUiUrl + pangolinUIUrl, + pangolinUIUrl ); if (traefikConfig?.http?.middlewares) { From f39cbc9bf4976b66c567e750b4176da484f79749 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 11:03:14 -0700 Subject: [PATCH 040/264] Add same signature to oss --- server/lib/traefik/getTraefikConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index c11a0c1e0..c63b5b718 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -44,7 +44,7 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, - allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE + maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE ): Promise { // Get resources with their targets and sites in a single optimized query From 90eceb457a648ff889c28ac901f6165d848b57bf Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 11:10:05 -0700 Subject: [PATCH 041/264] Clean up url passing --- server/lib/traefik/TraefikConfigManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index ea9d4907a..cc7299ff7 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -515,7 +515,7 @@ export class TraefikConfigManager { const maintenancePort = config.getRawConfig().server.next_port; const maintenanceHost = config.getRawConfig().server.internal_hostname; - const browserGatewayUiUrl = `http://${maintenanceHost}:${maintenancePort}`; + const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`; // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); traefikConfig = await getTraefikConfig( @@ -527,8 +527,8 @@ export class TraefikConfigManager { build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config - build != "oss" ? browserGatewayUiUrl : null, // generate maintenance pages on cloud and hybrid - browserGatewayUiUrl // generate browser gateway targets on cloud and hybrid + pangolinUIUrl, // generate maintenance pages on cloud and hybrid + pangolinUIUrl // generate browser gateway targets on cloud and hybrid ); const domains = new Set(); From 4435a669a61287fa1dd9fd4ccb2f6e0727498ebb Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 11:35:27 -0700 Subject: [PATCH 042/264] Fill in missing ui urls from the passed params --- server/private/lib/traefik/getTraefikConfig.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index c188178a3..5df41444e 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1226,8 +1226,6 @@ export async function getTraefikConfig( // The primary type is used for the path rewrite (e.g. /rdp), mirroring // how the maintenance page rewrites everything to /maintenance-screen. const primaryType = typeMap.keys().next().value as string; - const internalHost = config.getRawConfig().server.internal_hostname; - const internalPort = config.getRawConfig().server.next_port; const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; const entrypoint = bgResource.ssl ? config.getRawConfig().traefik.https_entrypoint @@ -1248,7 +1246,7 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${internalHost}:${internalPort}` + url: browserGatewayUiUrl } ] } @@ -1304,10 +1302,6 @@ export async function getTraefikConfig( const siteResourceRouterName = `${srKey}-router`; const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`; - const maintenancePort = config.getRawConfig().server.next_port; - const maintenanceHost = - config.getRawConfig().server.internal_hostname; - if (!config_output.http.routers) { config_output.http.routers = {}; } @@ -1323,7 +1317,7 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${maintenanceHost}:${maintenancePort}` + url: maintenancePageUiUrl } ], passHostHeader: true From ea1badf4e071e8aa3b69250fcb1b5f2057e87768 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 12:04:02 -0700 Subject: [PATCH 043/264] Add middleware for rewriting host headers --- .../private/lib/traefik/getTraefikConfig.ts | 58 +++++++++++++++++-- src/app/maintenance-screen/page.tsx | 2 +- src/lib/getBrowserTargetForRequest.ts | 2 +- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5df41444e..39ae1d9a0 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -634,6 +634,7 @@ export async function getTraefikConfig( const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceRouterName = `${key}-maintenance-router`; const rewriteMiddlewareName = `${key}-maintenance-rewrite`; + const maintenanceHeadersMiddlewareName = `${key}-maintenance-headers`; const entrypointHttp = config.getRawConfig().traefik.http_entrypoint; @@ -669,12 +670,26 @@ export async function getTraefikConfig( } }; + config_output.http.middlewares[ + maintenanceHeadersMiddlewareName + ] = { + headers: { + customRequestHeaders: { + Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource + "p-host": fullDomain + } + } + }; + config_output.http.routers[maintenanceRouterName] = { entryPoints: [ resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, - middlewares: [rewriteMiddlewareName], + middlewares: [ + rewriteMiddlewareName, + maintenanceHeadersMiddlewareName + ], rule: rule, priority: 2000, ...(resource.ssl ? { tls } : {}) @@ -687,6 +702,7 @@ export async function getTraefikConfig( resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, + middlewares: [maintenanceHeadersMiddlewareName], rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `, priority: 2001, ...(resource.ssl ? { tls } : {}) @@ -1119,6 +1135,7 @@ export async function getTraefikConfig( const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; + const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`; const entrypointHttp = config.getRawConfig().traefik.http_entrypoint; @@ -1150,12 +1167,26 @@ export async function getTraefikConfig( } }; + config_output.http.middlewares![ + bgMaintenanceHeadersMiddlewareName + ] = { + headers: { + customRequestHeaders: { + Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource + "p-host": fullDomain + } + } + }; + config_output.http.routers![bgMaintenanceRouterName] = { entryPoints: [ bgResource.ssl ? entrypointHttps : entrypointHttp ], service: bgMaintenanceServiceName, - middlewares: [bgRewriteMiddlewareName], + middlewares: [ + bgRewriteMiddlewareName, + bgMaintenanceHeadersMiddlewareName + ], rule: hostRule, priority: 2000, ...(bgResource.ssl ? { tls } : {}) @@ -1168,6 +1199,7 @@ export async function getTraefikConfig( bgResource.ssl ? entrypointHttps : entrypointHttp ], service: bgMaintenanceServiceName, + middlewares: [bgMaintenanceHeadersMiddlewareName], rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 2001, ...(bgResource.ssl ? { tls } : {}) @@ -1227,6 +1259,7 @@ export async function getTraefikConfig( // how the maintenance page rewrites everything to /maintenance-screen. const primaryType = typeMap.keys().next().value as string; const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; + const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`; const entrypoint = bgResource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint; @@ -1242,6 +1275,15 @@ export async function getTraefikConfig( } }; + config_output.http.middlewares![uiHeadersMiddlewareName] = { + headers: { + customRequestHeaders: { + Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource + "p-host": fullDomain + } + } + }; + config_output.http.services![bgUiServiceName] = { loadBalancer: { servers: [ @@ -1257,7 +1299,11 @@ export async function getTraefikConfig( `bg-r${bgResource.resourceId}-assets-router` ] = { entryPoints: [entrypoint], - middlewares: routerMiddlewares, + middlewares: [ + ...routerMiddlewares, + uiRewriteMiddlewareName, + uiHeadersMiddlewareName + ], service: bgUiServiceName, rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 101, @@ -1269,7 +1315,11 @@ export async function getTraefikConfig( `bg-r${bgResource.resourceId}-ui-router` ] = { entryPoints: [entrypoint], - middlewares: [...routerMiddlewares, uiRewriteMiddlewareName], + middlewares: [ + ...routerMiddlewares, + uiRewriteMiddlewareName, + uiHeadersMiddlewareName + ], service: bgUiServiceName, rule: hostRule, priority: 100, diff --git a/src/app/maintenance-screen/page.tsx b/src/app/maintenance-screen/page.tsx index 7af974564..a8cd3efb4 100644 --- a/src/app/maintenance-screen/page.tsx +++ b/src/app/maintenance-screen/page.tsx @@ -28,7 +28,7 @@ export default async function MaintenanceScreen() { try { const headersList = await headers(); - const host = headersList.get("host") || ""; + const host = headersList.get("p-host") || headersList.get("host") || ""; const hostname = host.split(":")[0]; const res = await priv.get>( diff --git a/src/lib/getBrowserTargetForRequest.ts b/src/lib/getBrowserTargetForRequest.ts index 179e6e6f1..d603d3078 100644 --- a/src/lib/getBrowserTargetForRequest.ts +++ b/src/lib/getBrowserTargetForRequest.ts @@ -6,7 +6,7 @@ import { cache } from "react"; export const getBrowserTargetForRequest = cache(async () => { const headersList = await headers(); - const host = headersList.get("host") || ""; + const host = headersList.get("p-host") || headersList.get("host") || ""; const hostname = host.split(":")[0]; try { From 70bc4c0b30527ee2bf0c4adfaca296885149e9ac Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 14:30:16 -0700 Subject: [PATCH 044/264] Remove the path rewrite from the next route --- server/private/lib/traefik/getTraefikConfig.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 39ae1d9a0..d47352e04 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1294,16 +1294,14 @@ export async function getTraefikConfig( } }; - // Assets router at higher priority so /_next files load without rewrite + // Assets router at higher priority so /_next files load without rewrite. + // Do NOT apply the path-rewrite middleware here — static assets must + // keep their original path; only the host headers are needed. config_output.http.routers![ `bg-r${bgResource.resourceId}-assets-router` ] = { entryPoints: [entrypoint], - middlewares: [ - ...routerMiddlewares, - uiRewriteMiddlewareName, - uiHeadersMiddlewareName - ], + middlewares: [...routerMiddlewares, uiHeadersMiddlewareName], service: bgUiServiceName, rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 101, From a1196d3da60163089ea5dd394d0941fef9ee4e24 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 14:34:39 -0700 Subject: [PATCH 045/264] Remove supporter warning --- src/app/admin/license/page.tsx | 17 +++++--- src/app/layout.tsx | 38 ++++++++-------- src/components/AuthPageFooterNotices.tsx | 8 ++-- src/components/SupporterMessage.tsx | 55 ++++++++++++------------ 4 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index 331cc9caf..e3051664d 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -42,7 +42,14 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react"; +import { + ArrowRight, + Check, + ExternalLink, + Heart, + InfoIcon, + TicketCheck +} from "lucide-react"; import Link from "next/link"; import DismissableBanner from "@app/components/DismissableBanner"; import CopyTextBox from "@app/components/CopyTextBox"; @@ -50,7 +57,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { SitePriceCalculator } from "@app/components/SitePriceCalculator"; import { Checkbox } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useTranslations } from "next-intl"; const ENTERPRISE_DOCS_URL = @@ -82,7 +89,7 @@ export default function LicensePage() { const [isActivatingLicense, setIsActivatingLicense] = useState(false); const [isDeletingLicense, setIsDeletingLicense] = useState(false); const [isRecheckingLicense, setIsRecheckingLicense] = useState(false); - const { supporterStatus } = useSupporterStatusContext(); + // const { supporterStatus } = useSupporterStatusContext(); const t = useTranslations(); @@ -347,9 +354,7 @@ export default function LicensePage() { storageKey="license-banner-dismissed" version={1} title={t("licenseBannerTitle")} - titleIcon={ - - } + titleIcon={} description={t("licenseBannerDescription")} > >( - "supporter-key/visible" - ); - supporterData.visible = res.data.data.visible; - supporterData.tier = res.data.data.tier; + // const res = await priv.get>( + // "supporter-key/visible" + // ); + // supporterData.visible = res.data.data.visible; + // supporterData.tier = res.data.data.tier; let licenseStatus: GetLicenseStatusResponse; if (build === "enterprise") { @@ -127,20 +127,20 @@ export default async function RootLayout({ - - {/* Main content */} -
-
- - - {children} - + > */} + {/* Main content */} +
+
+ -
+ {children} + +
- +
+ {/* */} diff --git a/src/components/AuthPageFooterNotices.tsx b/src/components/AuthPageFooterNotices.tsx index af9125953..ce557e63d 100644 --- a/src/components/AuthPageFooterNotices.tsx +++ b/src/components/AuthPageFooterNotices.tsx @@ -1,24 +1,24 @@ "use client"; -import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; export default function AuthPageFooterNotices() { const t = useTranslations(); - const { supporterStatus } = useSupporterStatusContext(); + // const { supporterStatus } = useSupporterStatusContext(); const { isUnlocked, licenseStatus } = useLicenseStatusContext(); return ( <> - {supporterStatus?.visible && ( + {/* {supporterStatus?.visible && (
{t("noSupportKey")}
- )} + )} */} {build === "enterprise" && !isUnlocked() ? (
diff --git a/src/components/SupporterMessage.tsx b/src/components/SupporterMessage.tsx index 6e7aedbc9..def3c062b 100644 --- a/src/components/SupporterMessage.tsx +++ b/src/components/SupporterMessage.tsx @@ -9,33 +9,34 @@ export default function SupporterMessage({ tier }: { tier: string }) { const t = useTranslations(); return ( -
- { - // Get the bounding box of the element - const rect = ( - e.target as HTMLElement - ).getBoundingClientRect(); + <> + //
+ // { + // // Get the bounding box of the element + // const rect = ( + // e.target as HTMLElement + // ).getBoundingClientRect(); - // Trigger confetti centered on the word "Pangolin" - confetti({ - particleCount: 100, - spread: 70, - origin: { - x: (rect.left + rect.width / 2) / window.innerWidth, - y: rect.top / window.innerHeight - }, - colors: ["#FFA500", "#FF4500", "#FFD700"] - }); - }} - > - Pangolin - - -
- {t("componentsSupporterMessage", { tier: tier })} -
-
+ // // Trigger confetti centered on the word "Pangolin" + // confetti({ + // particleCount: 100, + // spread: 70, + // origin: { + // x: (rect.left + rect.width / 2) / window.innerWidth, + // y: rect.top / window.innerHeight + // }, + // colors: ["#FFA500", "#FF4500", "#FFD700"] + // }); + // }} + // > + // Pangolin + //
+ // + //
+ // {t("componentsSupporterMessage", { tier: tier })} + //
+ //
); } From a6568692b778bbc3b23526fd38ad7645bbd99b4f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 14 Jun 2026 14:40:37 -0700 Subject: [PATCH 046/264] force set supporter status to true in server info endpoint --- server/routers/serverInfo/getServerInfo.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/routers/serverInfo/getServerInfo.ts b/server/routers/serverInfo/getServerInfo.ts index fa71cedc4..d7b407dc8 100644 --- a/server/routers/serverInfo/getServerInfo.ts +++ b/server/routers/serverInfo/getServerInfo.ts @@ -3,7 +3,6 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; -import config from "@server/lib/config"; import { build } from "@server/build"; import { APP_VERSION } from "@server/lib/consts"; import license from "#dynamic/license/license"; @@ -22,9 +21,6 @@ export async function getServerInfo( next: NextFunction ): Promise { try { - const supporterData = config.getSupporterData(); - const supporterStatusValid = supporterData?.valid || false; - let enterpriseLicenseValid = false; let enterpriseLicenseType: string | null = null; @@ -41,7 +37,7 @@ export async function getServerInfo( return sendResponse(res, { data: { version: APP_VERSION, - supporterStatusValid, + supporterStatusValid: true, build, enterpriseLicenseValid, enterpriseLicenseType From a08c6d70fef9fe7ecc252f1c3674295ab182febb Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 14:43:56 -0700 Subject: [PATCH 047/264] Comment out --- src/components/SupporterStatus.tsx | 220 ++++++++++++++--------------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx index 45c91be8d..59ebd5011 100644 --- a/src/components/SupporterStatus.tsx +++ b/src/components/SupporterStatus.tsx @@ -3,7 +3,7 @@ // THIS IS DEPRECATED AND IS NO LONGER SHOWED TO THE USER WITH THE DISCONTINUATION // OF THE SUPPORTER PROGRAM. IT MAY BE REMOVED IN A FUTURE UPDATE. -import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useState, useTransition } from "react"; import { Tooltip, @@ -58,134 +58,134 @@ interface SupporterStatusProps { export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) { - const { supporterStatus, updateSupporterStatus } = - useSupporterStatusContext(); - const [supportOpen, setSupportOpen] = useState(false); - const [keyOpen, setKeyOpen] = useState(false); - const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false); + // const { supporterStatus, updateSupporterStatus } = + // useSupporterStatusContext(); + // const [supportOpen, setSupportOpen] = useState(false); + // const [keyOpen, setKeyOpen] = useState(false); + // const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const t = useTranslations(); + // const { env } = useEnvContext(); + // const api = createApiClient({ env }); + // const t = useTranslations(); - const formSchema = z.object({ - githubUsername: z.string().nonempty({ - error: "GitHub username is required" - }), - key: z.string().nonempty({ - error: "Supporter key is required" - }) - }); + // const formSchema = z.object({ + // githubUsername: z.string().nonempty({ + // error: "GitHub username is required" + // }), + // key: z.string().nonempty({ + // error: "Supporter key is required" + // }) + // }); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - githubUsername: "", - key: "" - } - }); + // const form = useForm({ + // resolver: zodResolver(formSchema), + // defaultValues: { + // githubUsername: "", + // key: "" + // } + // }); - async function hide() { - await api.post("/supporter-key/hide"); + // async function hide() { + // await api.post("/supporter-key/hide"); - updateSupporterStatus({ - visible: false - }); - } + // updateSupporterStatus({ + // visible: false + // }); + // } - async function onSubmit(values: z.infer) { - try { - const res = await api.post< - AxiosResponse - >("/supporter-key/validate", { - githubUsername: values.githubUsername, - key: values.key - }); + // async function onSubmit(values: z.infer) { + // try { + // const res = await api.post< + // AxiosResponse + // >("/supporter-key/validate", { + // githubUsername: values.githubUsername, + // key: values.key + // }); - const data = res.data.data; + // const data = res.data.data; - if (!data || !data.valid) { - toast({ - variant: "destructive", - title: t("supportKeyInvalid"), - description: t("supportKeyInvalidDescription") - }); - return; - } + // if (!data || !data.valid) { + // toast({ + // variant: "destructive", + // title: t("supportKeyInvalid"), + // description: t("supportKeyInvalidDescription") + // }); + // return; + // } - // Trigger the toast - toast({ - variant: "default", - title: t("supportKeyValid"), - description: t("supportKeyValidDescription") - }); + // // Trigger the toast + // toast({ + // variant: "default", + // title: t("supportKeyValid"), + // description: t("supportKeyValidDescription") + // }); - // Fireworks-style confetti - const duration = 5 * 1000; // 5 seconds - const animationEnd = Date.now() + duration; - const defaults = { - startVelocity: 30, - spread: 360, - ticks: 60, - zIndex: 0, - colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues - }; + // // Fireworks-style confetti + // const duration = 5 * 1000; // 5 seconds + // const animationEnd = Date.now() + duration; + // const defaults = { + // startVelocity: 30, + // spread: 360, + // ticks: 60, + // zIndex: 0, + // colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues + // }; - function randomInRange(min: number, max: number) { - return Math.random() * (max - min) + min; - } + // function randomInRange(min: number, max: number) { + // return Math.random() * (max - min) + min; + // } - const interval = setInterval(() => { - const timeLeft = animationEnd - Date.now(); + // const interval = setInterval(() => { + // const timeLeft = animationEnd - Date.now(); - if (timeLeft <= 0) { - clearInterval(interval); - return; - } + // if (timeLeft <= 0) { + // clearInterval(interval); + // return; + // } - const particleCount = 50 * (timeLeft / duration); + // const particleCount = 50 * (timeLeft / duration); - // Launch confetti from two random horizontal positions - confetti({ - ...defaults, - particleCount, - origin: { - x: randomInRange(0.1, 0.3), - y: Math.random() - 0.2 - } - }); - confetti({ - ...defaults, - particleCount, - origin: { - x: randomInRange(0.7, 0.9), - y: Math.random() - 0.2 - } - }); - }, 250); + // // Launch confetti from two random horizontal positions + // confetti({ + // ...defaults, + // particleCount, + // origin: { + // x: randomInRange(0.1, 0.3), + // y: Math.random() - 0.2 + // } + // }); + // confetti({ + // ...defaults, + // particleCount, + // origin: { + // x: randomInRange(0.7, 0.9), + // y: Math.random() - 0.2 + // } + // }); + // }, 250); - setPurchaseOptionsOpen(false); - setKeyOpen(false); + // setPurchaseOptionsOpen(false); + // setKeyOpen(false); - updateSupporterStatus({ - visible: false - }); - } catch (error) { - toast({ - variant: "destructive", - title: t("error"), - description: formatAxiosError( - error, - t("supportKeyErrorValidationDescription") - ) - }); - return; - } - } + // updateSupporterStatus({ + // visible: false + // }); + // } catch (error) { + // toast({ + // variant: "destructive", + // title: t("error"), + // description: formatAxiosError( + // error, + // t("supportKeyErrorValidationDescription") + // ) + // }); + // return; + // } + // } return ( <> - { setPurchaseOptionsOpen(val); @@ -469,7 +469,7 @@ export default function SupporterStatus({ {t("supportKeyBuy")} ) - ) : null} + ) : null} */} ); } From 95c3f74a339efaeaee7074bcf526ea037aee209b Mon Sep 17 00:00:00 2001 From: Ritwij Aryan Parmar Date: Wed, 17 Jun 2026 14:36:34 -0400 Subject: [PATCH 048/264] Fix inline policy fields in resource update response --- server/routers/resource/getResource.ts | 9 +-- .../resource/inlinePolicyFields.test.ts | 74 +++++++++++++++++++ server/routers/resource/inlinePolicyFields.ts | 19 +++++ server/routers/resource/updateResource.ts | 9 ++- 4 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 server/routers/resource/inlinePolicyFields.test.ts create mode 100644 server/routers/resource/inlinePolicyFields.ts diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 708351db1..e139ee58f 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -9,6 +9,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { applyInlinePolicyFields } from "./inlinePolicyFields"; const getResourceSchema = z.strictObject({ resourceId: z @@ -151,13 +152,7 @@ export async function getResource( const policy = await queryInlinePolicy( resource.defaultResourcePolicyId! ); - returnData = { - ...returnData, - sso: policy?.sso || null, - emailWhitelistEnabled: policy?.emailWhitelistEnabled || null, - applyRules: policy?.applyRules || null, - skipToIdpId: policy?.idpId || null - }; + returnData = applyInlinePolicyFields(returnData, policy); } return response(res, { diff --git a/server/routers/resource/inlinePolicyFields.test.ts b/server/routers/resource/inlinePolicyFields.test.ts new file mode 100644 index 000000000..3330e6073 --- /dev/null +++ b/server/routers/resource/inlinePolicyFields.test.ts @@ -0,0 +1,74 @@ +import { assertEquals } from "../../../test/assert"; +import { applyInlinePolicyFields } from "./inlinePolicyFields"; + +function runTests() { + const resource = { + resourceId: 1, + name: "dashboard", + sso: null, + emailWhitelistEnabled: null, + applyRules: null, + skipToIdpId: null + } as any; + + const enabledPolicy = { + sso: true, + emailWhitelistEnabled: true, + applyRules: true, + idpId: 42 + }; + + const enabledResult = applyInlinePolicyFields(resource, enabledPolicy); + assertEquals(enabledResult.sso, true, "sso should mirror policy true"); + assertEquals( + enabledResult.emailWhitelistEnabled, + true, + "email whitelist should mirror policy true" + ); + assertEquals( + enabledResult.applyRules, + true, + "applyRules should mirror policy true" + ); + assertEquals( + enabledResult.skipToIdpId, + 42, + "skipToIdpId should use policy idpId" + ); + + const disabledPolicy = { + sso: false, + emailWhitelistEnabled: false, + applyRules: false, + idpId: null + }; + + const disabledResult = applyInlinePolicyFields(resource, disabledPolicy); + assertEquals(disabledResult.sso, false, "sso false must not become null"); + assertEquals( + disabledResult.emailWhitelistEnabled, + false, + "email whitelist false must not become null" + ); + assertEquals( + disabledResult.applyRules, + false, + "applyRules false must not become null" + ); + assertEquals( + disabledResult.skipToIdpId, + null, + "missing idp should stay null" + ); + + const missingPolicyResult = applyInlinePolicyFields(resource, null); + assertEquals( + missingPolicyResult.sso, + null, + "missing policy should return nullable resource fields" + ); + + console.log("PASS: inline policy fields mirror policy values"); +} + +runTests(); diff --git a/server/routers/resource/inlinePolicyFields.ts b/server/routers/resource/inlinePolicyFields.ts new file mode 100644 index 000000000..9c006beda --- /dev/null +++ b/server/routers/resource/inlinePolicyFields.ts @@ -0,0 +1,19 @@ +import type { Resource, ResourcePolicy } from "@server/db"; + +type InlinePolicyFields = Pick< + ResourcePolicy, + "sso" | "emailWhitelistEnabled" | "applyRules" | "idpId" +>; + +export function applyInlinePolicyFields( + resource: T, + policy: InlinePolicyFields | null | undefined +): T { + return { + ...resource, + sso: policy?.sso ?? null, + emailWhitelistEnabled: policy?.emailWhitelistEnabled ?? null, + applyRules: policy?.applyRules ?? null, + skipToIdpId: policy?.idpId ?? null + }; +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 77603fe9f..35995d517 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -47,6 +47,7 @@ import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { applyInlinePolicyFields } from "./inlinePolicyFields"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -682,6 +683,12 @@ async function updateHttpResource( .where(eq(resourcePolicies.resourcePolicyId, policyId)); } + const [inlinePolicy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, policyId)) + .limit(1); + const updatedResource = await db .update(resources) .set({ ...resourceOnlyData, headers }) @@ -698,7 +705,7 @@ async function updateHttpResource( } return response(res, { - data: updatedResource[0], + data: applyInlinePolicyFields(updatedResource[0], inlinePolicy), success: true, error: false, message: "HTTP resource updated successfully", From 1c600413905de61b91d829ece2b0fc432cafdbe2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:46:17 +0000 Subject: [PATCH 049/264] Bump nodemailer from 8.0.9 to 9.0.1 Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.9 to 9.0.1. - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.9...v9.0.1) --- updated-dependencies: - dependency-name: nodemailer dependency-version: 9.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 74 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 821e8a95e..81900fd7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.9", + "nodemailer": "9.0.1", "oslo": "1.2.1", "pg": "8.21.0", "posthog-node": "5.35.6", @@ -7296,6 +7296,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -14574,9 +14640,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz", - "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index a0637aaa5..52d4e9292 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.9", + "nodemailer": "9.0.1", "oslo": "1.2.1", "pg": "8.21.0", "posthog-node": "5.35.6", From a24091257ac22e7f32962fb225fc317bf9579e05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:40:11 +0000 Subject: [PATCH 050/264] Bump form-data from 4.0.5 to 4.0.6 Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6. - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 82 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 821e8a95e..bda4403a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7296,6 +7296,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -12191,16 +12257,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -12629,9 +12695,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" From d747b45f0bd9e0b9d144e6438bf4b610c7e11f90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:33:16 +0000 Subject: [PATCH 051/264] Bump actions/checkout from 6.0.2 to 7.0.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 8 ++++---- .github/workflows/linting.yml | 2 +- .github/workflows/test.yml | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 2b8b5a5e2..09d2986e9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Monitor storage space run: | @@ -134,7 +134,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Monitor storage space run: | @@ -201,7 +201,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Log in to Docker Hub uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -256,7 +256,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Extract tag name id: get-tag diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index ba60e6337..09b5270b0 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 970cd0dc9..3f64d913f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -62,7 +62,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Build Docker image sqlite run: make dev-build-sqlite @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Build Docker image pg run: make dev-build-pg From 753358a17da2b69467e86829dbca8ecb3b40a9d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:32:24 +0000 Subject: [PATCH 052/264] Bump golang.org/x/term in /install in the go-install-dependencies group Bumps the go-install-dependencies group in /install with 1 update: [golang.org/x/term](https://github.com/golang/term). Updates `golang.org/x/term` from 0.43.0 to 0.44.0 - [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go-install-dependencies ... Signed-off-by: dependabot[bot] --- install/go.mod | 4 ++-- install/go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/install/go.mod b/install/go.mod index 7f50d4e9e..69dc9e5fc 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/term v0.43.0 + golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -33,6 +33,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.23.0 // indirect ) diff --git a/install/go.sum b/install/go.sum index 7ed97929f..704142a64 100644 --- a/install/go.sum +++ b/install/go.sum @@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= From 34a0d2a68bc6fec27e8a2a5d21b7e68c4112e9df Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 15:02:18 -0700 Subject: [PATCH 053/264] Remove NoNewPrivileges Fixes https://github.com/fosrl/newt/issues/383 --- src/components/newt-install-commands.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index badc174cc..b720a8478 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -139,7 +139,6 @@ Restart=always RestartSec=2 UMask=0077 -NoNewPrivileges=true PrivateTmp=true [Install] From bf604f25e9c5c774cd172bcfc2499a6cf6ac4f23 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Jun 2026 13:02:38 -0400 Subject: [PATCH 054/264] Show the input validation in the error report --- server/lib/blueprints/applyBlueprint.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index f2bb9b0c8..ab095646e 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -48,18 +48,18 @@ export async function applyBlueprint({ name, source = "API" }: ApplyBlueprintArgs): Promise { - // Validate the input data - const validationResult = ConfigSchema.safeParse(configData); - if (!validationResult.success) { - throw new Error(fromError(validationResult.error).toString()); - } - - const config: Config = validationResult.data; let blueprintSucceeded: boolean = false; - let blueprintMessage: string; + let blueprintMessage = ""; let error: any | null = null; try { + const validationResult = ConfigSchema.safeParse(configData); + if (!validationResult.success) { + throw new Error(fromError(validationResult.error).toString()); + } + + const config: Config = validationResult.data; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { From 476d92b3ac1d5a80f272669ec845f7b9e18bdb13 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 16:01:46 -0400 Subject: [PATCH 055/264] Convert things to regional cache --- server/lib/billing/usageService.ts | 8 +++++--- server/private/lib/certificates.ts | 2 +- .../routers/remoteExitNode/listRemoteExitNodes.ts | 2 +- server/routers/newt/getNewtVersion.ts | 2 +- server/routers/newt/handleSocketMessages.ts | 2 +- server/routers/olm/handleOlmRegisterMessage.ts | 2 +- server/routers/resource/listUserResourceAliases.ts | 13 ++++++++----- server/routers/site/listSites.ts | 2 +- 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 9cb24bbeb..dd32a09ad 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -12,7 +12,7 @@ import { import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; export function noop() { if (build !== "saas") { @@ -22,7 +22,6 @@ export function noop() { } export class UsageService { - constructor() { if (noop()) { return; @@ -57,7 +56,10 @@ export class UsageService { try { let usage; if (transaction) { - const orgIdToUse = await this.getBillingOrg(orgId, transaction); + const orgIdToUse = await this.getBillingOrg( + orgId, + transaction + ); usage = await this.internalAddUsage( orgIdToUse, featureId, diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 31e40ed55..03ea6a58c 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -17,7 +17,7 @@ import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import { build } from "@server/build"; // Define the return type for clarity and type safety diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index 061be1792..872e62b2d 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -22,7 +22,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import semver from "semver"; let stalePangolinNodeVersion: string | null = null; diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 8a76bc3d2..b36ec8c16 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import config from "@server/lib/config"; // Stale-while-revalidate in-memory fallback for the releases API. diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 5d5497ee1..634dbb6a9 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; -import cache from "#dynamic/lib/cache"; +import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 9fe09736f..6bfc02aee 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -20,7 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; -import cache from "#dynamic/lib/cache"; +import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index d6e02b522..205c029f0 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -15,8 +15,7 @@ import logger from "@server/logger"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { OpenAPITags, registry } from "@server/openApi"; -import { localCache } from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; @@ -153,7 +152,7 @@ export async function listUserResourceAliases( pageSize ); const cachedData: ListUserResourceAliasesResponse | undefined = - localCache.get(cacheKey); + await cache.get(cacheKey); if (cachedData) { return response(res, { @@ -211,7 +210,11 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set( + cacheKey, + data, + USER_RESOURCE_ALIASES_CACHE_TTL_SEC + ); return response(res, { data, success: true, @@ -256,7 +259,7 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); return response(res, { data, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 86699feaf..6bb095030 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -14,7 +14,7 @@ import { siteLabels, type Label } from "@server/db"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; From a2882857ff55d5a536a87371827a9e5b06413cc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:39:56 +0000 Subject: [PATCH 056/264] Initial plan From 3f37408daedebcf872e3d65d6c24e742ae3a9613 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:44:35 +0000 Subject: [PATCH 057/264] fix: allow ALL ASN values in policy rule validation --- server/lib/validators.test.ts | 27 ++++++++++++++++++- server/lib/validators.ts | 4 ++- .../policy-access-rule-validation.ts | 16 ++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 00b6c75db..99208a5d5 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -1,4 +1,7 @@ -import { isValidUrlGlobPattern } from "./validators"; +import { + getResourceRuleValueValidationError, + isValidUrlGlobPattern +} from "./validators"; import { assertEquals } from "@test/assert"; function runTests() { @@ -236,6 +239,28 @@ function runTests() { "Path with isolated percent sign should be invalid" ); + // ASN validation tests + assertEquals( + getResourceRuleValueValidationError("ASN", "AS15169"), + null, + "Standard ASN should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("ASN", "ALL"), + null, + "ALL ASN selector should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("ASN", "AS0"), + null, + "AS0 alias should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("ASN", "not-an-asn"), + "Invalid ASN provided", + "Invalid ASN should return an error" + ); + console.log("All tests passed!"); } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index c179d3c91..bdc072667 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -100,7 +100,9 @@ export function getResourceRuleValueValidationError( ? null : "Invalid country code provided"; case "ASN": - return /^AS\d+$/i.test(value.trim()) + return /^AS\d+$/i.test(value.trim()) || + value.trim().toUpperCase() === "ALL" || + value.trim().toUpperCase() === "AS0" ? null : "Invalid ASN provided"; default: diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts index 387d2003b..2b3ddfb8f 100644 --- a/src/components/resource-policy/policy-access-rule-validation.ts +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { { message: t("rulesErrorInvalidCountryDescription") } ); case "ASN": - return required.refine((value) => /^AS\d+$/i.test(value.trim()), { - message: t("rulesErrorInvalidAsnDescription") - }); + return required.refine( + (value) => { + const normalizedValue = value.trim().toUpperCase(); + return ( + /^AS\d+$/i.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" + ); + }, + { + message: t("rulesErrorInvalidAsnDescription") + } + ); default: return required; } From de48a0529eb8f7e42e2c2eb107949136602a027f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:46:44 +0000 Subject: [PATCH 058/264] refactor: normalize ASN validation value once --- server/lib/validators.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index bdc072667..5251aae25 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -100,9 +100,10 @@ export function getResourceRuleValueValidationError( ? null : "Invalid country code provided"; case "ASN": - return /^AS\d+$/i.test(value.trim()) || - value.trim().toUpperCase() === "ALL" || - value.trim().toUpperCase() === "AS0" + const normalizedValue = value.trim().toUpperCase(); + return /^AS\d+$/i.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" ? null : "Invalid ASN provided"; default: From e5e7b7971221d46b1a74588ca447fe015f2b212a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:48:28 +0000 Subject: [PATCH 059/264] test: add normalized ASN validation coverage --- server/lib/validators.test.ts | 15 +++++++++++++++ server/lib/validators.ts | 2 +- .../policy-access-rule-validation.ts | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 99208a5d5..ac95184a5 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -245,16 +245,31 @@ function runTests() { null, "Standard ASN should be valid" ); + assertEquals( + getResourceRuleValueValidationError("ASN", " As15169 "), + null, + "Standard ASN should be valid with mixed case and whitespace" + ); assertEquals( getResourceRuleValueValidationError("ASN", "ALL"), null, "ALL ASN selector should be valid" ); + assertEquals( + getResourceRuleValueValidationError("ASN", " all "), + null, + "ALL ASN selector should be valid with mixed case and whitespace" + ); assertEquals( getResourceRuleValueValidationError("ASN", "AS0"), null, "AS0 alias should be valid" ); + assertEquals( + getResourceRuleValueValidationError("ASN", " as0 "), + null, + "AS0 alias should be valid with mixed case and whitespace" + ); assertEquals( getResourceRuleValueValidationError("ASN", "not-an-asn"), "Invalid ASN provided", diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 5251aae25..ec19bd852 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -101,7 +101,7 @@ export function getResourceRuleValueValidationError( : "Invalid country code provided"; case "ASN": const normalizedValue = value.trim().toUpperCase(); - return /^AS\d+$/i.test(normalizedValue) || + return /^AS\d+$/.test(normalizedValue) || normalizedValue === "ALL" || normalizedValue === "AS0" ? null diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts index 2b3ddfb8f..a5c9d32e0 100644 --- a/src/components/resource-policy/policy-access-rule-validation.ts +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -87,7 +87,7 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { (value) => { const normalizedValue = value.trim().toUpperCase(); return ( - /^AS\d+$/i.test(normalizedValue) || + /^AS\d+$/.test(normalizedValue) || normalizedValue === "ALL" || normalizedValue === "AS0" ); From a55fb21e53c7a9adca9dbd580d41460f2efd2fa5 Mon Sep 17 00:00:00 2001 From: Josh Voyles <120749218+Josh-Voyles@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:53:07 -0400 Subject: [PATCH 060/264] fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120) A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were added to reduce event-loop blocking on TraefikConfigManager JOINs but the memory cost outweighs the I/O benefit on the deployment shapes that hit #2120. Leave SQLite on its conservative defaults. Co-Authored-By: Claude Opus 4.7 --- server/db/sqlite/driver.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0e50d1289..644a160aa 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -60,13 +60,9 @@ function createDb() { // retry loops that accumulate memory. sqlite.pragma("busy_timeout = 5000"); - // 64 MB page cache (default 2 MB) — reduces I/O round-trips on large - // TraefikConfigManager JOINs that block the event loop. - sqlite.pragma("cache_size = -65536"); - - // 256 MB memory-mapped I/O — OS serves reads from page cache directly, - // reducing event-loop blocking. - sqlite.pragma("mmap_size = 268435456"); + // Intentionally NOT setting cache_size or mmap_size: a large page cache plus + // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing + // on small (~1 GB) instances. Leave SQLite on its conservative defaults. // Wrap prepare() so every drizzle-orm statement is auto-finalized after // first use, preventing sqlite3_stmt accumulation between GC cycles. From b7081aff119fd2c812d7b8e07f4be58965bf3f85 Mon Sep 17 00:00:00 2001 From: Josh Voyles <120749218+Josh-Voyles@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:12:00 -0400 Subject: [PATCH 061/264] fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and swallowed a TypeError on every query (verified: 'Statement.finalize exists: undefined' in the runner image) while adding +122% per-statement overhead (3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing. Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh per query, so nothing accumulates unbounded. busy_timeout=5000 duplicates better-sqlite3's default timeout option, which already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js). With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is sticky in the DB file, so removing it would strand opted-in databases on WAL+synchronous=FULL. Co-Authored-By: Claude Fable 5 --- server/db/sqlite/driver.ts | 52 +++++++------------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 644a160aa..a7eee52b7 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -1,6 +1,5 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; -import type BetterSqlite3 from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; @@ -12,64 +11,31 @@ export const exists = checkFileExists(location); bootstrapVolume(); -/** - * Wraps better-sqlite3 Statement to call `finalize()` immediately after - * execution, freeing native sqlite3_stmt memory deterministically instead - * of waiting for GC. Fixes steady off-heap growth under load (#2120). - * WARNING: Finalizes after first execution — incompatible with drizzle's - * reusable .prepare() builders. No such usage exists in this codebase. - */ -function autoFinalizeStatement( - stmt: BetterSqlite3.Statement -): BetterSqlite3.Statement { - const wrapExec = any>(fn: T): T => { - return function (this: any, ...args: any[]) { - try { - return fn.apply(this, args); - } finally { - try { - // finalize() exists on the native Statement at runtime but - // is missing from @types/better-sqlite3. - (stmt as any).finalize(); - } catch { - // Already finalized — harmless - } - } - } as unknown as T; - }; - - stmt.run = wrapExec(stmt.run); - stmt.get = wrapExec(stmt.get); - stmt.all = wrapExec(stmt.all); - - return stmt; -} - function createDb() { const sqlite = new Database(location); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { // Enable WAL mode — allows concurrent readers + single writer, preventing // contention across subsystems (verifySession, Traefik, audit, ping). + // NOTE: journal_mode persists in the DB file once set; unsetting this + // env var does NOT revert an existing WAL database. sqlite.pragma("journal_mode = WAL"); // NORMAL sync mode: safe with WAL, reduces write lock hold time. sqlite.pragma("synchronous = NORMAL"); } - // Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log - // retry loops that accumulate memory. - sqlite.pragma("busy_timeout = 5000"); + // No busy_timeout pragma: better-sqlite3 already arms + // sqlite3_busy_timeout(db, 5000) via its default `timeout` option + // (lib/database.js), so an explicit pragma is redundant. // Intentionally NOT setting cache_size or mmap_size: a large page cache plus // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing // on small (~1 GB) instances. Leave SQLite on its conservative defaults. - // Wrap prepare() so every drizzle-orm statement is auto-finalized after - // first use, preventing sqlite3_stmt accumulation between GC cycles. - const originalPrepare = sqlite.prepare.bind(sqlite); - (sqlite as any).prepare = function autoFinalizePrepare(source: string) { - return autoFinalizeStatement(originalPrepare(source)); - }; + // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes + // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a + // fresh statement per query (no statement cache), so statements cannot + // accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all. return DrizzleSqlite(sqlite, { schema From d240201361811eb7921eb6ec0f589e6237ea1b7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:39:56 +0000 Subject: [PATCH 062/264] Initial plan From 16abe98fd98224329b07c9e72aed1925825bf972 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Jun 2026 12:25:57 -0700 Subject: [PATCH 063/264] Add queue --- server/index.ts | 2 + server/lib/rebuildClientAssociations.ts | 104 +++++++++++++-- server/lib/rebuildQueue.ts | 23 ++++ server/private/lib/rebuildQueue.ts | 169 ++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 server/lib/rebuildQueue.ts create mode 100644 server/private/lib/rebuildQueue.ts diff --git a/server/index.ts b/server/index.ts index 99fd20156..53b3e9a69 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,7 @@ import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; +import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; async function startServers() { await setHostMeta(); @@ -41,6 +42,7 @@ async function startServers() { initLogCleanupInterval(); initAcmeCertSync(); + startRebuildQueueProcessor(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 4efc72476..8b601ae71 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -8,6 +8,7 @@ import { exitNodes, newts, olms, + primaryDb, roleSiteResources, Site, SiteResource, @@ -40,6 +41,7 @@ import { removeTargets as removeSubnetProxyTargets } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; +import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; // TTL for rebuild-association locks. These functions can fan out into many // peer/proxy updates, so give them a generous window. @@ -167,11 +169,32 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { - return await lockManager.withLock( - `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + try { + return await lockManager.withLock( + `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, + () => + rebuildClientAssociationsFromSiteResourceImpl( + siteResource, + trx + ), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for site resource ${siteResource.siteResourceId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + return { mergedAllClients: [] }; + } + throw err; + } } async function rebuildClientAssociationsFromSiteResourceImpl( @@ -956,11 +979,28 @@ export async function rebuildClientAssociationsFromClient( client: Client, trx: Transaction | typeof db = db ): Promise { - return await lockManager.withLock( - `rebuild-client-associations:client:${client.clientId}`, - () => rebuildClientAssociationsFromClientImpl(client, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + try { + return await lockManager.withLock( + `rebuild-client-associations:client:${client.clientId}`, + () => rebuildClientAssociationsFromClientImpl(client, trx), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for client ${client.clientId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "client", + id: client.clientId + }); + return; + } + throw err; + } } async function rebuildClientAssociationsFromClientImpl( @@ -1906,3 +1946,47 @@ export async function cleanupSiteAssociations( logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`); } + +/** + * Start the background rebuild queue processor. This should be called once + * during server startup. Only one server instance at a time will actively + * consume the queue (enforced via a distributed Redis lock); all other + * instances will poll and wait until the lock becomes available. + */ +export function startRebuildQueueProcessor(): void { + rebuildQueue.startProcessing({ + onSiteResource: async (siteResourceId: number) => { + const [siteResource] = await primaryDb + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)); + + if (!siteResource) { + logger.warn( + `Rebuild queue: site resource ${siteResourceId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromSiteResource( + siteResource, + primaryDb + ); + }, + onClient: async (clientId: number) => { + const [client] = await primaryDb + .select() + .from(clients) + .where(eq(clients.clientId, clientId)); + + if (!client) { + logger.warn( + `Rebuild queue: client ${clientId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromClient(client, primaryDb); + } + }); +} diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts new file mode 100644 index 000000000..475858108 --- /dev/null +++ b/server/lib/rebuildQueue.ts @@ -0,0 +1,23 @@ +export type RebuildJobType = "site-resource" | "client"; + +export interface RebuildJob { + type: RebuildJobType; + id: number; +} + +export interface RebuildJobHandlers { + onSiteResource(siteResourceId: number): Promise; + onClient(clientId: number): Promise; +} + +export interface RebuildQueueManager { + enqueue(job: RebuildJob): Promise; + startProcessing(handlers: RebuildJobHandlers): void; +} + +class NoopRebuildQueue implements RebuildQueueManager { + async enqueue(_job: RebuildJob): Promise {} + startProcessing(_handlers: RebuildJobHandlers): void {} +} + +export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts new file mode 100644 index 000000000..e5ee7e7cb --- /dev/null +++ b/server/private/lib/rebuildQueue.ts @@ -0,0 +1,169 @@ +/* + * 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 { lockManager } from "#dynamic/lib/lock"; +import logger from "@server/logger"; + +export type RebuildJobType = "site-resource" | "client"; + +export interface RebuildJob { + type: RebuildJobType; + id: number; +} + +export interface RebuildJobHandlers { + onSiteResource(siteResourceId: number): Promise; + onClient(clientId: number): Promise; +} + +// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order). +const QUEUE_KEY = "rebuild-client-associations:queue"; + +// Distributed lock that serialises queue consumption to a single server instance +// at a time. TTL is generous enough to cover a full batch of expensive rebuilds. +const PROCESSOR_LOCK_KEY = "rebuild-client-associations:processor"; + +// Each rebuild can take up to REBUILD_ASSOCIATIONS_LOCK_TTL_MS (120 s) per +// resource. Allow BATCH_SIZE resources per processor-lock acquisition, plus a +// small buffer. +const BATCH_SIZE = 5; +const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s + +const POLL_INTERVAL_MS = 500; + +class RedisRebuildQueue { + private processingStarted = false; + + async enqueue(job: RebuildJob): Promise { + if (!redis || redis.status !== "ready") { + logger.warn( + `Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried` + ); + return; + } + + try { + await redis.rpush(QUEUE_KEY, JSON.stringify(job)); + logger.debug( + `Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)` + ); + } catch (err) { + logger.error( + `Rebuild queue: failed to enqueue ${job.type}:${job.id}:`, + err + ); + } + } + + startProcessing(handlers: RebuildJobHandlers): void { + if (this.processingStarted) return; + this.processingStarted = true; + + this.processLoop(handlers).catch((err) => { + logger.error("Rebuild queue processor loop crashed:", err); + }); + + logger.info("Rebuild queue processor started"); + } + + private async processLoop(handlers: RebuildJobHandlers): Promise { + while (true) { + try { + await this.tryProcessBatch(handlers); + } catch (err) { + logger.error( + "Rebuild queue: unhandled error in process loop:", + err + ); + } + await new Promise((resolve) => + setTimeout(resolve, POLL_INTERVAL_MS) + ); + } + } + + private async tryProcessBatch(handlers: RebuildJobHandlers): Promise { + if (!redis || redis.status !== "ready") return; + + // Peek before acquiring the processor lock to avoid unnecessary Redis + // round-trips and lock contention when the queue is idle. + const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0); + if (queueLength === 0) return; + + try { + await lockManager.withLock( + PROCESSOR_LOCK_KEY, + async () => { + for (let i = 0; i < BATCH_SIZE; i++) { + if (!redis || redis.status !== "ready") break; + + const payload = await redis.lpop(QUEUE_KEY); + if (payload === null) break; // queue drained + + let job: RebuildJob; + try { + job = JSON.parse(payload) as RebuildJob; + } catch { + logger.error( + `Rebuild queue: could not parse job payload, discarding: ${payload}` + ); + continue; + } + + logger.debug( + `Rebuild queue: processing ${job.type}:${job.id}` + ); + + try { + if (job.type === "site-resource") { + await handlers.onSiteResource(job.id); + } else if (job.type === "client") { + await 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 + ); + } + } + }, + PROCESSOR_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + // Another server instance currently holds the processor lock and + // is consuming the queue — nothing to do this cycle. + logger.debug( + "Rebuild queue: processor lock held by another instance, skipping this cycle" + ); + } else { + throw err; + } + } + } +} + +export const rebuildQueue: RedisRebuildQueue = new RedisRebuildQueue(); From d09668b20b6875467ac160fa5378d73e741a46ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:46:12 +0000 Subject: [PATCH 064/264] feat: batch redis ws direct messages and dedupe rebuild queue jobs --- server/private/lib/rebuildQueue.ts | 19 ++++ server/private/routers/ws/ws.ts | 140 +++++++++++++++++++++++++---- server/routers/ws/types.ts | 6 +- 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index e5ee7e7cb..ad150f278 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -29,6 +29,7 @@ export interface RebuildJobHandlers { // Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order). const QUEUE_KEY = "rebuild-client-associations:queue"; +const QUEUED_SET_KEY = "rebuild-client-associations:queued"; // Distributed lock that serialises queue consumption to a single server instance // at a time. TTL is generous enough to cover a full batch of expensive rebuilds. @@ -54,11 +55,23 @@ class RedisRebuildQueue { } try { + const dedupeKey = `${job.type}:${job.id}`; + const added = await redis.sadd(QUEUED_SET_KEY, dedupeKey); + if (added === 0) { + logger.debug( + `Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}` + ); + return; + } + await redis.rpush(QUEUE_KEY, JSON.stringify(job)); logger.debug( `Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)` ); } catch (err) { + await redis + ?.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) + .catch(() => undefined); logger.error( `Rebuild queue: failed to enqueue ${job.type}:${job.id}:`, err @@ -121,6 +134,12 @@ class RedisRebuildQueue { continue; } + // Remove from dedupe set once dequeued so the same job + // can be re-queued while this one is in progress. + await redis + .srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) + .catch(() => undefined); + logger.debug( `Rebuild queue: processing ${job.type}:${job.id}` ); diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index a592927cc..c58437b78 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -187,6 +187,8 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true }); // Generate unique node ID for this instance const NODE_ID = uuidv4(); const REDIS_CHANNEL = "websocket_messages"; +const REDIS_DIRECT_BATCH_SIZE = 250; +const REDIS_DIRECT_FLUSH_MS = 10; // Client tracking map (local to this node) const connectedClients: Map = new Map(); @@ -197,6 +199,15 @@ const clientConfigVersions: Map = new Map(); // Recovery tracking let isRedisRecoveryInProgress = false; +interface RedisDirectBatchEntry { + targetClientId: string; + message: WSMessage; + resolve: () => void; +} + +let pendingRedisDirectMessages: RedisDirectBatchEntry[] = []; +let redisDirectFlushTimer: NodeJS.Timeout | null = null; + // Helper to get map key const getClientMapKey = (clientId: string) => clientId; @@ -207,6 +218,82 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) => const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`; +const clearRedisDirectFlushTimer = (): void => { + if (redisDirectFlushTimer) { + clearTimeout(redisDirectFlushTimer); + redisDirectFlushTimer = null; + } +}; + +const publishDirectBatch = async ( + entries: RedisDirectBatchEntry[] +): Promise => { + const redisMessage: RedisMessage = { + type: "direct-batch", + messages: entries.map((entry) => ({ + targetClientId: entry.targetClientId, + message: entry.message + })), + message: { + type: "batch", + data: {} + }, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); +}; + +const flushPendingRedisDirectMessages = async (): Promise => { + clearRedisDirectFlushTimer(); + + if (pendingRedisDirectMessages.length === 0) { + return; + } + + const entries = pendingRedisDirectMessages; + pendingRedisDirectMessages = []; + + if (!redisManager.isRedisEnabled()) { + entries.forEach((entry) => entry.resolve()); + return; + } + + for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) { + const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE); + try { + await publishDirectBatch(batch); + } catch (error) { + logger.error( + "Failed to send batched direct messages via Redis, messages may be lost:", + error + ); + } finally { + batch.forEach((entry) => entry.resolve()); + } + } +}; + +const enqueueRedisDirectMessage = async ( + targetClientId: string, + message: WSMessage +): Promise => { + await new Promise((resolve) => { + pendingRedisDirectMessages.push({ targetClientId, message, resolve }); + + if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) { + void flushPendingRedisDirectMessages(); + return; + } + + if (!redisDirectFlushTimer) { + redisDirectFlushTimer = setTimeout(() => { + void flushPendingRedisDirectMessages(); + }, REDIS_DIRECT_FLUSH_MS); + } + }); +}; + // Initialize Redis subscription for cross-node messaging const initializeRedisSubscription = async (): Promise => { if (!redisManager.isRedisEnabled()) return; @@ -227,7 +314,16 @@ const initializeRedisSubscription = async (): Promise => { // Send to specific client on this node await sendToClientLocal( redisMessage.targetClientId, - redisMessage.message + redisMessage.message, + {}, + redisMessage.message.configVersion + ); + } else if ( + redisMessage.type === "direct-batch" && + redisMessage.messages + ) { + await sendRedisDirectBatchToLocalClients( + redisMessage.messages ); } else if (redisMessage.type === "broadcast") { // Broadcast to all clients on this node except excluded @@ -503,7 +599,8 @@ const incrementClientConfigVersion = async ( const sendToClientLocal = async ( clientId: string, message: WSMessage, - options: SendMessageOptions = {} + options: SendMessageOptions = {}, + preResolvedConfigVersion?: number ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); @@ -512,7 +609,8 @@ const sendToClientLocal = async ( } // Handle config version - const configVersion = await getClientConfigVersion(clientId); + const configVersion = + preResolvedConfigVersion ?? (await getClientConfigVersion(clientId)); // Add config version to message const messageWithVersion = { @@ -545,6 +643,20 @@ const sendToClientLocal = async ( return true; }; +const sendRedisDirectBatchToLocalClients = async ( + entries: { targetClientId: string; message: WSMessage }[] +): Promise => { + const jobs = entries.map((entry) => + sendToClientLocal( + entry.targetClientId, + entry.message, + {}, + entry.message.configVersion + ) + ); + await Promise.all(jobs); +}; + const broadcastToAllExceptLocal = async ( message: WSMessage, excludeClientId?: string, @@ -607,23 +719,13 @@ const sendToClient = async ( // Only send via Redis if the client is not connected locally and Redis is enabled if (!localSent && redisManager.isRedisEnabled()) { try { - const redisMessage: RedisMessage = { - type: "direct", - targetClientId: clientId, - message: { - ...message, - configVersion - }, - fromNodeId: NODE_ID - }; - - await redisManager.publish( - REDIS_CHANNEL, - JSON.stringify(redisMessage) - ); + await enqueueRedisDirectMessage(clientId, { + ...message, + configVersion + }); } catch (error) { logger.error( - "Failed to send message via Redis, message may be lost:", + "Failed to queue batched direct message for Redis delivery, message may be lost:", error ); // Continue execution - local delivery already attempted @@ -1109,6 +1211,8 @@ const disconnectClient = async (clientId: string): Promise => { // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { + await flushPendingRedisDirectMessages(); + // Close all WebSocket connections connectedClients.forEach((clients) => { clients.forEach((client) => { diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index e539954ce..9e6504ce0 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -78,10 +78,14 @@ export interface SendMessageOptions { // Redis message type for cross-node communication export interface RedisMessage { - type: "direct" | "broadcast"; + type: "direct" | "direct-batch" | "broadcast"; targetClientId?: string; excludeClientId?: string; message: WSMessage; + messages?: { + targetClientId: string; + message: WSMessage; + }[]; fromNodeId: string; options?: SendMessageOptions; } From 22ac711dc63c1e60ce4b09e7d837785bc12879c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:50:03 +0000 Subject: [PATCH 065/264] refactor: tighten ws batch typing and queue cleanup logging --- server/private/lib/rebuildQueue.ts | 16 ++++++++++--- server/private/routers/ws/ws.ts | 8 ++----- server/routers/ws/types.ts | 36 +++++++++++++++++++----------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index ad150f278..2cd1dadc0 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -70,8 +70,13 @@ class RedisRebuildQueue { ); } catch (err) { await redis - ?.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) - .catch(() => undefined); + .srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) + .catch((cleanupErr) => + logger.warn( + `Rebuild queue: failed to cleanup dedupe key for ${job.type}:${job.id} after enqueue failure:`, + cleanupErr + ) + ); logger.error( `Rebuild queue: failed to enqueue ${job.type}:${job.id}:`, err @@ -138,7 +143,12 @@ class RedisRebuildQueue { // can be re-queued while this one is in progress. await redis .srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) - .catch(() => undefined); + .catch((cleanupErr) => + logger.warn( + `Rebuild queue: failed to remove dedupe key for ${job.type}:${job.id} on dequeue:`, + cleanupErr + ) + ); logger.debug( `Rebuild queue: processing ${job.type}:${job.id}` diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index c58437b78..2db8f3140 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -188,7 +188,7 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true }); const NODE_ID = uuidv4(); const REDIS_CHANNEL = "websocket_messages"; const REDIS_DIRECT_BATCH_SIZE = 250; -const REDIS_DIRECT_FLUSH_MS = 10; +const REDIS_DIRECT_FLUSH_INTERVAL_MS = 10; // Client tracking map (local to this node) const connectedClients: Map = new Map(); @@ -234,10 +234,6 @@ const publishDirectBatch = async ( targetClientId: entry.targetClientId, message: entry.message })), - message: { - type: "batch", - data: {} - }, fromNodeId: NODE_ID }; @@ -289,7 +285,7 @@ const enqueueRedisDirectMessage = async ( if (!redisDirectFlushTimer) { redisDirectFlushTimer = setTimeout(() => { void flushPendingRedisDirectMessages(); - }, REDIS_DIRECT_FLUSH_MS); + }, REDIS_DIRECT_FLUSH_INTERVAL_MS); } }); }; diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index 9e6504ce0..d541d3276 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -76,16 +76,26 @@ export interface SendMessageOptions { compress?: boolean; } -// Redis message type for cross-node communication -export interface RedisMessage { - type: "direct" | "direct-batch" | "broadcast"; - targetClientId?: string; - excludeClientId?: string; - message: WSMessage; - messages?: { - targetClientId: string; - message: WSMessage; - }[]; - fromNodeId: string; - options?: SendMessageOptions; -} +// Redis message types for cross-node communication +export type RedisMessage = + | { + type: "direct"; + targetClientId: string; + message: WSMessage; + fromNodeId: string; + } + | { + type: "direct-batch"; + messages: { + targetClientId: string; + message: WSMessage; + }[]; + fromNodeId: string; + } + | { + type: "broadcast"; + excludeClientId?: string; + message: WSMessage; + fromNodeId: string; + options?: SendMessageOptions; + }; From ee42846c90c5a7f49e78e4c4d0b81573e296fbc0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:20:07 -0400 Subject: [PATCH 066/264] Add batch messaging functions to rebuild function --- server/lib/rebuildClientAssociations.ts | 321 +++++++++++++++++------- server/private/routers/ws/ws.ts | 72 ++++++ server/routers/client/targets.ts | 241 +++++++++++++++++- server/routers/newt/peers.ts | 77 ++++-- server/routers/olm/peers.ts | 151 ++++++++++- server/routers/ws/types.ts | 6 + server/routers/ws/ws.ts | 18 +- 7 files changed, 767 insertions(+), 119 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8b601ae71..f6a94e7b7 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -21,10 +21,10 @@ import { } from "@server/db"; import { and, count, eq, inArray, ne } from "drizzle-orm"; -import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; +import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { - initPeerAddHandshake, - deletePeer as olmDeletePeer + initPeerAddHandshakeBatch, + deletePeersBatch as olmDeletePeersBatch } from "@server/routers/olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import logger from "@server/logger"; @@ -35,10 +35,10 @@ import { parseEndpoint } from "@server/lib/ip"; import { - addPeerData, - addTargets as addSubnetProxyTargets, - removePeerData, - removeTargets as removeSubnetProxyTargets + addPeerDataBatch, + addTargetsBatch as addSubnetProxyTargetsBatch, + removePeerDataBatch, + removeTargetsBatch as removeSubnetProxyTargetsBatch } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; @@ -559,6 +559,28 @@ async function handleMessagesForSiteClients( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; // Combine all clients that need processing (those being added or removed) const clientsToProcess = new Map< @@ -638,15 +660,17 @@ async function handleMessagesForSiteClients( } if (isDelete) { - newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); - olmJobs.push( - olmDeletePeer( - client.clientId, - siteId, - site.publicKey, - olm.olmId - ) - ); + newtPeerDeletes.push({ + siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey, + olmId: olm.olmId + }); } if (isAdd) { @@ -658,23 +682,34 @@ async function handleMessagesForSiteClients( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, - olm.olmId - ); + olmId: olm.olmId + }); } exitNodeJobs.push(updateClientSiteDestinations(client, trx)); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`, @@ -867,24 +902,28 @@ async function handleSubnetProxyTargetUpdates( if (targetsToAdd) { proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targetsToAdd, - newt.version - ) + addSubnetProxyTargetsBatch([ + { + newtId: newt.newtId, + targets: targetsToAdd, + version: newt.version + } + ]) ); } - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, + olmJobs.push( + addPeerDataBatch( + addedClients.map((client) => ({ + clientId: client.clientId, siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + remoteSubnets: generateRemoteSubnets([ + siteResource + ]), + aliases: generateAliasConfig([siteResource]) + })) + ) + ); } } @@ -904,14 +943,23 @@ async function handleSubnetProxyTargetUpdates( if (targetsToRemove) { proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targetsToRemove, - newt.version - ) + removeSubnetProxyTargetsBatch([ + { + newtId: newt.newtId, + targets: targetsToRemove, + version: newt.version + } + ]) ); } + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const client of removedClients) { if (!siteResource.destination) { continue; @@ -959,14 +1007,16 @@ async function handleSubnetProxyTargetUpdates( ? [] : generateRemoteSubnets([siteResource]); - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) - ); + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([siteResource]) + }); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); } } } @@ -1277,6 +1327,28 @@ async function handleMessagesForClientSites( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; const totalSitesOnClient = await trx .select({ count: count(clientSitesAssociationsCache.siteId) }) @@ -1308,19 +1380,19 @@ async function handleMessagesForClientSites( if (isRemove) { // Remove peer from newt - newtJobs.push( - newtDeletePeer(site.siteId, client.pubKey, newt.newtId) - ); + newtPeerDeletes.push({ + siteId: site.siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); try { // Remove peer from olm - olmJobs.push( - olmDeletePeer( - client.clientId, - site.siteId, - site.publicKey, - olmId - ) - ); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId: site.siteId, + publicKey: site.publicKey, + olmId + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1352,10 +1424,9 @@ async function handleMessagesForClientSites( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId: site.siteId, exitNode: { publicKey: exitNode.publicKey, @@ -1363,7 +1434,7 @@ async function handleMessagesForClientSites( } }, olmId - ); + }); } // Update exit node destinations @@ -1379,6 +1450,18 @@ async function handleMessagesForClientSites( ); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`, @@ -1477,6 +1560,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1487,25 +1584,21 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { // Add peer data to olm - olmJobs.push( - addPeerData( - client.clientId, - siteId, - generateRemoteSubnets([resource]), - generateAliasConfig([resource]) - ) - ); + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([resource]), + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1520,6 +1613,14 @@ async function handleMessagesForClientResources( } } } + + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (peerDataAdds.length > 0) { + olmJobs.push(addPeerDataBatch(peerDataAdds)); + } } } @@ -1586,6 +1687,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1596,13 +1711,11 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { @@ -1653,14 +1766,12 @@ async function handleMessagesForClientResources( : generateRemoteSubnets([resource]); // Remove peer data from olm - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([resource]) - ) - ); + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1675,6 +1786,16 @@ async function handleMessagesForClientResources( } } } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push( + removeSubnetProxyTargetsBatch(targetsToRemoveBatch) + ); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); + } } } @@ -1928,7 +2049,15 @@ export async function cleanupSiteAssociations( for (const client of allClients) { // Tell each olm to drop the site's WireGuard peer. if (site.publicKey) { - jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey)); + jobs.push( + olmDeletePeersBatch([ + { + clientId: client.clientId, + siteId, + publicKey: site.publicKey + } + ]) + ); } // Recompute and push updated relay destinations (now excluding this site). diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 2db8f3140..8d222fd72 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -38,6 +38,7 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { AuthenticatedWebSocket, + BatchSendMessage, ClientType, WSMessage, TokenPayload, @@ -736,6 +737,76 @@ const sendToClient = async ( return localSent; }; +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + if (entries.length === 0) { + return; + } + + const remoteEntries: { targetClientId: string; message: WSMessage }[] = []; + + for (const entry of entries) { + const options = entry.options || {}; + const { clientId, message } = entry; + + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = await incrementClientConfigVersion(clientId); + } + + logger.debug( + `sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})` + ); + + const localSent = await sendToClientLocal( + clientId, + message, + options, + configVersion + ); + + if (!localSent && redisManager.isRedisEnabled()) { + remoteEntries.push({ + targetClientId: clientId, + message: { + ...message, + configVersion + } + }); + } else if (!localSent && !redisManager.isRedisEnabled()) { + logger.debug( + `Could not deliver batch message to ${clientId} - not connected locally and Redis unavailable` + ); + } + } + + if (!redisManager.isRedisEnabled() || remoteEntries.length === 0) { + return; + } + + for (let i = 0; i < remoteEntries.length; i += REDIS_DIRECT_BATCH_SIZE) { + const messages = remoteEntries.slice(i, i + REDIS_DIRECT_BATCH_SIZE); + try { + const redisMessage: RedisMessage = { + type: "direct-batch", + messages, + fromNodeId: NODE_ID + }; + + await redisManager.publish( + REDIS_CHANNEL, + JSON.stringify(redisMessage) + ); + } catch (error) { + logger.error( + "Failed to send explicit direct batch via Redis, messages may be lost:", + error + ); + } + } +}; + const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, @@ -1239,6 +1310,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index c208acd88..c62a64ae0 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,4 +1,4 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { db, newts, olms } from "@server/db"; import { Alias, @@ -8,7 +8,7 @@ import { } from "@server/lib/ip"; import { canCompress } from "@server/lib/clientVersionChecks"; import logger from "@server/logger"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import semver from "semver"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; @@ -59,6 +59,42 @@ export async function addTargets( ); } +export async function addTargetsBatch( + entries: { + newtId: string; + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolved = await Promise.all( + entries.map(async (entry) => ({ + ...entry, + targets: await convertTargetsIfNecessary( + entry.newtId, + entry.targets + ) + })) + ); + + await sendToClientsBatch( + resolved.map((entry) => ({ + clientId: entry.newtId, + message: { + type: `newt/wg/targets/add`, + data: entry.targets + }, + options: { + incrementConfigVersion: true, + compress: canCompress(entry.version, "newt") + } + })) + ); +} + export async function removeTargets( newtId: string, targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], @@ -76,6 +112,42 @@ export async function removeTargets( ); } +export async function removeTargetsBatch( + entries: { + newtId: string; + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolved = await Promise.all( + entries.map(async (entry) => ({ + ...entry, + targets: await convertTargetsIfNecessary( + entry.newtId, + entry.targets + ) + })) + ); + + await sendToClientsBatch( + resolved.map((entry) => ({ + clientId: entry.newtId, + message: { + type: `newt/wg/targets/remove`, + data: entry.targets + }, + options: { + incrementConfigVersion: true, + compress: canCompress(entry.version, "newt") + } + })) + ); +} + export async function updateTargets( newtId: string, targets: { @@ -201,6 +273,171 @@ export async function removePeerData( }); } +const resolveOlmTargets = async ( + entries: { + clientId: number; + olmId?: string; + version?: string | null; + }[] +) => { + const unresolvedClientIds = entries + .filter((entry) => !entry.olmId) + .map((entry) => entry.clientId); + + const olmMap = new Map(); + + if (unresolvedClientIds.length > 0) { + const olmRows = await db + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, unresolvedClientIds)); + + for (const row of olmRows) { + if (row.clientId !== null) { + olmMap.set(row.clientId, { + olmId: row.olmId, + version: row.version + }); + } + } + } + + return entries + .map((entry) => { + if (entry.olmId) { + return { + clientId: entry.clientId, + olmId: entry.olmId, + version: entry.version + }; + } + + const resolved = olmMap.get(entry.clientId); + if (!resolved) { + return null; + } + + return { + clientId: entry.clientId, + olmId: resolved.olmId, + version: entry.version ?? resolved.version + }; + }) + .filter((entry) => entry !== null); +}; + +export async function addPeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: Alias[]; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/add`, + data: { + siteId: entry.siteId, + remoteSubnets: entry.remoteSubnets, + aliases: entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + +export async function removePeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: Alias[]; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/remove`, + data: { + siteId: entry.siteId, + remoteSubnets: entry.remoteSubnets, + aliases: entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + export async function updatePeerData( clientId: number, siteId: number, diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index 4b74d863d..6c38671f3 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -1,7 +1,7 @@ import { db, Site } from "@server/db"; import { newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import logger from "@server/logger"; export async function addPeer( @@ -36,10 +36,14 @@ export async function addPeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/add", - data: peer - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/add", + data: peer + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -76,12 +80,16 @@ export async function deletePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/remove", - data: { - publicKey - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/remove", + data: { + publicKey + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -90,6 +98,35 @@ export async function deletePeer( return site; } +export async function deletePeersBatch( + peers: { + siteId: number; + publicKey: string; + newtId: string; + }[] +) { + if (peers.length === 0) { + return; + } + + await sendToClientsBatch( + peers.map((peer) => ({ + clientId: peer.newtId, + message: { + type: "newt/wg/peer/remove", + data: { + publicKey: peer.publicKey + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched newt peer removals:`, error); + }); + + logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`); +} + export async function updatePeer( siteId: number, publicKey: string, @@ -122,13 +159,17 @@ export async function updatePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/update", - data: { - publicKey, - ...peer - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/update", + data: { + publicKey, + ...peer + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 05e153fea..962d7367e 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -1,9 +1,9 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { Alias } from "yaml"; export async function addPeer( @@ -205,3 +205,150 @@ export async function initPeerAddHandshake( `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` ); } + +export async function deletePeersBatch( + peers: { + clientId: number; + siteId: number; + publicKey: string; + olmId?: string; + version?: string | null; + }[] +) { + if (peers.length === 0) { + return; + } + + const unresolvedClientIds = peers + .filter((peer) => !peer.olmId) + .map((peer) => peer.clientId); + + const olmByClientId = new Map< + number, + { olmId: string; version: string | null } + >(); + + if (unresolvedClientIds.length > 0) { + const olmRows = await db + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, unresolvedClientIds)); + + for (const row of olmRows) { + if (row.clientId !== null) { + olmByClientId.set(row.clientId, { + olmId: row.olmId, + version: row.version + }); + } + } + } + + const batchPayloads = peers + .map((peer) => { + const resolved = peer.olmId + ? { olmId: peer.olmId, version: peer.version ?? null } + : olmByClientId.get(peer.clientId); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: "olm/wg/peer/remove", + data: { + publicKey: peer.publicKey, + siteId: peer.siteId + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress( + peer.version ?? resolved.version, + "olm" + ) + } + }; + }) + .filter((payload) => payload !== null); + + if (batchPayloads.length === 0) { + return; + } + + await sendToClientsBatch(batchPayloads).catch((error) => { + logger.warn(`Error sending batched olm peer removals:`, error); + }); + + logger.info(`Deleted ${batchPayloads.length} peer(s) from olms (batch)`); +} + +export async function initPeerAddHandshakeBatch( + handshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + chainId?: string; + }[] +) { + if (handshakes.length === 0) { + return; + } + + await sendToClientsBatch( + handshakes.map((item) => ({ + clientId: item.olmId, + message: { + type: "olm/wg/peer/holepunch/site/add", + data: { + siteId: item.peer.siteId, + exitNode: { + publicKey: item.peer.exitNode.publicKey, + relayPort: + config.getRawConfig().gerbil.clients_start_port, + endpoint: item.peer.exitNode.endpoint + }, + chainId: item.chainId + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched olm handshakes:`, error); + }); + + await Promise.all( + handshakes.map((item) => + db + .update(clientSitesAssociationsCache) + .set({ isJitMode: false }) + .where( + and( + eq( + clientSitesAssociationsCache.clientId, + item.clientId + ), + eq( + clientSitesAssociationsCache.siteId, + item.peer.siteId + ) + ) + ) + ) + ); + + logger.info( + `Initiated ${handshakes.length} peer add handshake(s) to olms (batch)` + ); +} diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index d541d3276..eeb272457 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -76,6 +76,12 @@ export interface SendMessageOptions { compress?: boolean; } +export interface BatchSendMessage { + clientId: string; + message: WSMessage; + options?: SendMessageOptions; +} + // Redis message types for cross-node communication export type RedisMessage = | { diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index e7dcfe9cb..4ce337a20 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -26,7 +26,8 @@ import { WebSocketRequest, WSMessage, AuthenticatedWebSocket, - SendMessageOptions + SendMessageOptions, + BatchSendMessage } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -212,6 +213,20 @@ const sendToClient = async ( return localSent; }; +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + if (entries.length === 0) { + return; + } + + await Promise.all( + entries.map((entry) => + sendToClient(entry.clientId, entry.message, entry.options) + ) + ); +}; + const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, @@ -552,6 +567,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, From 604dee9aa5a34989f1e467a38c227675d53f6b14 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:34:46 -0400 Subject: [PATCH 067/264] Batch get olm ids --- server/lib/rebuildClientAssociations.ts | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index f6a94e7b7..8e235bc48 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -629,6 +629,21 @@ async function handleMessagesForSiteClients( } } + // Batch-fetch all olm IDs for the clients we need to process + const clientIdsToProcess = Array.from(clientsToProcess.keys()); + const olmRows = + clientIdsToProcess.length > 0 + ? await trx + .select({ olmId: olms.olmId, clientId: olms.clientId }) + .from(olms) + .where(inArray(olms.clientId, clientIdsToProcess)) + : []; + const olmByClientId = new Map( + olmRows + .filter((r) => r.clientId !== null) + .map((r) => [r.clientId as number, r.olmId]) + ); + for (const client of clientsToProcess.values()) { // UPDATE THE NEWT if (!client.subnet || !client.pubKey) { @@ -645,14 +660,8 @@ async function handleMessagesForSiteClients( continue; } - const [olm] = await trx - .select({ - olmId: olms.olmId - }) - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - if (!olm) { + const olmId = olmByClientId.get(client.clientId); + if (!olmId) { logger.warn( `Olm not found for client ${client.clientId} so cannot add/delete peers` ); @@ -669,7 +678,7 @@ async function handleMessagesForSiteClients( clientId: client.clientId, siteId, publicKey: site.publicKey, - olmId: olm.olmId + olmId }); } @@ -691,7 +700,7 @@ async function handleMessagesForSiteClients( endpoint: exitNode.endpoint } }, - olmId: olm.olmId + olmId }); } From 60c1b572baf44b4a275fdf9de9997a6b6e7a5995 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:43:39 -0400 Subject: [PATCH 068/264] Add drizzle indexes to match db --- server/db/pg/schema/privateSchema.ts | 35 +- server/db/pg/schema/schema.ts | 672 ++++++++++++++++----------- 2 files changed, 414 insertions(+), 293 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 229fc9ff0..ae73b97ac 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -11,7 +11,7 @@ import { primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { domains, orgs, @@ -207,17 +207,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); -export const loginPage = pgTable("loginPage", { - loginPageId: serial("loginPageId").primaryKey(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }) -}); +export const loginPage = pgTable( + "loginPage", + { + loginPageId: serial("loginPageId").primaryKey(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + exitNodeId: integer("exitNodeId").references( + () => exitNodes.exitNodeId, + { + onDelete: "set null" + } + ), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }) + }, + (t) => [ + index("idx_loginpage_fulldomain") + .on(t.fullDomain) + .where(sql`${t.fullDomain} IS NOT NULL`) + ] +); export const loginPageOrg = pgTable("loginPageOrg", { loginPageId: integer("loginPageId") diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 025bdf923..1b48aa520 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,5 +1,5 @@ import { randomUUID } from "crypto"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { bigint, boolean, @@ -82,107 +82,130 @@ export const orgDomains = pgTable("orgDomains", { .references(() => domains.domainId, { onDelete: "cascade" }) }); -export const sites = pgTable("sites", { - siteId: serial("siteId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: varchar("niceId").notNull(), - exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet"), - megabytesIn: real("bytesIn").default(0), - megabytesOut: real("bytesOut").default(0), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - type: varchar("type").notNull(), // "newt" or "wireguard" - online: boolean("online").notNull().default(false), - lastPing: integer("lastPing"), - address: varchar("address"), - endpoint: varchar("endpoint"), - publicKey: varchar("publicKey"), - lastHolePunch: bigint("lastHolePunch", { mode: "number" }), - listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false), - autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") - .notNull() - .default(false), - status: varchar("status") - .$type<"pending" | "approved">() - .default("approved") -}); +export const sites = pgTable( + "sites", + { + siteId: serial("siteId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: varchar("niceId").notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet"), + megabytesIn: real("bytesIn").default(0), + megabytesOut: real("bytesOut").default(0), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + type: varchar("type").notNull(), // "newt" or "wireguard" + online: boolean("online").notNull().default(false), + lastPing: integer("lastPing"), + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), + dockerSocketEnabled: boolean("dockerSocketEnabled") + .notNull() + .default(true), + autoUpdateEnabled: boolean("autoUpdateEnabled") + .notNull() + .default(false), + autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") + .notNull() + .default(false), + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") + }, + (t) => [ + index("idx_sites_exitnodeid").on(t.exitNodeId), + index("idx_sites_exitnode_type_siteid").on( + t.exitNodeId, + t.type, + t.siteId + ) + ] +); -export const resources = pgTable("resources", { - resourceId: serial("resourceId").primaryKey(), - resourcePolicyId: integer("resourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { onDelete: "set null" } - ), - defaultResourcePolicyId: integer("defaultResourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { - onDelete: "restrict" - } - ), - resourceGuid: varchar("resourceGuid", { length: 36 }) - .unique() - .notNull() - .$defaultFn(() => randomUUID()), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: text("niceId").notNull(), - name: varchar("name").notNull(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }), - ssl: boolean("ssl").notNull().default(false), - blockAccess: boolean("blockAccess").notNull().default(false), - proxyPort: integer("proxyPort"), - sso: boolean("sso"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled"), - applyRules: boolean("applyRules"), - enabled: boolean("enabled").notNull().default(true), - stickySession: boolean("stickySession").notNull().default(false), - tlsServerName: varchar("tlsServerName"), - setHostHeader: varchar("setHostHeader"), - enableProxy: boolean("enableProxy").default(true), - skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "set null" - }), - headers: text("headers"), // comma-separated list of headers to add to the request - proxyProtocol: boolean("proxyProtocol").notNull().default(false), - proxyProtocolVersion: integer("proxyProtocolVersion").default(1), - maintenanceModeEnabled: boolean("maintenanceModeEnabled") - .notNull() - .default(false), - maintenanceModeType: text("maintenanceModeType", { - enum: ["forced", "automatic"] - }).default("forced"), // "forced" = always show, "automatic" = only when down - maintenanceTitle: text("maintenanceTitle"), - maintenanceMessage: text("maintenanceMessage"), - maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath"), - health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" - wildcard: boolean("wildcard").notNull().default(false), - mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc - pamMode: varchar("pamMode", { length: 32 }) - .$type<"passthrough" | "push">() - .default("passthrough"), - authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote" | "native">() - .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) -}); +export const resources = pgTable( + "resources", + { + resourceId: serial("resourceId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), + resourceGuid: varchar("resourceGuid", { length: 36 }) + .unique() + .notNull() + .$defaultFn(() => randomUUID()), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + ssl: boolean("ssl").notNull().default(false), + blockAccess: boolean("blockAccess").notNull().default(false), + proxyPort: integer("proxyPort"), + sso: boolean("sso"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled"), + applyRules: boolean("applyRules"), + enabled: boolean("enabled").notNull().default(true), + stickySession: boolean("stickySession").notNull().default(false), + tlsServerName: varchar("tlsServerName"), + setHostHeader: varchar("setHostHeader"), + enableProxy: boolean("enableProxy").default(true), + skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { + onDelete: "set null" + }), + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: boolean("proxyProtocol").notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1), + maintenanceModeEnabled: boolean("maintenanceModeEnabled") + .notNull() + .default(false), + maintenanceModeType: text("maintenanceModeType", { + enum: ["forced", "automatic"] + }).default("forced"), // "forced" = always show, "automatic" = only when down + maintenanceTitle: text("maintenanceTitle"), + maintenanceMessage: text("maintenanceMessage"), + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath"), + health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" + wildcard: boolean("wildcard").notNull().default(false), + mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote" | "native">() + .default("site"), + authDaemonPort: integer("authDaemonPort").default(22123) + }, + (t) => [ + index("idx_resources_fulldomain") + .on(t.fullDomain) + .where(sql`${t.fullDomain} IS NOT NULL`) + ] +); export const labels = pgTable("labels", { labelId: serial("labelId").primaryKey(), @@ -267,71 +290,84 @@ export const clientLabels = pgTable( (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] ); -export const targets = pgTable("targets", { - targetId: serial("targetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - ip: varchar("ip").notNull(), - method: varchar("method"), - port: integer("port").notNull(), - internalPort: integer("internalPort"), - enabled: boolean("enabled").notNull().default(true), - path: text("path"), - pathMatchType: text("pathMatchType"), // exact, prefix, regex - rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target - rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100), - mode: varchar("mode") - .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() - .notNull() - .default("http"), - authToken: varchar("authToken") -}); +export const targets = pgTable( + "targets", + { + targetId: serial("targetId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + ip: varchar("ip").notNull(), + method: varchar("method"), + port: integer("port").notNull(), + internalPort: integer("internalPort"), + enabled: boolean("enabled").notNull().default(true), + path: text("path"), + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix + priority: integer("priority").notNull().default(100), + mode: varchar("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: varchar("authToken") + }, + (t) => [ + index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId), + index("idx_targets_site_enabled_priority_target_resource") + .on(t.siteId, t.priority.desc(), t.targetId, t.resourceId) + .where(sql`${t.enabled} = true`) + ] +); -export const targetHealthCheck = pgTable("targetHealthCheck", { - targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), - targetId: integer("targetId").references(() => targets.targetId, { - onDelete: "cascade" - }), - orgId: varchar("orgId") - .references(() => orgs.orgId, { +export const targetHealthCheck = pgTable( + "targetHealthCheck", + { + targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), + targetId: integer("targetId").references(() => targets.targetId, { onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - name: varchar("name"), - hcEnabled: boolean("hcEnabled").notNull().default(false), - hcPath: varchar("hcPath"), - hcScheme: varchar("hcScheme"), - hcMode: varchar("hcMode").default("http"), - hcHostname: varchar("hcHostname"), - hcPort: integer("hcPort"), - hcInterval: integer("hcInterval").default(30), // in seconds - hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds - hcTimeout: integer("hcTimeout").default(5), // in seconds - hcHeaders: varchar("hcHeaders"), - hcFollowRedirects: boolean("hcFollowRedirects").default(true), - hcMethod: varchar("hcMethod").default("GET"), - hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth") - .$type<"unknown" | "healthy" | "unhealthy">() - .default("unknown"), // "unknown", "healthy", "unhealthy" - hcTlsServerName: text("hcTlsServerName"), - hcHealthyThreshold: integer("hcHealthyThreshold").default(1), - hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) -}); + }), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + name: varchar("name"), + hcEnabled: boolean("hcEnabled").notNull().default(false), + hcPath: varchar("hcPath"), + hcScheme: varchar("hcScheme"), + hcMode: varchar("hcMode").default("http"), + hcHostname: varchar("hcHostname"), + hcPort: integer("hcPort"), + hcInterval: integer("hcInterval").default(30), // in seconds + hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds + hcTimeout: integer("hcTimeout").default(5), // in seconds + hcHeaders: varchar("hcHeaders"), + hcFollowRedirects: boolean("hcFollowRedirects").default(true), + hcMethod: varchar("hcMethod").default("GET"), + hcStatus: integer("hcStatus"), // http code + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" + hcTlsServerName: text("hcTlsServerName"), + hcHealthyThreshold: integer("hcHealthyThreshold").default(1), + hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) + }, + (t) => [index("idx_targethealthcheck_targetid").on(t.targetId)] +); export const exitNodes = pgTable("exitNodes", { exitNodeId: serial("exitNodeId").primaryKey(), @@ -406,43 +442,74 @@ export const networks = pgTable("networks", { .notNull() }); -export const siteNetworks = pgTable("siteNetworks", { - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { - onDelete: "cascade" - }), - networkId: integer("networkId") - .notNull() - .references(() => networks.networkId, { onDelete: "cascade" }) -}); +export const siteNetworks = pgTable( + "siteNetworks", + { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_sitenetworks_siteid").on(t.siteId), + index("idx_sitenetworks_networkid").on(t.networkId) + ] +); -export const clientSiteResources = pgTable("clientSiteResources", { - clientId: integer("clientId") - .notNull() - .references(() => clients.clientId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const clientSiteResources = pgTable( + "clientSiteResources", + { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [ + index("idx_clientsiteresources_clientid").on(t.clientId), + index("idx_clientsiteresources_siteresourceid").on(t.siteResourceId) + ] +); -export const roleSiteResources = pgTable("roleSiteResources", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const roleSiteResources = pgTable( + "roleSiteResources", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)] +); -export const userSiteResources = pgTable("userSiteResources", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const userSiteResources = pgTable( + "userSiteResources", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [ + index("idx_usersiteresources_userid").on(t.userId), + index("idx_usersiteresources_siteresourceid").on(t.siteResourceId) + ] +); export const users = pgTable("user", { userId: varchar("id").primaryKey(), @@ -467,15 +534,19 @@ export const users = pgTable("user", { locale: varchar("locale") }); -export const newts = pgTable("newt", { - newtId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: varchar("version"), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }) -}); +export const newts = pgTable( + "newt", + { + newtId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_newt_siteid").on(t.siteId)] +); export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", { codeId: serial("id").primaryKey(), @@ -576,29 +647,49 @@ export const userOrgRoles = pgTable( (t) => [unique().on(t.userId, t.orgId, t.roleId)] ); -export const roleActions = pgTable("roleActions", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const roleActions = pgTable( + "roleActions", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_roleActions_roleId_orgId_actionId").on( + t.roleId, + t.orgId, + t.actionId + ) + ] +); -export const userActions = pgTable("userActions", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const userActions = pgTable( + "userActions", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_userActions_userId_orgId_actionId").on( + t.userId, + t.orgId, + t.actionId + ) + ] +); export const roleSites = pgTable("roleSites", { roleId: integer("roleId") @@ -1004,40 +1095,44 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); -export const clients = pgTable("clients", { - clientId: serial("clientId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { +export const clients = pgTable( + "clients", + { + clientId: serial("clientId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) - .notNull(), - exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - userId: text("userId").references(() => users.userId, { - // optionally tied to a user and in this case delete when the user deletes - onDelete: "cascade" - }), - niceId: varchar("niceId").notNull(), - olmId: text("olmId"), // to lock it to a specific olm optionally - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), - megabytesIn: real("bytesIn"), - megabytesOut: real("bytesOut"), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - lastPing: integer("lastPing"), - type: varchar("type").notNull(), // "olm" - online: boolean("online").notNull().default(false), - // endpoint: varchar("endpoint"), - lastHolePunch: integer("lastHolePunch"), - maxConnections: integer("maxConnections"), - archived: boolean("archived").notNull().default(false), - blocked: boolean("blocked").notNull().default(false), - approvalState: varchar("approvalState").$type< - "pending" | "approved" | "denied" - >() -}); + }), + niceId: varchar("niceId").notNull(), + olmId: text("olmId"), // to lock it to a specific olm optionally + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet").notNull(), + megabytesIn: real("bytesIn"), + megabytesOut: real("bytesOut"), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + lastPing: integer("lastPing"), + type: varchar("type").notNull(), // "olm" + online: boolean("online").notNull().default(false), + // endpoint: varchar("endpoint"), + lastHolePunch: integer("lastHolePunch"), + maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false), + approvalState: varchar("approvalState").$type< + "pending" | "approved" | "denied" + >() + }, + (t) => [index("idx_clients_userid").on(t.userId)] +); export const clientSitesAssociationsCache = pgTable( "clientSitesAssociationsCache", @@ -1049,7 +1144,11 @@ export const clientSitesAssociationsCache = pgTable( isJitMode: boolean("isJitMode").notNull().default(false), endpoint: varchar("endpoint"), publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes - } + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.siteId] }), + index("idx_clientsitesassociationscache_siteid").on(t.siteId) + ] ); export const clientSiteResourcesAssociationsCache = pgTable( @@ -1058,7 +1157,14 @@ export const clientSiteResourcesAssociationsCache = pgTable( clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteResourceId: integer("siteResourceId").notNull() - } + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.siteResourceId] }), + index("idx_clientSiteResourcesAssociationsCache_siteResourceId").on( + t.siteResourceId, + t.clientId + ) + ] ); export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { @@ -1071,23 +1177,27 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { collectedAt: integer("collectedAt").notNull() }); -export const olms = pgTable("olms", { - olmId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: text("version"), - agent: text("agent"), - name: varchar("name"), - clientId: integer("clientId").references(() => clients.clientId, { - // we will switch this depending on the current org it wants to connect to - onDelete: "set null" - }), - userId: text("userId").references(() => users.userId, { - // optionally tied to a user and in this case delete when the user deletes - onDelete: "cascade" - }), - archived: boolean("archived").notNull().default(false) -}); +export const olms = pgTable( + "olms", + { + olmId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: text("version"), + agent: text("agent"), + name: varchar("name"), + clientId: integer("clientId").references(() => clients.clientId, { + // we will switch this depending on the current org it wants to connect to + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), + archived: boolean("archived").notNull().default(false) + }, + (t) => [index("idx_olms_clientid").on(t.clientId)] +); export const currentFingerprint = pgTable("currentFingerprint", { fingerprintId: serial("id").primaryKey(), From 6b56c00782fa9437c3da4491555d620f629239f3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Jun 2026 15:24:31 -0400 Subject: [PATCH 069/264] Pull the listing out of the queue --- server/lib/rebuildClientAssociations.ts | 8 +------- .../routers/siteResource/updateSiteResource.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8e235bc48..98c6c58f4 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -162,13 +162,7 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource, trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { +) { try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index db4d4445b..3f271d2f9 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -28,7 +28,10 @@ import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + getClientSiteResourceAccess, + rebuildClientAssociationsFromSiteResource +} from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -846,9 +849,14 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource ); - const { mergedAllClients } = - await rebuildClientAssociationsFromSiteResource( - existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below + await rebuildClientAssociationsFromSiteResource( + existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below + trx + ); + + const { sitesList, mergedAllClients, mergedAllClientIds } = + await getClientSiteResourceAccess( + existingSiteResource || updatedSiteResource, trx ); From c3820a4e7029d31454916973da02e77827dfbab4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Jun 2026 16:47:52 -0400 Subject: [PATCH 070/264] Add missing queuing --- server/lib/rebuildClientAssociations.ts | 75 ++++++++++------- server/private/routers/ws/ws.ts | 104 ++++++++++++++++-------- 2 files changed, 118 insertions(+), 61 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 98c6c58f4..7f271bbe5 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -873,6 +873,20 @@ async function handleSubnetProxyTargetUpdates( ): Promise { const proxyJobs: Promise[] = []; const olmJobs: Promise[] = []; + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; for (const siteData of sitesList) { const siteId = siteData.siteId; @@ -904,15 +918,11 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToAdd) { - proxyJobs.push( - addSubnetProxyTargetsBatch([ - { - newtId: newt.newtId, - targets: targetsToAdd, - version: newt.version - } - ]) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targetsToAdd, + version: newt.version + }); } olmJobs.push( @@ -945,15 +955,11 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToRemove) { - proxyJobs.push( - removeSubnetProxyTargetsBatch([ - { - newtId: newt.newtId, - targets: targetsToRemove, - version: newt.version - } - ]) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targetsToRemove, + version: newt.version + }); } const peerDataRemovals: { @@ -1025,7 +1031,15 @@ async function handleSubnetProxyTargetUpdates( } } - await Promise.all(proxyJobs); + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch)); + } + + await Promise.all([...proxyJobs, ...olmJobs]); } export async function rebuildClientAssociationsFromClient( @@ -2048,19 +2062,20 @@ export async function cleanupSiteAssociations( // 7. Fire all removal messages in parallel. const jobs: Promise[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + }[] = []; for (const client of allClients) { // Tell each olm to drop the site's WireGuard peer. if (site.publicKey) { - jobs.push( - olmDeletePeersBatch([ - { - clientId: client.clientId, - siteId, - publicKey: site.publicKey - } - ]) - ); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey + }); } // Recompute and push updated relay destinations (now excluding this site). @@ -2069,6 +2084,10 @@ export async function cleanupSiteAssociations( } } + if (olmPeerDeletes.length > 0) { + jobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + await Promise.all(jobs).catch((error) => { logger.error( `cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`, diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 8d222fd72..5e38c709e 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -659,38 +659,52 @@ const broadcastToAllExceptLocal = async ( excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { - for (const [mapKey, clients] of connectedClients.entries()) { - const [type, id] = mapKey.split(":"); - const clientId = mapKey; // mapKey is the clientId - if (!(excludeClientId && clientId === excludeClientId)) { - // Handle config version per client - let configVersion = await getClientConfigVersion(clientId); - if (options.incrementConfigVersion) { - configVersion = await incrementClientConfigVersion(clientId); - } + const sendPlans = await Promise.all( + Array.from(connectedClients.entries()).map( + async ([mapKey, clients]) => { + const clientId = mapKey; // mapKey is the clientId + if (excludeClientId && clientId === excludeClientId) { + return null; + } - // Add config version to message - const messageWithVersion = { - ...message, - configVersion - }; + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = + await incrementClientConfigVersion(clientId); + } - if (options.compress) { - const compressed = zlib.gzipSync( - Buffer.from(JSON.stringify(messageWithVersion), "utf8") - ); - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(compressed); + return { + clients, + messageWithVersion: { + ...message, + configVersion } - }); - } else { - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(messageWithVersion)); - } - }); + }; } + ) + ); + + for (const plan of sendPlans) { + if (!plan) { + continue; + } + + if (options.compress) { + const compressed = zlib.gzipSync( + Buffer.from(JSON.stringify(plan.messageWithVersion), "utf8") + ); + plan.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + const messageString = JSON.stringify(plan.messageWithVersion); + plan.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); } } }; @@ -711,7 +725,12 @@ const sendToClient = async ( ); // Try to send locally first - const localSent = await sendToClientLocal(clientId, message, options); + const localSent = await sendToClientLocal( + clientId, + message, + options, + configVersion + ); // Only send via Redis if the client is not connected locally and Redis is enabled if (!localSent && redisManager.isRedisEnabled()) { @@ -745,15 +764,34 @@ const sendToClientsBatch = async ( } const remoteEntries: { targetClientId: string; message: WSMessage }[] = []; + const clientsWithIncrement = new Set( + entries + .filter((entry) => !!entry.options?.incrementConfigVersion) + .map((entry) => entry.clientId) + ); + const nonIncrementOnlyClientIds = Array.from( + new Set( + entries + .map((entry) => entry.clientId) + .filter((clientId) => !clientsWithIncrement.has(clientId)) + ) + ); + const stableConfigVersionByClient = new Map( + await Promise.all( + nonIncrementOnlyClientIds.map( + async (clientId) => + [clientId, await getClientConfigVersion(clientId)] as const + ) + ) + ); for (const entry of entries) { const options = entry.options || {}; const { clientId, message } = entry; - let configVersion = await getClientConfigVersion(clientId); - if (options.incrementConfigVersion) { - configVersion = await incrementClientConfigVersion(clientId); - } + const configVersion = options.incrementConfigVersion + ? await incrementClientConfigVersion(clientId) + : stableConfigVersionByClient.get(clientId); logger.debug( `sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})` From 75084028d79d461dae0354af120c7cb5751ce54d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 9 Jun 2026 19:29:00 +0200 Subject: [PATCH 071/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20queries?= =?UTF-8?q?=20that=20prefetch=201000=20users/roles=20in=20private=20resour?= =?UTF-8?q?ces=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PrivateResourceForm.tsx | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 1f7d56b18..6082fd20e 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { From 2cbc6fb128bac0b724827f4e04e9570fd7a6b36d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 20:56:24 +0200 Subject: [PATCH 072/264] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b1..b8a50a908 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = { From ffb6c64de038ecf0fba1fcf39e2085939fa104a9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:57:55 +0200 Subject: [PATCH 073/264] =?UTF-8?q?=F0=9F=92=84=20Show=20updates=20availab?= =?UTF-8?q?le=20in=20the=20frontend,=20on=20sites=20&=20user=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../clients/user/[niceId]/general/page.tsx | 55 +++++++++++++++++-- src/components/SitesTable.tsx | 23 +++++--- src/components/UserDevicesTable.tsx | 48 ++++++++++++++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b5..584a43d79 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a996..87c2d0dec 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lte( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a..5b5ac1db1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -55,6 +55,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -113,12 +116,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -333,6 +335,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +353,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -561,7 +568,7 @@ export default function SitesTable({ } return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb87..17a82dfc9 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, From 5dc3ae4c7ff5420d1ea818ababa9566149ce4717 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:58:42 +0200 Subject: [PATCH 074/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sites=20&=20client?= =?UTF-8?q?s=20should=20not=20get=20latest=20versions=20on=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 25 ----- server/routers/site/listSites.ts | 126 ----------------------- 2 files changed, 151 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 5a864f93b..e2a035929 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -420,31 +420,6 @@ export async function listUserDevices( } ); - // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW - // // Try to get the latest version, but don't block if it fails - // try { - // const latestOlmVersion = await getLatestOlmVersion(); - - // if (latestOlmVersion) { - // olmsWithUpdates.forEach((client) => { - // try { - // client.olmUpdateAvailable = semver.lt( - // client.olmVersion ? client.olmVersion : "", - // latestOlmVersion - // ); - // } catch (error) { - // client.olmUpdateAvailable = false; - // } - // }); - // } - // } catch (error) { - // // Log the error but don't let it block the response - // logger.warn( - // "Failed to check for OLM updates, continuing without update info:", - // error - // ); - // } - return response(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 6bb095030..c9635b346 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -29,97 +29,6 @@ import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} - const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); @@ -447,11 +356,6 @@ export async function listSites( siteListQuery ]); - const totalCount = Number(countRows[0]?.count ?? 0); - - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -494,36 +398,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); From 3f2bb422213eb7119428759e1327dd5cfaa941ac Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 19:55:48 +0200 Subject: [PATCH 075/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`lt`=20instead=20o?= =?UTF-8?q?f=20`lte`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 87c2d0dec..8573e8199 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -195,7 +195,7 @@ export default function GeneralPage() { if (agent in latestPlatformVersions) { const agentVersion = latestPlatformVersions[agent]; - updateAvailable = semver.lte( + updateAvailable = semver.lt( client.olmVersion, agentVersion.latestVersion ); From be888c3fc1a9c329a88198d6a262a66468c836ee Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 20:58:23 +0200 Subject: [PATCH 076/264] =?UTF-8?q?=F0=9F=92=84=20Show=20the=20latest=20ne?= =?UTF-8?q?w=20update=20in=20machine=20client=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c9762..3094348e3 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } From cfb63f9742be20d0569e173790205d127447d595 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:04:39 +0000 Subject: [PATCH 077/264] Bump js-yaml from 4.1.1 to 4.2.0 Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.2.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 18 ++++++++++++++---- package.json | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bda4403a6..4b7b7f81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "input-otp": "1.4.2", "ioredis": "5.11.0", "jmespath": "0.16.0", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", @@ -13491,9 +13491,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index a0637aaa5..02ba1b894 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "input-otp": "1.4.2", "ioredis": "5.11.0", "jmespath": "0.16.0", - "js-yaml": "4.1.1", + "js-yaml": "4.2.0", "jsonwebtoken": "9.0.3", "lucide-react": "1.17.0", "maxmind": "5.0.6", From 37eaf34e4d3ca21fc6c8f7037529eae1e483c429 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:20 -0700 Subject: [PATCH 078/264] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index c2abdbe02..78ed0faa8 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", "loadingDNSRecords": "Chargement des enregistrements DNS...", "olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Paramètres du protocole proxy", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", From d8acccbde44ebb8ba27b946254bf837698187efd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:22 -0700 Subject: [PATCH 079/264] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/es-ES.json b/messages/es-ES.json index 19a6ab6e1..ec22f9b0e 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Falta el ID de organización o dominio", "loadingDNSRecords": "Cargando registros DNS...", "olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Cliente", "proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", From 18ec6c8d926fde0e6f42b06a01753b3eb9d6c808 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:24 -0700 Subject: [PATCH 080/264] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index af2c2eca3..2e0aea8ca 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", From 63c3ee623bdba5b3f470c5985eb2b591e1b9862c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:26 -0700 Subject: [PATCH 081/264] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 581f37762..d81927977 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Chybí ID organizace nebo domény", "loadingDNSRecords": "Načítání DNS záznamů...", "olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", From b07fe6d18b8048401097fc25c1a19514a0cb8a84 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:28 -0700 Subject: [PATCH 082/264] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/de-DE.json b/messages/de-DE.json index 87afc5d68..4e4ac9161 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "loadingDNSRecords": "Lade DNS-Einträge...", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", From 1262030abb278f79a6b3c978c006790155825f37 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:30 -0700 Subject: [PATCH 083/264] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/it-IT.json b/messages/it-IT.json index 7623e8f64..2a6a65ac3 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", "loadingDNSRecords": "Caricamento record DNS...", "olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", From 228efacfe0ad43b20d9acd340a2ce71aa1f945a7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:32 -0700 Subject: [PATCH 084/264] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f3829dd16..d57050693 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", From c58968536d1626f3e067e283f8053ebb90c8e295 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:34 -0700 Subject: [PATCH 085/264] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 642423454..bdb22ef52 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", "loadingDNSRecords": "DNS-records laden...", "olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klant", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", From 74ef844e27715029c19109bdf273408edde4e95a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:36 -0700 Subject: [PATCH 086/264] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 3de2fe165..1d553fa3b 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", "loadingDNSRecords": "Ładowanie rekordów DNS...", "olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klient", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", From 73eb07de7111cc2fc72064a25cfa62e0f0f54f4d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:38 -0700 Subject: [PATCH 087/264] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 78105f403..345a21b24 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "ID da organização ou domínio está faltando", "loadingDNSRecords": "Carregando registros DNS...", "olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", From 096940a1529b1758f1c787cf1a6ec1ad1568913c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:40 -0700 Subject: [PATCH 088/264] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index cacc4b895..851f8153c 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", From b2778a2c4978eb38c368c642e37254fb3d2ab0cf Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:42 -0700 Subject: [PATCH 089/264] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index d5fd66262..f04b80159 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", "loadingDNSRecords": "DNS kayıtları yükleniyor...", "olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", From f7050ef9891e7a77a8b210bc472a2190e0a52a7c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:44 -0700 Subject: [PATCH 090/264] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 119 ++++++++++++++++++++++---------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index e25c82e52..906cfc96e 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -17,7 +17,7 @@ "componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。", "componentsErrorNoMember": "您目前不是任何组织的成员。", "welcome": "欢迎使用 Pangolin", - "welcomeTo": "欢迎来到", + "welcomeTo": "欢迎使用", "componentsCreateOrg": "创建组织", "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", @@ -35,7 +35,7 @@ "trialDaysRemaining": "{count, plural, other {# 天剩余}}", "trialDaysLeftShort": "试用期剩余 {days} 天", "trialGoToBilling": "转到账单页面", - "subscriptionViolationViewBilling": "查看计费", + "subscriptionViolationViewBilling": "查看账单", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", "inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。", @@ -58,21 +58,21 @@ "name": "名称", "online": "在线", "offline": "离线的", - "site": "站点", + "site": "节点", "dataIn": "数据输入", "dataOut": "数据输出", "connectionType": "连接类型", "tunnelType": "隧道类型", "local": "本地的", "edit": "编辑", - "siteConfirmDelete": "确认删除站点", - "siteDelete": "删除站点", - "siteMessageRemove": "一旦移除,站点将无法访问。与站点相关的所有目标也将被移除。", - "siteQuestionRemove": "您确定要从组织中删除该站点吗?", + "siteConfirmDelete": "确认删除节点", + "siteDelete": "删除节点", + "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", + "siteQuestionRemove": "您确定要从组织中删除该节点吗?", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", - "sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。", + "sitesBannerDescription": "站点是到远程网络的连接,使 Pangolin 能够向任何位置的用户提提供公共或私有的资源访问。你可以在任何能够运行二进制文件或容器的地方安装站点网络连接器(Newt),以建立连接。", "sitesBannerButtonText": "安装站点", "approvalsBannerTitle": "批准或拒绝设备访问", "approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。", @@ -134,7 +134,7 @@ "siteResourcesHowToAccess": "如何访问", "siteResourcesTargetsOnSite": "此站点上的目标", "siteSetting": "{siteName} 设置", - "siteNewtTunnel": "新站点 (推荐)", + "siteNewtTunnel": "新节点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", "siteWg": "基本 WireGuard", "siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。", @@ -143,23 +143,23 @@ "siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。", "siteSeeAll": "查看所有站点", "siteTunnelDescription": "确定如何连接到站点", - "siteNewtCredentials": "全权证书", - "siteNewtCredentialsDescription": "站点如何通过服务器进行身份验证", + "siteNewtCredentials": "凭证", + "siteNewtCredentialsDescription": "节点如何与服务器进行身份验证", "remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证", "siteCredentialsSave": "保存证书", "siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。", "siteInfo": "站点信息", "status": "状态", - "shareTitle": "管理可共享链接", + "shareTitle": "管理共享链接", "shareDescription": "创建可共享的链接,允许临时或永久访问代理资源", - "shareSearch": "搜索可共享链接……", - "shareCreate": "创建可共享链接", + "shareSearch": "搜索共享链接……", + "shareCreate": "创建共享链接", "shareErrorDelete": "删除链接失败", "shareErrorDeleteMessage": "删除链接时出错", "shareDeleted": "链接已删除", "shareDeletedDescription": "链接已删除", - "shareDelete": "删除可共享链接", - "shareDeleteConfirm": "确认删除可共享链接", + "shareDelete": "删除共享链接", + "shareDeleteConfirm": "确认删除共享链接", "shareQuestionRemove": "您确定要删除这个共享链接吗?", "shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。", "shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。", @@ -204,11 +204,11 @@ "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", "publicResourcesBannerTitle": "基于 Web 的公共访问", - "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", + "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", - "privateResourcesBannerTitle": "零信任的私人访问", - "privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。", + "privateResourcesBannerTitle": "零信任私有访问", + "privateResourcesBannerDescription": "私有资源采用零信任安全机制,确保只有获得明确授权的用户和机器才能访问。用户设备或机器客户端连接后,即可通过安全的虚拟专用网络访问这些资源。", "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", @@ -327,7 +327,7 @@ "passToAuth": "传递至认证", "orgSettingsDescription": "配置组织设置", "orgGeneralSettings": "组织设置", - "orgGeneralSettingsDescription": "管理机构的详细信息和配置", + "orgGeneralSettingsDescription": "管理组织的详细信息和配置", "saveGeneralSettings": "保存常规设置", "saveSettings": "保存设置", "orgDangerZone": "危险区域", @@ -381,7 +381,7 @@ "accessApprovalsDescription": "查看和管理待审批的组织访问权限", "description": "描述", "inviteTitle": "打开邀请", - "inviteDescription": "管理其他用户加入机构的邀请", + "inviteDescription": "管理其他用户加入组织的邀请", "inviteSearch": "搜索邀请...", "minutes": "分钟", "hours": "小时", @@ -425,24 +425,24 @@ "apiKeysDelete": "删除 API 密钥", "apiKeysManage": "管理 API 密钥", "apiKeysDescription": "API 密钥用于认证集成 API", - "provisioningKeysTitle": "置备密钥", - "provisioningKeysManage": "管理置备键", + "provisioningKeysTitle": "预配密钥", + "provisioningKeysManage": "管理预配密钥", "provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。", - "provisioningManage": "置备中", - "provisioningDescription": "管理预配键和审查等待批准的站点。", - "pendingSites": "待定站点", + "provisioningManage": "预配", + "provisioningDescription": "管理预配密钥,并审核待批准的站点。", + "pendingSites": "待审批站点", "siteApproveSuccess": "站点批准成功", "siteApproveError": "批准站点出错", "provisioningKeys": "置备键", "searchProvisioningKeys": "搜索配备密钥...", - "provisioningKeysAdd": "生成置备键", + "provisioningKeysAdd": "生成预配密钥", "provisioningKeysErrorDelete": "删除预配键时出错", "provisioningKeysErrorDeleteMessage": "删除预配键时出错", "provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?", "provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。", "provisioningKeysDeleteConfirm": "确认删除置备键", "provisioningKeysDelete": "删除置备键", - "provisioningKeysCreate": "生成置备键", + "provisioningKeysCreate": "生成预配密钥", "provisioningKeysCreateDescription": "为组织生成一个新的预置密钥", "provisioningKeysSeeAll": "查看所有预配键", "provisioningKeysSave": "保存预配键", @@ -462,16 +462,16 @@ "provisioningKeysNeverUsed": "永不过期", "provisioningKeysEdit": "编辑置备键", "provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。", - "provisioningKeysApproveNewSites": "批准新站点", - "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的站点。", + "provisioningKeysApproveNewSites": "批准新节点", + "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的节点。", "provisioningKeysUpdateError": "更新预配键时出错", "provisioningKeysUpdated": "置备密钥已更新", "provisioningKeysUpdatedDescription": "您的更改已保存。", - "provisioningKeysBannerTitle": "站点置备密钥", - "provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。", + "provisioningKeysBannerTitle": "站点预配密钥", + "provisioningKeysBannerDescription": "生成预配密钥,并将其与 Newt 连接器配合使用,即可在首次启动时自动创建站点,无需为每个站点单独配置凭据。", "provisioningKeysBannerButtonText": "了解更多", - "pendingSitesBannerTitle": "待定站点", - "pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。", + "pendingSitesBannerTitle": "待审批站点", + "pendingSitesBannerDescription": "使用预配密钥连接的网站会在这里以供审核。", "pendingSitesBannerButtonText": "了解更多", "apiKeysSettings": "{apiKeyName} 设置", "userTitle": "管理所有用户", @@ -883,11 +883,11 @@ "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", "accessControl": "访问控制", - "shareLink": "{resource} 可共享链接", + "shareLink": "{resource} 的共享链接", "resourceSelect": "选择资源", - "shareLinks": "可共享链接", + "shareLinks": "共享链接", "share": "分享链接", - "shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", + "shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", "shareEasyCreate": "轻松创建和分享", "shareConfigurableExpirationDuration": "可配置的过期时间", "shareSecureAndRevocable": "安全和可撤销的", @@ -1059,7 +1059,7 @@ "network": "网络", "manage": "管理", "sitesNotFound": "未找到站点。", - "pangolinServerAdmin": "服务器管理员 - Pangolin", + "pangolinServerAdmin": "服务器管理 - Pangolin", "licenseTierProfessional": "专业许可证", "licenseTierEnterprise": "企业许可证", "licenseTierPersonal": "个人许可证", @@ -1366,7 +1366,7 @@ "supportKeyBuy": "购买支持者密钥", "logoutError": "注销错误", "signingAs": "登录为", - "serverAdmin": "服务器管理员", + "serverAdmin": "服务器管理", "managedSelfhosted": "托管自托管", "otpEnable": "启用双因子认证", "otpDisable": "禁用双因子认证", @@ -1536,8 +1536,8 @@ "sidebarSites": "站点", "sidebarApprovals": "审批请求", "sidebarResources": "资源", - "sidebarProxyResources": "公开的", - "sidebarClientResources": "非公开的", + "sidebarProxyResources": "公开资源", + "sidebarClientResources": "私有资源", "sidebarPolicies": "共享策略", "sidebarResourcePolicies": "公共资源", "sidebarAccessControl": "访问控制", @@ -1547,17 +1547,17 @@ "sidebarAdmin": "管理员", "sidebarInvitations": "邀请", "sidebarRoles": "角色", - "sidebarShareableLinks": "可共享链接", + "sidebarShareableLinks": "共享链接", "sidebarApiKeys": "API密钥", - "sidebarProvisioning": "置备中", + "sidebarProvisioning": "预配", "sidebarSettings": "设置", "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", "sidebarClients": "客户端", "sidebarUserDevices": "用户设备", - "sidebarMachineClients": "机", - "sidebarDomains": "域", + "sidebarMachineClients": "机器身份", + "sidebarDomains": "域名", "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", @@ -1689,8 +1689,8 @@ "alertingTabHealthChecks": "健康检查", "alertingRulesBannerTitle": "获取通知", "alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。", - "alertingHealthChecksBannerTitle": "监视健康和资源", - "alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。", + "alertingHealthChecksBannerTitle": "资源与健康监控", + "alertingHealthChecksBannerDescription": "通过 HTTP 或 TCP 检查目标状态,并在服务异常或恢复时发送通知。资源中配置的健康检查也会显示在这里。", "standaloneHcTableTitle": "健康检查", "standaloneHcSearchPlaceholder": "搜索健康检查…", "standaloneHcAddButton": "创建健康检查", @@ -1791,17 +1791,17 @@ "theme": "主题", "subnetRequired": "子网是必填项", "initialSetupTitle": "初始服务器设置", - "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", + "initialSetupDescription": "创建初始的管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", - "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", + "setupErrorCreateAdmin": "创建管理员账户时发生错误。", "certificateStatus": "证书", "certificateStatusAutoRefreshHint": "状态自动刷新。", "loading": "加载中", "loadingEllipsis": "加载中……", "loadingAnalytics": "加载分析", "restart": "重启", - "domains": "域", - "domainsDescription": "创建和管理组织中可用的域", + "domains": "域名", + "domainsDescription": "创建和管理组织中可用的域名", "domainsSearch": "搜索域...", "domainAdd": "添加域", "domainAddDescription": "注册一个新域名到组织", @@ -2165,12 +2165,12 @@ "sshSudoMode": "Sudo 访问", "sshSudoModeNone": "无", "sshSudoModeNoneDescription": "用户不能用sudo运行命令。", - "sshSudoModeFull": "全苏多", + "sshSudoModeFull": "完整 Sudo 权限", "sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。", "sshSudoModeCommands": "命令", "sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。", "sshSudo": "允许Sudo", - "sshSudoCommands": "Sudo 命令", + "sshSudoCommands": "可用 Sudo 命令", "sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。", "sshCreateHomeDir": "创建主目录", "sshUnixGroups": "Unix 组", @@ -2183,7 +2183,7 @@ "roleTextImportAppend": "附加到现有", "roleTextImportMode": "导入模式", "roleTextImportPreview": "预览", - "roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}", + "roleTextImportItemCount": "{count, plural, =0 {没有可导入的项目} one {1 个可导入项目} other {# 个可导入项目}}", "roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计", "roleTextImportConfirm": "导入", "roleTextImportInvalidFile": "不支持的文件类型", @@ -2235,8 +2235,8 @@ "resourceEditDomain": "编辑域名", "siteName": "站点名称", "proxyPort": "端口", - "resourcesTableProxyResources": "公开的", - "resourcesTableClientResources": "非公开的", + "resourcesTableProxyResources": "", + "resourcesTableClientResources": "私有资源", "resourcesTableNoProxyResourcesFound": "未找到代理资源。", "resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableDestination": "目标", @@ -2925,7 +2925,7 @@ "logRetentionRequestDescription": "保留请求日志的时间", "logRetentionAccessLabel": "访问日志保留", "logRetentionAccessDescription": "保留访问日志的时间", - "logRetentionActionLabel": "动作日志保留", + "logRetentionActionLabel": "审计日志保留", "logRetentionActionDescription": "保留操作日志的时间", "logRetentionConnectionLabel": "连接日志保留", "logRetentionConnectionDescription": "保留连接日志的时间", @@ -2938,11 +2938,11 @@ "logRetentionForever": "永远的", "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", - "accessLogsDescription": "查看此机构资源的访问认证请求", + "accessLogsDescription": "查看此组织资源的访问认证请求", "connectionLogs": "连接日志", "connectionLogsDescription": "查看此机构隧道的连接日志", "sidebarLogsConnection": "连接日志", - "sidebarLogsStreaming": "流流", + "sidebarLogsStreaming": "事件流", "sourceAddress": "源地址", "destinationAddress": "目的地址", "duration": "期限", @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", From babd90ae719ebd2b94ed4b538134b8439a07b1b4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:46 -0700 Subject: [PATCH 091/264] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 52ddf8e33..1660e12a4 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", "loadingDNSRecords": "Laster DNS-poster...", "olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klient", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", From 8004ae68705268842200ad8e34ff25a91c175e4e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 11:25:09 -0400 Subject: [PATCH 092/264] Use the policy when updating rule Fixes #3273 --- server/routers/resource/updateResourceRule.ts | 157 ++++++++++++++---- 1 file changed, 124 insertions(+), 33 deletions(-) diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index cc2a6fc03..84afb38b6 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { resourcePolicyRules, resourceRules, resources } 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"; @@ -22,13 +22,20 @@ const updateResourceRuleParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const resourceRuleMatchSchema = z.enum([ + "CIDR", + "IP", + "PATH", + "COUNTRY", + "ASN", + "REGION" +]); + // Define Zod schema for request body validation const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z - .enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]) - .optional(), + match: resourceRuleMatchSchema.optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() @@ -123,37 +130,102 @@ export async function updateResourceRule( return next( createHttpError( HttpCode.BAD_REQUEST, - "Cannot create rule for non-http resource" + "Cannot update rule for non-http resource" ) ); } - // Verify that the rule exists and belongs to the specified resource - const [existingRule] = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.ruleId, ruleId)) - .limit(1); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (!existingRule) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource rule with ID ${ruleId} not found` - ) + let existingMatch: + | "CIDR" + | "IP" + | "PATH" + | "COUNTRY" + | "ASN" + | "REGION"; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const [existingRule] = await db + .select() + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourcePolicyId !== policyId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match ); + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; + } else { + // Verify that the rule exists and belongs to the specified resource + const [existingRule] = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match + ); + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; } - if (existingRule.resourceId !== resourceId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `Resource rule ${ruleId} does not belong to resource ${resourceId}` - ) - ); - } - - const match = updateData.match || existingRule.match; + const match = updateData.match || existingMatch; const { value } = updateData; if (value !== undefined) { @@ -197,11 +269,30 @@ export async function updateResourceRule( } // Update the rule - const [updatedRule] = await db - .update(resourceRules) - .set(updateData) - .where(eq(resourceRules.ruleId, ruleId)) - .returning(); + const [updatedRule] = isInlinePolicy + ? await db + .update(resourcePolicyRules) + .set(updateData) + .where( + and( + eq(resourcePolicyRules.ruleId, ruleId), + eq( + resourcePolicyRules.resourcePolicyId, + resource.defaultResourcePolicyId! + ) + ) + ) + .returning() + : await db + .update(resourceRules) + .set(updateData) + .where( + and( + eq(resourceRules.ruleId, ruleId), + eq(resourceRules.resourceId, resourceId) + ) + ) + .returning(); return response(res, { data: updatedRule, From ce3c2f7583806cc65928d93a26e66378c0f5429a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 12:05:47 -0400 Subject: [PATCH 093/264] Fix #3314 --- .../routers/healthChecks/createHealthCheck.ts | 59 ++++++++----- .../routers/healthChecks/updateHealthCheck.ts | 31 ++++++- server/routers/target/createTarget.ts | 88 +++++++++++-------- server/routers/target/updateTarget.ts | 47 ++++++---- 4 files changed, 151 insertions(+), 74 deletions(-) diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index aa3706833..6f49f0f18 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -29,26 +29,40 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - siteId: z.number().int().positive(), - hcEnabled: z.boolean().default(false), - hcMode: z.string().default("http"), - hcHostname: z.string().optional(), - hcPort: z.number().int().min(1).max(65535).optional(), - hcPath: z.string().optional(), - hcScheme: z.string().optional(), - hcMethod: z.string().default("GET"), - hcInterval: z.number().int().positive().default(30), - hcUnhealthyInterval: z.number().int().positive().default(30), - hcTimeout: z.number().int().positive().default(1), - hcHeaders: z.string().optional().nullable(), - hcFollowRedirects: z.boolean().default(true), - hcStatus: z.number().int().optional().nullable(), - hcTlsServerName: z.string().optional(), - hcHealthyThreshold: z.number().int().positive().default(1), - hcUnhealthyThreshold: z.number().int().positive().default(1) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + siteId: z.number().int().positive(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(1), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateHealthCheckResponse = { targetHealthCheckId: number; @@ -57,7 +71,6 @@ const CreateHealthCheckResponseDataSchema = z.object({ targetHealthCheckId: z.number() }); - registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", @@ -78,7 +91,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + CreateHealthCheckResponseDataSchema + ) } } } diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index f08324f9b..4fb7a624b 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -105,7 +105,6 @@ const UpdateHealthCheckResponseDataSchema = z.object({ hcUnhealthyThreshold: z.number().nullable() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", @@ -126,7 +125,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(UpdateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + UpdateHealthCheckResponseDataSchema + ) } } } @@ -215,6 +216,32 @@ export async function updateHealthCheck( ) .limit(1); + if (!existingHealthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const nextHcEnabled = hcEnabled ?? existingHealthCheck.hcEnabled; + const nextHcHostname = + hcHostname !== undefined + ? hcHostname + : existingHealthCheck.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + if (name !== undefined) updateData.name = name; if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 2b3f472e8..289f47c76 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -33,41 +33,59 @@ const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const createTargetSchema = z.strictObject({ - siteId: z.int().positive(), - ip: z.string().refine(isTargetValid), - mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), - method: z.string().optional().nullable(), - port: z.int().min(1).max(65535), - enabled: z.boolean().default(true), - hcEnabled: z.boolean().optional(), - hcPath: z.string().min(1).optional().nullable(), - hcScheme: z.string().optional().nullable(), - hcMode: z.string().optional().nullable(), - hcHostname: z.string().optional().nullable(), - hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(1).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), - hcTimeout: z.int().positive().min(1).optional().nullable(), - hcHeaders: z - .array(z.strictObject({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcFollowRedirects: z.boolean().optional().nullable(), - hcMethod: z.string().min(1).optional().nullable(), - hcStatus: z.int().optional().nullable(), - hcTlsServerName: z.string().optional().nullable(), - hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), - hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), - path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional().nullable() -}); +const createTargetSchema = z + .strictObject({ + siteId: z.int().positive(), + ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), + method: z.string().optional().nullable(), + port: z.int().min(1).max(65535), + enabled: z.boolean().default(true), + hcEnabled: z.boolean().optional(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.int().positive().optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), + hcTimeout: z.int().positive().min(1).optional().nullable(), + hcHeaders: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.int().optional().nullable(), + hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z.int().min(1).max(1000).optional().nullable() + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname === null || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateTargetResponse = Target & TargetHealthCheck; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1bed7b982..52bf3e578 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,38 @@ export async function updateTarget( ); } + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + + const nextHcEnabled = parsedBody.data.hcEnabled ?? existingHc.hcEnabled; + const nextHcHostname = + parsedBody.data.hcHostname !== undefined + ? parsedBody.data.hcHostname + : existingHc.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const nextMode = parsedBody.data.mode === null ? undefined : parsedBody.data.mode; @@ -218,21 +250,6 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); - const [existingHc] = await trx - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); - - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } - let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); From f48a4f7bc0a9c09c8377e15b9b759c1332dd1770 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 12:22:47 -0400 Subject: [PATCH 094/264] Enforce strick query params Fixes #3313 --- server/routers/accessToken/listAccessTokens.ts | 2 +- server/routers/apiKeys/listApiKeyActions.ts | 2 +- server/routers/apiKeys/listOrgApiKeys.ts | 2 +- server/routers/apiKeys/listRootApiKeys.ts | 2 +- server/routers/auditLogs/queryRequestAuditLog.ts | 2 +- server/routers/client/listClients.ts | 2 +- server/routers/client/listUserDevices.ts | 2 +- server/routers/olm/listUserOlms.ts | 2 +- server/routers/org/listOrgs.ts | 2 +- server/routers/org/listUserOrgs.ts | 2 +- server/routers/resource/listResourceRules.ts | 2 +- server/routers/resource/listResources.ts | 2 +- server/routers/resource/listUserResourceAliases.ts | 2 +- server/routers/role/listRoles.ts | 2 +- server/routers/site/listSites.ts | 2 +- server/routers/siteResource/listAllSiteResourcesByOrg.ts | 2 +- server/routers/siteResource/listSiteResources.ts | 2 +- server/routers/target/listTargets.ts | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 0339cc2c4..472d9da40 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -30,7 +30,7 @@ const listAccessTokensParamsSchema = z error: "Either resourceId or orgId must be provided, but not both" }); -const listAccessTokensSchema = z.object({ +const listAccessTokensSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts index 364f3aee2..3b5efa8b5 100644 --- a/server/routers/apiKeys/listApiKeyActions.ts +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -15,7 +15,7 @@ const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts index ba87a3033..68a7f9a25 100644 --- a/server/routers/apiKeys/listOrgApiKeys.ts +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -11,7 +11,7 @@ import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts index 654b830a6..434ff5a8b 100644 --- a/server/routers/apiKeys/listRootApiKeys.ts +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index f14c28cf1..7f4a0ec16 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -20,7 +20,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; -export const queryAccessAuditLogsQuery = z.object({ +export const queryAccessAuditLogsQuery = z.strictObject({ // iso string just validate its a parseable date timeStart: z .string() diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 9178c27a5..98c0fc550 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({ orgId: z.string() }); -const listClientsSchema = z.object({ +const listClientsSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index e2a035929..fb3004921 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserDevicesSchema = z.object({ +const listUserDevicesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index b2db262e6..5549afc9f 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -11,7 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index 336592fd5..88c05f61a 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -11,7 +11,7 @@ import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index c48f2fa91..47d540930 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -14,7 +14,7 @@ const listOrgsParamsSchema = z.object({ userId: z.string() }); -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 6b9df688a..ec4cc332e 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -14,7 +14,7 @@ const listResourceRulesParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listResourceRulesSchema = z.object({ +const listResourceRulesSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 684c48159..f15a3beda 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -48,7 +48,7 @@ const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listResourcesSchema = z.object({ +const listResourcesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 205c029f0..75dc91166 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -32,7 +32,7 @@ const listUserResourceAliasesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.object({ +const listUserResourceAliasesQuerySchema = z.strictObject({ pageSize: z.coerce .number() .int() diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 248db5063..d59ced2f7 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -15,7 +15,7 @@ const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); -const listRolesSchema = z.object({ +const listRolesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4ca28eda9..86c555f93 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -32,7 +32,7 @@ const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSitesSchema = z.object({ +const listSitesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 5c20bc5a7..721d76bf6 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -26,7 +26,7 @@ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() }); -const listAllSiteResourcesByOrgQuerySchema = z.object({ +const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 311009dfa..a9688a9c6 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -15,7 +15,7 @@ const listSiteResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSiteResourcesQuerySchema = z.object({ +const listSiteResourcesQuerySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index b097b1f6e..68f80197a 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -14,7 +14,7 @@ const listTargetsParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listTargetsSchema = z.object({ +const listTargetsSchema = z.strictObject({ limit: z .string() .optional() From d78223b94fa3911ec11e525420d7dc0e4caf1e00 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 14:52:37 -0400 Subject: [PATCH 095/264] Fix import to be private --- server/private/lib/rebuildQueue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index 2cd1dadc0..01082cc50 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -12,7 +12,7 @@ */ import { redis } from "#private/lib/redis"; -import { lockManager } from "#dynamic/lib/lock"; +import { lockManager } from "#private/lib/lock"; import logger from "@server/logger"; export type RebuildJobType = "site-resource" | "client"; From a9b7cce49bf9b48dbf7b3560924c6de66dc72909 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 15:41:38 -0400 Subject: [PATCH 096/264] Improve efficiency of calculateUserClientsForOrgs --- server/lib/calculateUserClientsForOrgs.ts | 75 ++++++++++++++----- .../routers/orgIdp/unassociateOrgIdp.ts | 2 +- server/routers/auth/deleteMyAccount.ts | 2 +- server/routers/idp/validateOidcCallback.ts | 2 +- server/routers/olm/createUserOlm.ts | 2 +- server/routers/org/createOrg.ts | 14 +++- server/routers/user/acceptInvite.ts | 12 ++- server/routers/user/adminRemoveUser.ts | 2 +- server/routers/user/createOrgUser.ts | 17 ++--- server/routers/user/removeUserOrg.ts | 2 +- 10 files changed, 84 insertions(+), 46 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 090bf4d8c..fe09539a9 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -6,6 +6,7 @@ import { db, olms, orgs, + primaryDb, roleClients, roles, Transaction, @@ -23,10 +24,44 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations import { OlmErrorCodes } from "@server/routers/olm/error"; import { tierMatrix } from "./billing/tierMatrix"; -export async function calculateUserClientsForOrgs( +type ClientRow = typeof clients.$inferSelect; + +function runQueuedClientAssociationRebuilds( userId: string, - trx: Transaction | typeof db = db + queuedClients: ClientRow[] +): void { + if (queuedClients.length === 0) { + return; + } + + const uniqueClientsById = new Map(); + for (const client of queuedClients) { + uniqueClientsById.set(client.clientId, client); + } + + void (async () => { + for (const client of uniqueClientsById.values()) { + try { + await rebuildClientAssociationsFromClient(client, db); + } catch (error) { + logger.error( + `Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` + ); + } + } + + logger.debug( + `Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})` + ); + })(); +} + +export async function calculateUserClientsForOrgs( + userId: string ): Promise { + const trx = primaryDb; + const queuedAssociationRebuilds: ClientRow[] = []; + const execute = async (transaction: Transaction | typeof db) => { const orgCache = new Map(); const adminRoleCache = new Map< @@ -189,7 +224,12 @@ export async function calculateUserClientsForOrgs( if (userOlms.length === 0) { // No OLMs for this user, but we should still clean up any orphaned clients - await cleanupOrphanedClients(userId, transaction); + await cleanupOrphanedClients( + userId, + transaction, + [], + queuedAssociationRebuilds + ); return; } @@ -382,10 +422,7 @@ export async function calculateUserClientsForOrgs( .returning(); } - await rebuildClientAssociationsFromClient( - newClient, - transaction - ); + queuedAssociationRebuilds.push(newClient); // Grant admin role access to the client await transaction.insert(roleClients).values({ @@ -414,24 +451,22 @@ export async function calculateUserClientsForOrgs( } // Clean up clients in orgs the user is no longer in - await cleanupOrphanedClients(userId, transaction, userOrgIds); + await cleanupOrphanedClients( + userId, + transaction, + userOrgIds, + queuedAssociationRebuilds + ); }; - if (trx) { - // Use provided transaction - await execute(trx); - } else { - // Create new transaction - await db.transaction(async (transaction) => { - await execute(transaction); - }); - } + runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds); } async function cleanupOrphanedClients( userId: string, trx: Transaction | typeof db, - userOrgIds: string[] = [] + userOrgIds: string[] = [], + queuedAssociationRebuilds: ClientRow[] = [] ): Promise { // Find all OLM clients for this user that should be deleted // If userOrgIds is empty, delete all OLM clients (user has no orgs) @@ -461,9 +496,9 @@ async function cleanupOrphanedClients( ) .returning(); - // Rebuild associations for each deleted client to clean up related data + // Queue deleted clients for post-transaction association cleanup. for (const deletedClient of deletedClients) { - await rebuildClientAssociationsFromClient(deletedClient, trx); + queuedAssociationRebuilds.push(deletedClient); if (deletedClient.olmId) { await sendTerminateClient( diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index 41b2e6c89..0b5c1ed51 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -121,7 +121,7 @@ export async function unassociateOrgIdp( }); for (const userId of userIdsToRemove) { - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}` ); diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index d03af5631..d45486946 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -224,7 +224,7 @@ export async function deleteMyAccount( } }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after deleting account for user ${userId}: ${e}` ); diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 6a82f2c12..f6cb656b3 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -635,7 +635,7 @@ export async function validateOidcCallback( } }); - calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => { + calculateUserClientsForOrgs(userId!).catch((err) => { logger.error( "Error calculating user clients after syncing orgs and roles for OIDC user", { error: err } diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts index 714fb4b35..306317a0c 100644 --- a/server/routers/olm/createUserOlm.ts +++ b/server/routers/olm/createUserOlm.ts @@ -104,7 +104,7 @@ export async function createUserOlm( dateCreated: moment().toISOString() }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { console.error( "Error calculating user clients after creating olm:", e diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 7b2b1f87a..35466ebc0 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, primaryDb } from "@server/db"; import { and, count, eq } from "drizzle-orm"; import { domains, @@ -233,6 +233,7 @@ export async function createOrg( let error = ""; let org: Org | null = null; let numOrgs: number | null = null; + let ownerUserId: string | null = null; await db.transaction(async (trx) => { const allDomains = await trx @@ -326,7 +327,6 @@ export async function createOrg( ); } - let ownerUserId: string | null = null; if (req.user) { await trx.insert(userOrgs).values({ userId: req.user!.userId, @@ -382,8 +382,6 @@ export async function createOrg( })) ); - await calculateUserClientsForOrgs(ownerUserId, trx); - if (billingOrgIdForNewOrg) { const [numOrgsResult] = await trx .select({ count: count() }) @@ -396,6 +394,14 @@ export async function createOrg( } }); + if (ownerUserId) { + calculateUserClientsForOrgs(ownerUserId).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org ${orgId} for user ${ownerUserId}: ${e}` + ); + }); + } + if (!org) { return next( createHttpError( diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index e3366a0c5..ef7ddcdbd 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -202,13 +202,11 @@ export async function acceptInvite( ); }); - calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` - ); - } - ); + calculateUserClientsForOrgs(existingUser[0].userId).catch((e) => { + logger.error( + `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` + ); + }); return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, diff --git a/server/routers/user/adminRemoveUser.ts b/server/routers/user/adminRemoveUser.ts index 38713ce26..066848b07 100644 --- a/server/routers/user/adminRemoveUser.ts +++ b/server/routers/user/adminRemoveUser.ts @@ -55,7 +55,7 @@ export async function adminRemoveUser( await trx.delete(users).where(eq(users.userId, userId)); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId}: ${e}` ); diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index f03dd763b..c6f25e085 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -56,7 +56,6 @@ const bodySchema = z export type CreateOrgUserResponse = {}; const CreateOrgUserResponseDataSchema = z.object({}); - registry.registerPath({ method: "put", path: "/org/{orgId}/user", @@ -77,7 +76,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateOrgUserResponseDataSchema) + schema: createApiResponseSchema( + CreateOrgUserResponseDataSchema + ) } } } @@ -326,13 +327,11 @@ export async function createOrgUser( }); if (userIdForClients) { - calculateUserClientsForOrgs(userIdForClients, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after creating org user: ${e}` - ); - } - ); + calculateUserClientsForOrgs(userIdForClients).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org user: ${e}` + ); + }); } } else { return next( diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 58fc85b69..902aeed84 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -109,7 +109,7 @@ export async function removeUserOrg( await removeUserFromOrg(org, userId, trx); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}` ); From c11d24e10a3b63bee2940c8f11f01045456f337d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 15:45:02 -0400 Subject: [PATCH 097/264] Standardize db rebuildClientAssociationsFromClient --- server/lib/calculateUserClientsForOrgs.ts | 2 +- server/lib/rebuildClientAssociations.ts | 6 +++--- server/private/routers/user/addUserRole.ts | 12 +++++------- server/private/routers/user/removeUserRole.ts | 12 +++++------- server/private/routers/user/setUserOrgRoles.ts | 12 +++++------- server/routers/client/createClient.ts | 12 +++++------- server/routers/client/createUserClient.ts | 12 +++++------- server/routers/client/deleteClient.ts | 12 +++++------- .../client/rebuildClientAssociationsCacheRoute.ts | 8 ++++++-- server/routers/olm/deleteUserOlm.ts | 12 +++++------- .../siteResource/batchAddClientToSiteResources.ts | 2 +- server/routers/user/addUserRoleLegacy.ts | 12 +++++------- 12 files changed, 51 insertions(+), 63 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index fe09539a9..9c6903ebc 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -42,7 +42,7 @@ function runQueuedClientAssociationRebuilds( void (async () => { for (const client of uniqueClientsById.values()) { try { - await rebuildClientAssociationsFromClient(client, db); + await rebuildClientAssociationsFromClient(client); } catch (error) { logger.error( `Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 7f271bbe5..83a03fa70 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1043,9 +1043,9 @@ async function handleSubnetProxyTargetUpdates( } export async function rebuildClientAssociationsFromClient( - client: Client, - trx: Transaction | typeof db = db + client: Client ): Promise { + const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:client:${client.clientId}`, @@ -2137,7 +2137,7 @@ export function startRebuildQueueProcessor(): void { return; } - await rebuildClientAssociationsFromClient(client, primaryDb); + await rebuildClientAssociationsFromClient(client); } }); } diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index c59a3d0f7..ce5a6dd50 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -163,13 +163,11 @@ export async function addUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index b96670815..79a5a522a 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -170,13 +170,11 @@ export async function removeUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 7790eacfb..ef6bc1b4f 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -150,13 +150,11 @@ export async function setUserOrgRoles( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index ecda098c5..bef47245d 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -280,13 +280,11 @@ export async function createClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 09bec218a..3c7d85018 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -255,13 +255,11 @@ export async function createUserClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating user client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating user client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 24ab9917a..62765c2c1 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -109,13 +109,11 @@ export async function deleteClient( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after deleting client ${clientId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after deleting client ${clientId}: ${e}` + ); + }); if (olm) { sendTerminateClient( deletedClient.clientId, diff --git a/server/routers/client/rebuildClientAssociationsCacheRoute.ts b/server/routers/client/rebuildClientAssociationsCacheRoute.ts index 32a6a407a..86cb5c485 100644 --- a/server/routers/client/rebuildClientAssociationsCacheRoute.ts +++ b/server/routers/client/rebuildClientAssociationsCacheRoute.ts @@ -60,13 +60,17 @@ export async function rebuildClientAssociationsCacheRoute( ); } - await rebuildClientAssociationsFromClient(client); + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId}: ${e}` + ); + }); return response(res, { data: null, success: true, error: false, - message: "Client association cache rebuilt successfully", + message: "Client association cache queued successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 861a413d8..addef32d9 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -86,13 +86,11 @@ export async function deleteUserOlm( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` + ); + }); sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index c8a8c90a6..1ebb3359d 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -235,7 +235,7 @@ export async function batchAddClientToSiteResources( } }); - rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => { + rebuildClientAssociationsFromClient(client).catch((e) => { logger.error( `Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}` ); diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index bef69387a..b3ff55a06 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -159,13 +159,11 @@ export async function addUserRoleLegacy( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { From 7731849a2f679f75c80c9f569882574b3c6c48fd Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:14:29 -0400 Subject: [PATCH 098/264] Standardize db rebuildClientAssociationsFromClient --- server/lib/blueprints/applyBlueprint.ts | 193 +----- server/lib/rebuildClientAssociations.ts | 9 +- .../siteResource/addClientToSiteResource.ts | 6 +- .../siteResource/addRoleToSiteResource.ts | 6 +- .../siteResource/addUserToSiteResource.ts | 6 +- .../siteResource/createSiteResource.ts | 17 +- .../siteResource/deleteSiteResource.ts | 17 +- .../removeClientFromSiteResource.ts | 6 +- .../removeRoleFromSiteResource.ts | 6 +- .../removeUserFromSiteResource.ts | 6 +- .../siteResource/setSiteResourceClients.ts | 6 +- .../siteResource/setSiteResourceRoles.ts | 6 +- .../siteResource/setSiteResourceUsers.ts | 7 +- .../siteResource/updateSiteResource.ts | 633 +++++++----------- 14 files changed, 336 insertions(+), 588 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index ab095646e..fbd6f3fb0 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -3,7 +3,6 @@ import { newts, blueprints, Blueprint, - Site, siteResources, roleSiteResources, userSiteResources, @@ -60,30 +59,26 @@ export async function applyBlueprint({ const config: Config = validationResult.data; - let proxyResourcesResults: PublicResourcesResults = []; - let clientResourcesResults: ClientResourcesResults = []; + let publicResourcesResults: PublicResourcesResults = []; + let privateResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); - proxyResourcesResults = await updatePublicResources( + publicResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updatePrivateResources( + privateResourcesResults = await updatePrivateResources( orgId, config, trx, siteId ); - logger.debug( - `Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}` - ); - // We need to update the targets on the newts from the successfully updated information - for (const result of proxyResourcesResults) { + for (const result of publicResourcesResults) { for (const target of result.targetsToUpdate) { const [site] = await trx .select() @@ -136,166 +131,38 @@ export async function applyBlueprint({ } logger.debug( - `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}` + `Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}` ); // We need to update the targets on the newts from the successfully updated information - for (const result of clientResourcesResults) { - if ( - result.oldSiteResource && - JSON.stringify(result.newSites?.sort()) !== - JSON.stringify(result.oldSites?.sort()) - ) { - // query existing associations - const existingRoleIds = await trx - .select() - .from(roleSiteResources) - .where( - eq( - roleSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - .then((rows) => rows.map((row) => row.roleId)); - - const existingUserIds = await trx - .select() - .from(userSiteResources) - .where( - eq( - userSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - .then((rows) => rows.map((row) => row.userId)); - - const existingClientIds = await trx - .select() - .from(clientSiteResources) - .where( - eq( - clientSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - .then((rows) => rows.map((row) => row.clientId)); - - // delete the existing site resource - await trx - .delete(siteResources) - .where( - and( - eq( - siteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - ); - - await rebuildClientAssociationsFromSiteResource( - result.oldSiteResource, - trx + for (const result of privateResourcesResults) { + rebuildClientAssociationsFromSiteResource( + result.newSiteResource + ).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` ); + }); - const [insertedSiteResource] = await trx - .insert(siteResources) - .values({ - ...result.newSiteResource - }) - .returning(); - - // wait some time to allow for messages to be handled - await new Promise((resolve) => setTimeout(resolve, 750)); - - //////////////////// update the associations //////////////////// - - if (existingRoleIds.length > 0) { - await trx.insert(roleSiteResources).values( - existingRoleIds.map((roleId) => ({ - roleId, - siteResourceId: - insertedSiteResource!.siteResourceId - })) - ); - } - - if (existingUserIds.length > 0) { - await trx.insert(userSiteResources).values( - existingUserIds.map((userId) => ({ - userId, - siteResourceId: - insertedSiteResource!.siteResourceId - })) - ); - } - - if (existingClientIds.length > 0) { - await trx.insert(clientSiteResources).values( - existingClientIds.map((clientId) => ({ - clientId, - siteResourceId: - insertedSiteResource!.siteResourceId - })) - ); - } - - await rebuildClientAssociationsFromSiteResource( - insertedSiteResource, - trx + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((site) => ({ + // only need to run this on the old sites because the new sites are added above + siteId: site.siteId, + orgId: result.newSiteResource.orgId + })) + ).catch((err) => { + logger.error( + `Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`, + err ); - } else { - let good = true; - for (const newSite of result.newSites) { - const [site] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, newSite.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) - ) - ) - .limit(1); - - if (!site) { - logger.debug( - `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` - ); - good = false; - break; - } - - logger.debug( - `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}` - ); - } - - if (!good) { - continue; - } - - await handleMessagingForUpdatedSiteResource( - result.oldSiteResource, - result.newSiteResource, - result.newSites.map((site) => ({ - siteId: site.siteId, - orgId: result.newSiteResource.orgId - })), - trx - ); - } - - // await addClientTargets( - // site.newt.newtId, - // result.resource.destination, - // result.resource.destinationPort, - // result.resource.protocol, - // result.resource.proxyPort - // ); + }); } + + logger.debug( + `Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` + ); }); blueprintSucceeded = true; diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 83a03fa70..42d6ff0b9 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -160,9 +160,9 @@ export async function getClientSiteResourceAccess( } export async function rebuildClientAssociationsFromSiteResource( - siteResource: SiteResource, - trx: Transaction | typeof db = db + siteResource: SiteResource ) { + const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, @@ -2119,10 +2119,7 @@ export function startRebuildQueueProcessor(): void { return; } - await rebuildClientAssociationsFromSiteResource( - siteResource, - primaryDb - ); + await rebuildClientAssociationsFromSiteResource(siteResource); }, onClient: async (clientId: number) => { const [client] = await primaryDb diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index c43b755b2..3b3238527 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -153,8 +153,12 @@ export async function addClientToSiteResource( clientId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index a7153b3e3..31220df14 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -160,8 +160,12 @@ export async function addRoleToSiteResource( roleId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 6300502af..51a7f980f 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -129,8 +129,12 @@ export async function addUserToSiteResource( userId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1eebbc01d..d0d018f84 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -625,15 +625,14 @@ export async function createSiteResource( // own transaction so it always executes on the primary — avoiding any // replica-lag issues while still allowing the HTTP response to return // early. - rebuildClientAssociationsFromSiteResource( - newSiteResource!, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, - err - ); - }); + rebuildClientAssociationsFromSiteResource(newSiteResource!).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, + err + ); + } + ); return response(res, { data: newSiteResource, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 8ff23405c..b9efc5ba8 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -88,15 +88,14 @@ export async function deleteSiteResource( // own transaction so it always executes on the primary — avoiding any // replica-lag issues while still allowing the HTTP response to return // early. - rebuildClientAssociationsFromSiteResource( - removedSiteResource, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err - ); - }); + rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, + err + ); + } + ); logger.info(`Deleted site resource ${siteResourceId}`); diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 35944ca15..53cac4d93 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -157,8 +157,12 @@ export async function removeClientFromSiteResource( eq(clientSiteResources.clientId, clientId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 2759a57e7..2904edfaa 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -165,8 +165,12 @@ export async function removeRoleFromSiteResource( eq(roleSiteResources.roleId, roleId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 473db41b5..c7b79cd2e 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -135,8 +135,12 @@ export async function removeUserFromSiteResource( eq(userSiteResources.userId, userId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId} after removing user ${userId}: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 0f88f363f..a4bc5b69e 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -141,8 +141,12 @@ export async function setSiteResourceClients( })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index e9878a320..cad6da53b 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -165,8 +165,12 @@ export async function setSiteResourceRoles( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 4fa6f2218..cde5b4e66 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { error } from "node:console"; const setSiteResourceUsersBodySchema = z .object({ @@ -120,8 +121,12 @@ export async function setSiteResourceUsers( userIds.map((userId) => ({ userId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 3f271d2f9..af185c50c 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -12,7 +12,8 @@ import { sites, networks, Transaction, - userSiteResources + userSiteResources, + primaryDb } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -474,345 +475,167 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { - // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (sitesChanged) { - // delete the existing site resource - await trx - .delete(siteResources) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) - ); + // Update the site resource + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || + authDaemonMode !== undefined || + pamMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }), + ...(pamMode !== undefined && { + pamMode + }) + } + : {}; + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } - await rebuildClientAssociationsFromSiteResource( - existingSiteResource, - trx + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name: name, + niceId: niceId, + mode: mode, + scheme, + ssl, + destination: destination, + destinationPort: destinationPort, + enabled: enabled, + alias: alias ? alias.trim() : null, + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), + domainId, + subdomain: finalSubdomain, + fullDomain, + ...sshPamSet + }) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .returning(); + + //////////////////// update the associations //////////////////// + + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedSiteResource.networkId!) ); - // create the new site resource from the removed one - the ID should stay the same - const [insertedSiteResource] = await trx - .insert(siteResources) - .values({ - ...existingSiteResource - }) - .returning(); - - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } - - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name, - niceId, - mode, - scheme, - ssl, - destination, - destinationPort, - enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and( - eq( - siteResources.siteResourceId, - insertedSiteResource.siteResourceId - ) - ) - ) - .returning(); - - if (!updatedSiteResource) { - throw new Error( - "Failed to create updated site resource after site change" - ); - } - - //////////////////// update the associations //////////////////// - - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } - - const [adminRole] = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) - ) - .limit(1); - - if (!adminRole) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Admin role not found` - ) - ); - } - - await trx.insert(roleSiteResources).values({ - roleId: adminRole.roleId, - siteResourceId: updatedSiteResource.siteResourceId + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! }); - - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - } else { - // Update the site resource - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } - - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name: name, - niceId: niceId, - mode: mode, - scheme, - ssl, - destination: destination, - destinationPort: destinationPort, - enabled: enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) - ) - .returning(); - - //////////////////// update the associations //////////////////// - - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } - - await trx - .delete(clientSiteResources) - .where( - eq(clientSiteResources.siteResourceId, siteResourceId) - ); - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - - await trx - .delete(userSiteResources) - .where( - eq(userSiteResources.siteResourceId, siteResourceId) - ); - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId - })) - ); - } - - // Get all admin role IDs for this org to exclude from deletion - const adminRoles = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) - ); - const adminRoleIds = adminRoles.map((role) => role.roleId); - - if (adminRoleIds.length > 0) { - await trx.delete(roleSiteResources).where( - and( - eq( - roleSiteResources.siteResourceId, - siteResourceId - ), - ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role - ) - ); - } else { - await trx - .delete(roleSiteResources) - .where( - eq(roleSiteResources.siteResourceId, siteResourceId) - ); - } - - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId - })) - ); - } - - logger.info(`Updated site resource ${siteResourceId}`); } + + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); + + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) + ) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(roleSiteResources) + .where( + eq(roleSiteResources.siteResourceId, siteResourceId) + ); + } + + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); + } + + logger.info(`Updated site resource ${siteResourceId}`); }); - // Background: wait for removal messages to propagate, then rebuild - // associations for the re-created resource. Own transaction ensures - // execution on the primary against fully committed state. - (async () => { - await db.transaction(async (trx) => { - if (!updatedSiteResource) { - throw new Error("No updated resource found after update"); - } - if (sitesChanged) { - await new Promise((resolve) => setTimeout(resolve, 750)); - await rebuildClientAssociationsFromSiteResource( - updatedSiteResource, - trx - ); - } - await handleMessagingForUpdatedSiteResource( - existingSiteResource, - updatedSiteResource, - siteIds.map((siteId) => ({ - siteId, - orgId: existingSiteResource.orgId - })), - trx + if (!updatedSiteResource) { + throw new Error("No updated resource found after update"); + } + + if (sitesChanged) { + rebuildClientAssociationsFromSiteResource( + updatedSiteResource + ).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` ); }); - })().catch((err) => { + } + + handleMessagingForUpdatedSiteResource( + existingSiteResource, + updatedSiteResource, + Array.from(existingSiteIdSet).map((siteId: number) => ({ + // we already added to the new sites above in the rebuild function so we only need to update the ones that did not change + siteId, + orgId: existingSiteResource.orgId + })) + ).catch((e) => { logger.error( - `Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`, - err + `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` ); }); @@ -837,9 +660,9 @@ export async function updateSiteResource( export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, - sites: { siteId: number; orgId: string }[], - trx: Transaction + sites: { siteId: number; orgId: string }[] ) { + const trx = primaryDb; logger.debug( "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource @@ -849,17 +672,14 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource ); - await rebuildClientAssociationsFromSiteResource( - existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below - trx - ); - const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess( existingSiteResource || updatedSiteResource, trx ); + const siteIds = sites.map((site) => site.siteId); + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed const destinationChanged = existingSiteResource && @@ -896,12 +716,86 @@ export async function handleMessagingForUpdatedSiteResource( portRangesChanged || destinationPortChanged ) { + const newtsForSites = + siteIds.length > 0 + ? await trx + .select() + .from(newts) + .where(inArray(newts.siteId, siteIds)) + : []; + const newtBySiteId = new Map( + newtsForSites.map((newt) => [newt.siteId, newt]) + ); + + const oldDestinationStillInUseClientSitePairs = new Set(); + if ( + existingSiteResource?.destination && + siteIds.length > 0 && + mergedAllClientIds.length > 0 + ) { + const oldDestinationStillInUseRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + mergedAllClientIds + ), + inArray(siteNetworks.siteId, siteIds), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + for (const row of oldDestinationStillInUseRows) { + oldDestinationStillInUseClientSitePairs.add( + `${row.clientId}:${row.siteId}` + ); + } + } + + const shouldUpdateTargets = + destinationChanged || + sslChanged || + portRangesChanged || + fullDomainChanged || + destinationPortChanged; + const oldTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ) + : []; + const newTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ) + : []; + for (const site of sites) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + const newt = newtBySiteId.get(site.siteId); if (!newt) { throw new Error( @@ -910,22 +804,7 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if these items change - if ( - destinationChanged || - sslChanged || // we need to push a new cert if the ssl changed - portRangesChanged || - fullDomainChanged || // if the domain changes we need to update the certs and stuff - destinationPortChanged - ) { - const oldTargets = await generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ); - const newTargets = await generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ); - + if (shouldUpdateTargets) { await updateTargets( newt.newtId, { @@ -939,49 +818,19 @@ export async function handleMessagingForUpdatedSiteResource( const olmJobs: Promise[] = []; for (const client of mergedAllClients) { // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed if (!existingSiteResource.destination) { continue; } - const oldDestinationStillInUseSites = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteNetworks.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) - ); - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${site.siteId}` + ); // we also need to update the remote subnets on the olms for each client that has access to this site olmJobs.push( updatePeerData( + // TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH client.clientId, site.siteId, destinationChanged From e1044892575e1b1a3be34ecbe287d8fb77cf3dcd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 23 Jun 2026 23:28:46 +0200 Subject: [PATCH 099/264] =?UTF-8?q?=F0=9F=92=84=20Show=20GeoIp=20flag=20in?= =?UTF-8?q?=20site=20details=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/getSite.ts | 7 ++++++- src/components/SiteInfoCard.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index a671a47f9..885e3aa7a 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { getCountryCodeForIp } from "@server/lib/geoip"; const getSiteSchema = z.strictObject({ siteId: z @@ -47,6 +48,7 @@ type SiteQueryRow = NonNullable>>; export type GetSiteResponse = SiteQueryRow["sites"] & { newtId: string | null; newtVersion: string | null; + countryCode: string | null; }; registry.registerPath({ @@ -134,7 +136,10 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, newtId: site.newt ? site.newt.newtId : null, - newtVersion: site.newt?.version ?? null + newtVersion: site.newt?.version ?? null, + countryCode: site.sites.endpoint + ? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null) + : null }; return response(res, { diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 21697d697..a5e639c28 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -9,6 +9,7 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; type SiteInfoCardProps = {}; @@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("publicIpEndpoint")} - {formatPublicEndpoint(site.endpoint)} + {formatPublicEndpoint(site.endpoint)}  + + {site.countryCode && + countryCodeToFlagEmoji(site.countryCode)} + ) : null; From 91ef0d01531a7e4e78b7c6ad511ff5865179adef Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:44:18 -0400 Subject: [PATCH 100/264] Show warning about the .local aliases --- messages/en-US.json | 1 + src/components/PrivateResourceForm.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index 584a43d79..9d8a7fbdb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2338,6 +2338,7 @@ "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", + "internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.", "internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources", "internalResourceHttpPortRequired": "Destination port is required for HTTP resources", "siteConfiguration": "Configuration", diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 6082fd20e..e25df6469 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -580,6 +580,7 @@ export function PrivateResourceForm({ }); const mode = form.watch("mode"); + const aliasValue = form.watch("alias"); const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); @@ -595,6 +596,9 @@ export function PrivateResourceForm({ !isNative && pamMode === "push" && authDaemonMode === "remote"; + const aliasEndsWithLocal = + typeof aliasValue === "string" && + aliasValue.trim().toLowerCase().endsWith(".local"); const hasInitialized = useRef(false); const previousResourceId = useRef(null); const initialSitesRef = useRef(initialSites); @@ -1209,6 +1213,13 @@ export function PrivateResourceForm({ } /> + {aliasEndsWithLocal && ( +

+ {t( + "internalResourceAliasLocalWarning" + )} +

+ )} )} From 2a8ceeec1bfe00570ed58a773c7c6baa46b8f593 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:45:42 -0400 Subject: [PATCH 101/264] Restrict admin role --- src/components/PrivateResourceForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index e25df6469..23fd8a7b2 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -1748,6 +1748,7 @@ export function PrivateResourceForm({ field.value ?? [] } orgId={orgId} + restrictAdminRole onSelectRoles={( newUsers ) => { From bb7729df00d2e52e3e92bbea466141be8d4ef642 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 23 Jun 2026 23:45:59 +0200 Subject: [PATCH 102/264] =?UTF-8?q?=F0=9F=92=84=20show=20geoip=20flag=20in?= =?UTF-8?q?=20policy=20access=20rule=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PolicyAccessRulesTable.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index a701b92ff..b8445a44c 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -74,6 +74,7 @@ import { sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; export type PolicyAccessRulesTableProps = { rules: PolicyAccessRule[]; @@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({ { accessorKey: "value", header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( + cell: ({ row }) => { + let selectedCountry: (typeof COUNTRIES)[number] | undefined; + if ( + row.original.match === "COUNTRY" && + row.original.value + ) { + selectedCountry = COUNTRIES.find( + (c) => c.code === row.original.value + ); + } + return row.original.match === "COUNTRY" ? ( @@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({ + + {country.code === "ALL" + ? "🌍" + : countryCodeToFlagEmoji( + country.code + )} + {country.name} ( {country.code}) @@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({ }); }} /> - ) + ); + } }, { accessorKey: "enabled", From bc63747efec6067931573f43e64f453c143329ae Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 18:12:51 -0400 Subject: [PATCH 103/264] Refactor out transactions and always call rebuild on update --- .../siteResource/addClientToSiteResource.ts | 8 +++----- .../siteResource/addRoleToSiteResource.ts | 8 +++----- .../siteResource/addUserToSiteResource.ts | 8 +++----- .../removeClientFromSiteResource.ts | 18 ++++++++--------- .../removeRoleFromSiteResource.ts | 18 ++++++++--------- .../removeUserFromSiteResource.ts | 18 ++++++++--------- .../siteResource/updateSiteResource.ts | 20 ++++--------------- 7 files changed, 37 insertions(+), 61 deletions(-) diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 3b3238527..4a6dd141e 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -148,11 +148,9 @@ export async function addClientToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(clientSiteResources).values({ - clientId, - siteResourceId - }); + await db.insert(clientSiteResources).values({ + clientId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 31220df14..05186c351 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -155,11 +155,9 @@ export async function addRoleToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(roleSiteResources).values({ - roleId, - siteResourceId - }); + await db.insert(roleSiteResources).values({ + roleId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 51a7f980f..c35357993 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -124,11 +124,9 @@ export async function addUserToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(userSiteResources).values({ - userId, - siteResourceId - }); + await db.insert(userSiteResources).values({ + userId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 53cac4d93..c53e214cd 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -148,16 +148,14 @@ export async function removeClientFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(clientSiteResources) - .where( - and( - eq(clientSiteResources.siteResourceId, siteResourceId), - eq(clientSiteResources.clientId, clientId) - ) - ); - }); + await db + .delete(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 2904edfaa..6dd978e24 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -156,16 +156,14 @@ export async function removeRoleFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceId), - eq(roleSiteResources.roleId, roleId) - ) - ); - }); + await db + .delete(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index c7b79cd2e..67e6ac960 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -126,16 +126,14 @@ export async function removeUserFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(userSiteResources) - .where( - and( - eq(userSiteResources.siteResourceId, siteResourceId), - eq(userSiteResources.userId, userId) - ) - ); - }); + await db + .delete(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index af185c50c..9b79121fa 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -390,7 +390,6 @@ export async function updateSiteResource( ); } - let sitesChanged = false; const existingSiteIds = existingSiteResource.networkId ? await db .select() @@ -399,16 +398,7 @@ export async function updateSiteResource( eq(siteNetworks.networkId, existingSiteResource.networkId) ) : []; - const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); - const newSiteIdSet = new Set(siteIds); - - if ( - existingSiteIdSet.size !== newSiteIdSet.size || - ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) - ) { - sitesChanged = true; - } let fullDomain: string | null = null; let finalSubdomain: string | null = null; @@ -615,15 +605,13 @@ export async function updateSiteResource( throw new Error("No updated resource found after update"); } - if (sitesChanged) { - rebuildClientAssociationsFromSiteResource( - updatedSiteResource - ).catch((e) => { + rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch( + (e) => { logger.error( `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` ); - }); - } + } + ); handleMessagingForUpdatedSiteResource( existingSiteResource, From 034bcbd2712cbb45737a563447f6a46f95487301 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 11:54:56 -0400 Subject: [PATCH 104/264] Reorg --- server/lib/rebuildClientAssociations.ts | 334 ++++++++++++++++-- server/routers/client/targets.ts | 64 ++++ .../siteResource/updateSiteResource.ts | 242 +------------ 3 files changed, 387 insertions(+), 253 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 42d6ff0b9..9e362fb51 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -38,7 +38,9 @@ import { addPeerDataBatch, addTargetsBatch as addSubnetProxyTargetsBatch, removePeerDataBatch, - removeTargetsBatch as removeSubnetProxyTargetsBatch + removeTargetsBatch as removeSubnetProxyTargetsBatch, + updatePeerDataBatch, + updateTargets } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; @@ -162,15 +164,10 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource ) { - const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => - rebuildClientAssociationsFromSiteResourceImpl( - siteResource, - trx - ), + () => rebuildClientAssociationsFromSiteResourceImpl(siteResource), REBUILD_ASSOCIATIONS_LOCK_TTL_MS ); } catch (err: any) { @@ -192,15 +189,10 @@ export async function rebuildClientAssociationsFromSiteResource( } async function rebuildClientAssociationsFromSiteResourceImpl( - siteResource: SiteResource, - trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { + siteResource: SiteResource +) { + const trx = primaryDb; + logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` ); @@ -485,10 +477,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientSiteResourcesToRemove, trx ); - - return { - mergedAllClients - }; } async function handleMessagesForSiteClients( @@ -1042,6 +1030,312 @@ async function handleSubnetProxyTargetUpdates( await Promise.all([...proxyJobs, ...olmJobs]); } +export async function handleMessagingForUpdatedSiteResource( + existingSiteResource: SiteResource | undefined, + updatedSiteResource: SiteResource, + existingSiteIds: number[], + updatedSiteIds: number[], + trx: Transaction | typeof db = db +) { + logger.debug( + "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", + existingSiteResource + ); + logger.debug( + "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", + updatedSiteResource + ); + + const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])]; + + const newtsForSites = + allSiteIds.length > 0 + ? await trx + .select() + .from(newts) + .where(inArray(newts.siteId, allSiteIds)) + : []; + const newtBySiteId = new Map( + newtsForSites.map((newt) => [newt.siteId, newt]) + ); + + // get all of the clients from the cache + + const targets = await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ); + + const oldDestinationStillInUseClientSitePairs = new Set(); + if ( + existingSiteResource?.destination && + allSiteIds.length > 0 && + mergedAllClientIds.length > 0 + ) { + const oldDestinationStillInUseRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + mergedAllClientIds + ), + inArray(siteNetworks.siteId, allSiteIds), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + for (const row of oldDestinationStillInUseRows) { + oldDestinationStillInUseClientSitePairs.add( + `${row.clientId}:${row.siteId}` + ); + } + } + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES + const removedSiteIds = existingSiteIds.filter( + (id) => !updatedSiteIds.includes(id) + ); + + const targetsToRemoveBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataRemoves: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of removedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + continue; + } + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + const oldDestinationStillInUseByASite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` + ); + peerDataRemoves.push({ + // this might happen twice after the rebuild function but that is okay + clientId: client.clientId, + siteId, + remoteSubnets: !oldDestinationStillInUseByASite + ? generateRemoteSubnets([updatedSiteResource]) + : [], + aliases: generateAliasConfig([updatedSiteResource]) + }); + } + } + } + + removeSubnetProxyTargetsBatch(targetsToRemoveBatch); + + removePeerDataBatch(peerDataRemoves); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES + const addedSiteIds = updatedSiteIds.filter( + (id) => !existingSiteIds.includes(id) + ); + + const targetsToAddBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of addedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + continue; + } + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([updatedSiteResource]), + aliases: generateAliasConfig([updatedSiteResource]) + }); + } + } + } + + addSubnetProxyTargetsBatch(targetsToAddBatch); + + addPeerDataBatch(peerDataAdds); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES + + const unchangedSiteIds = existingSiteIds.filter((id) => + updatedSiteIds.includes(id) + ); + + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed + const destinationChanged = + existingSiteResource && + existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; + const aliasChanged = + existingSiteResource && + existingSiteResource.alias !== updatedSiteResource.alias; + const fullDomainChanged = + existingSiteResource && + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; + const sslChanged = + existingSiteResource && + existingSiteResource.ssl !== updatedSiteResource.ssl; + const portRangesChanged = + existingSiteResource && + (existingSiteResource.tcpPortRangeString !== + updatedSiteResource.tcpPortRangeString || + existingSiteResource.udpPortRangeString !== + updatedSiteResource.udpPortRangeString || + existingSiteResource.disableIcmp !== + updatedSiteResource.disableIcmp); + + // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all + + if ( + destinationChanged || + aliasChanged || + fullDomainChanged || + sslChanged || + portRangesChanged || + destinationPortChanged + ) { + const shouldUpdateTargets = + destinationChanged || + sslChanged || + portRangesChanged || + fullDomainChanged || + destinationPortChanged; + const oldTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ) + : []; + const newTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ) + : []; + + const peerDataUpdateBatch: Parameters[0] = + []; + + for (const siteId of unchangedSiteIds) { + const newt = newtBySiteId.get(siteId); + + if (!newt) { + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if these items change + if (shouldUpdateTargets) { + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets ? oldTargets : [], + newTargets: newTargets ? newTargets : [] + }, + newt.version + ); + } + + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + if (!existingSiteResource.destination) { + continue; + } + + const oldDestinationStillInUseByASite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` + ); + + // we also need to update the remote subnets on the olms for each client that has access to this site + peerDataUpdateBatch.push({ + clientId: client.clientId, + siteId, + remoteSubnets: destinationChanged + ? { + oldRemoteSubnets: !oldDestinationStillInUseByASite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliases: + aliasChanged || fullDomainChanged // the full domain is sent down as an alias + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + }); + } + } + + updatePeerDataBatch(peerDataUpdateBatch); + } +} + export async function rebuildClientAssociationsFromClient( client: Client ): Promise { diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index c62a64ae0..543293a10 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -438,6 +438,70 @@ export async function removePeerDataBatch( await sendToClientsBatch(payloads); } +export async function updatePeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: + | { + oldRemoteSubnets: string[]; + newRemoteSubnets: string[]; + } + | undefined; + aliases: + | { + oldAliases: Alias[]; + newAliases: Alias[]; + } + | undefined; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/update`, + data: { + siteId: entry.siteId, + ...entry.remoteSubnets, + ...entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + export async function updatePeerData( clientId: number, siteId: number, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 9b79121fa..5a8ed2aa0 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,8 +1,6 @@ import { clientSiteResources, - clientSiteResourcesAssociationsCache, db, - newts, orgs, roles, roleSiteResources, @@ -10,10 +8,7 @@ import { SiteResource, siteResources, sites, - networks, - Transaction, - userSiteResources, - primaryDb + userSiteResources } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -21,18 +16,8 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; -import { - generateAliasConfig, - generateRemoteSubnets, - generateSubnetProxyTargetV2, - isIpInCidr, - portRangeStringSchema -} from "@server/lib/ip"; -import { - getClientSiteResourceAccess, - rebuildClientAssociationsFromSiteResource -} from "@server/lib/rebuildClientAssociations"; +import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -390,7 +375,7 @@ export async function updateSiteResource( ); } - const existingSiteIds = existingSiteResource.networkId + const existingSiteNetworks = existingSiteResource.networkId ? await db .select() .from(siteNetworks) @@ -398,7 +383,7 @@ export async function updateSiteResource( eq(siteNetworks.networkId, existingSiteResource.networkId) ) : []; - const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); + const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); let fullDomain: string | null = null; let finalSubdomain: string | null = null; @@ -464,6 +449,7 @@ export async function updateSiteResource( } let updatedSiteResource: SiteResource | undefined; + let updatedSiteIds: number[] = []; await db.transaction(async (trx) => { // Update the site resource const sshPamSet = @@ -534,6 +520,7 @@ export async function updateSiteResource( siteId: siteId, networkId: updatedSiteResource.networkId! }); + updatedSiteIds.push(siteId); } await trx @@ -616,11 +603,8 @@ export async function updateSiteResource( handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, - Array.from(existingSiteIdSet).map((siteId: number) => ({ - // we already added to the new sites above in the rebuild function so we only need to update the ones that did not change - siteId, - orgId: existingSiteResource.orgId - })) + existingSiteIds, + updatedSiteIds ).catch((e) => { logger.error( `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` @@ -644,211 +628,3 @@ export async function updateSiteResource( ); } } - -export async function handleMessagingForUpdatedSiteResource( - existingSiteResource: SiteResource | undefined, - updatedSiteResource: SiteResource, - sites: { siteId: number; orgId: string }[] -) { - const trx = primaryDb; - logger.debug( - "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", - existingSiteResource - ); - logger.debug( - "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", - updatedSiteResource - ); - - const { sitesList, mergedAllClients, mergedAllClientIds } = - await getClientSiteResourceAccess( - existingSiteResource || updatedSiteResource, - trx - ); - - const siteIds = sites.map((site) => site.siteId); - - // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed - const destinationChanged = - existingSiteResource && - existingSiteResource.destination !== updatedSiteResource.destination; - const destinationPortChanged = - existingSiteResource && - existingSiteResource.destinationPort !== - updatedSiteResource.destinationPort; - const aliasChanged = - existingSiteResource && - existingSiteResource.alias !== updatedSiteResource.alias; - const fullDomainChanged = - existingSiteResource && - existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; - const sslChanged = - existingSiteResource && - existingSiteResource.ssl !== updatedSiteResource.ssl; - const portRangesChanged = - existingSiteResource && - (existingSiteResource.tcpPortRangeString !== - updatedSiteResource.tcpPortRangeString || - existingSiteResource.udpPortRangeString !== - updatedSiteResource.udpPortRangeString || - existingSiteResource.disableIcmp !== - updatedSiteResource.disableIcmp); - - // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - - if ( - destinationChanged || - aliasChanged || - fullDomainChanged || - sslChanged || - portRangesChanged || - destinationPortChanged - ) { - const newtsForSites = - siteIds.length > 0 - ? await trx - .select() - .from(newts) - .where(inArray(newts.siteId, siteIds)) - : []; - const newtBySiteId = new Map( - newtsForSites.map((newt) => [newt.siteId, newt]) - ); - - const oldDestinationStillInUseClientSitePairs = new Set(); - if ( - existingSiteResource?.destination && - siteIds.length > 0 && - mergedAllClientIds.length > 0 - ) { - const oldDestinationStillInUseRows = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId, - siteId: siteNetworks.siteId - }) - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - inArray( - clientSiteResourcesAssociationsCache.clientId, - mergedAllClientIds - ), - inArray(siteNetworks.siteId, siteIds), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) - ); - - for (const row of oldDestinationStillInUseRows) { - oldDestinationStillInUseClientSitePairs.add( - `${row.clientId}:${row.siteId}` - ); - } - } - - const shouldUpdateTargets = - destinationChanged || - sslChanged || - portRangesChanged || - fullDomainChanged || - destinationPortChanged; - const oldTargets = shouldUpdateTargets - ? await generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ) - : []; - const newTargets = shouldUpdateTargets - ? await generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ) - : []; - - for (const site of sites) { - const newt = newtBySiteId.get(site.siteId); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if these items change - if (shouldUpdateTargets) { - await updateTargets( - newt.newtId, - { - oldTargets: oldTargets ? oldTargets : [], - newTargets: newTargets ? newTargets : [] - }, - newt.version - ); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - if (!existingSiteResource.destination) { - continue; - } - - const oldDestinationStillInUseByASite = - oldDestinationStillInUseClientSitePairs.has( - `${client.clientId}:${site.siteId}` - ); - - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - // TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH - client.clientId, - site.siteId, - destinationChanged - ? { - oldRemoteSubnets: - !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged || fullDomainChanged // the full domain is sent down as an alias - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); - } - - await Promise.all(olmJobs); - } - } -} From 80b66cf9b9860433d8005c504a571ec7110ce775 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 13:50:33 -0400 Subject: [PATCH 105/264] Add locks to rebuilds --- server/lib/blueprints/applyBlueprint.ts | 46 ++++---- server/lib/lock.ts | 2 +- server/lib/rebuildClientAssociations.ts | 108 ++++++++++++++++++ server/lib/rebuildQueue.ts | 4 + server/private/lib/rebuildQueue.ts | 11 ++ .../newt/handleNewtGetConfigMessage.ts | 3 + .../routers/olm/handleOlmRegisterMessage.ts | 3 + .../siteResource/updateSiteResource.ts | 43 ++++--- 8 files changed, 179 insertions(+), 41 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index fbd6f3fb0..493831131 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -29,8 +29,11 @@ import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { generateName } from "@server/db/names"; -import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; -import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "../rebuildClientAssociations"; type ApplyBlueprintArgs = { orgId: string; @@ -138,26 +141,25 @@ export async function applyBlueprint({ for (const result of privateResourcesResults) { rebuildClientAssociationsFromSiteResource( result.newSiteResource - ).catch((e) => { - logger.error( - `Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` - ); - }); - - handleMessagingForUpdatedSiteResource( - result.oldSiteResource, - result.newSiteResource, - result.oldSites.map((site) => ({ - // only need to run this on the old sites because the new sites are added above - siteId: site.siteId, - orgId: result.newSiteResource.orgId - })) - ).catch((err) => { - logger.error( - `Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`, - err - ); - }); + ) + .then(() => + waitForSiteResourceRebuildIdle( + result.newSiteResource.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((s) => s.siteId), + result.newSites.map((s) => s.siteId) + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` + ); + }); } logger.debug( diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 7eea89084..15d1f39e1 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -35,7 +35,7 @@ export class LockManager { ttl: number; owner?: string; }> { - return { exists: true, ownedByMe: true, ttl: 0 }; + return { exists: false, ownedByMe: false, ttl: 0 }; } /** diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 9e362fb51..daf639bf3 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -49,6 +49,112 @@ import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; // peer/proxy updates, so give them a generous window. const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; +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; + +/** + * Returns true if a rebuild for the given site resource is currently active + * (holding the distributed lock) or is pending in the rebuild queue. + */ +export async function hasActiveSiteResourceRebuild( + siteResourceId: number +): Promise { + const lockKey = `rebuild-client-associations:site-resource:${siteResourceId}`; + const lockInfo = await lockManager.getLockInfo(lockKey); + if (lockInfo.exists) return true; + return rebuildQueue.isQueued({ type: "site-resource", id: siteResourceId }); +} + +/** + * Resolves once there is no active or queued rebuild for the given site resource. + * Logs a warning and resolves early if the timeout is reached. + */ +export async function waitForSiteResourceRebuildIdle( + siteResourceId: number, + timeoutMs = REBUILD_IDLE_DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await hasActiveSiteResourceRebuild(siteResourceId))) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteResourceRebuildIdle: timed out after ${timeoutMs}ms waiting for siteResourceId=${siteResourceId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given site. + */ +export async function waitForSiteRebuildIdle( + siteId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where(eq(siteNetworks.siteId, siteId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteRebuildIdle: timed out after ${timeoutMs}ms waiting for siteId=${siteId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given client. + */ +export async function waitForClientRebuildIdle( + clientId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ + siteResourceId: + clientSiteResourcesAssociationsCache.siteResourceId + }) + .from(clientSiteResourcesAssociationsCache) + .where(eq(clientSiteResourcesAssociationsCache.clientId, clientId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForClientRebuildIdle: timed out after ${timeoutMs}ms waiting for clientId=${clientId}` + ); +} + export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db @@ -1060,6 +1166,8 @@ export async function handleMessagingForUpdatedSiteResource( ); // get all of the clients from the cache + const { mergedAllClients, mergedAllClientIds } = + await getClientSiteResourceAccess(updatedSiteResource, trx); const targets = await generateSubnetProxyTargetV2( updatedSiteResource, diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 475858108..84dce9641 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -13,11 +13,15 @@ export interface RebuildJobHandlers { export interface RebuildQueueManager { enqueue(job: RebuildJob): Promise; startProcessing(handlers: RebuildJobHandlers): void; + isQueued(job: RebuildJob): Promise; } class NoopRebuildQueue implements RebuildQueueManager { async enqueue(_job: RebuildJob): Promise {} startProcessing(_handlers: RebuildJobHandlers): void {} + async isQueued(_job: RebuildJob): Promise { + return false; + } } export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index 01082cc50..b5e112545 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -46,6 +46,17 @@ const POLL_INTERVAL_MS = 500; class RedisRebuildQueue { private processingStarted = false; + async isQueued(job: RebuildJob): Promise { + if (!redis || redis.status !== "ready") return false; + const dedupeKey = `${job.type}:${job.id}`; + try { + const member = await redis.sismember(QUEUED_SET_KEY, dedupeKey); + return member === 1; + } catch { + return false; + } + } + async enqueue(job: RebuildJob): Promise { if (!redis || redis.status !== "ready") { logger.warn( diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index ff5d83799..fd5e2b42e 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { convertTargetsIfNecessary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; +import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations"; export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -61,6 +62,8 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } + await waitForSiteRebuildIdle(siteId); + // update the endpoint and the public key const [site] = await db .update(sites) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 6bfc02aee..bef993831 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -21,6 +21,7 @@ import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is +import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -385,6 +386,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } // NOTE: its important that the client here is the old client and the public key is the new key + await waitForClientRebuildIdle(olm.clientId); + const siteConfigurations = await buildSiteConfigurationForOlmClient( client, publicKey, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 5a8ed2aa0..434163f6f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -17,7 +17,11 @@ import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -592,24 +596,27 @@ export async function updateSiteResource( throw new Error("No updated resource found after update"); } - rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` - ); - } - ); + const finalUpdatedSiteResource = updatedSiteResource; - handleMessagingForUpdatedSiteResource( - existingSiteResource, - updatedSiteResource, - existingSiteIds, - updatedSiteIds - ).catch((e) => { - logger.error( - `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` - ); - }); + rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource) + .then(() => + waitForSiteResourceRebuildIdle( + finalUpdatedSiteResource.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + existingSiteResource, + finalUpdatedSiteResource, + existingSiteIds, + updatedSiteIds + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${siteResourceId}. Error: ${e}` + ); + }); return response(res, { data: updatedSiteResource, From 62fc2edae99cfd1495cbaabf9c7fcc18776f048e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:28:46 -0400 Subject: [PATCH 106/264] Add logging and fix removing alias --- server/lib/rebuildClientAssociations.ts | 172 ++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 8 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index daf639bf3..72a16efcf 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1140,9 +1140,14 @@ export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, existingSiteIds: number[], - updatedSiteIds: number[], - trx: Transaction | typeof db = db + updatedSiteIds: number[] ) { + const trx = primaryDb; + + logger.debug( + `handleMessagingForUpdatedSiteResource: START siteResourceId=${updatedSiteResource.siteResourceId} existingSiteIds=[${existingSiteIds.join(", ")}] updatedSiteIds=[${updatedSiteIds.join(", ")}]` + ); + logger.debug( "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource @@ -1154,6 +1159,10 @@ export async function handleMessagingForUpdatedSiteResource( const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])]; + logger.debug( + `handleMessagingForUpdatedSiteResource: allSiteIds=[${allSiteIds.join(", ")}] count=${allSiteIds.length}` + ); + const newtsForSites = allSiteIds.length > 0 ? await trx @@ -1165,21 +1174,53 @@ export async function handleMessagingForUpdatedSiteResource( newtsForSites.map((newt) => [newt.siteId, newt]) ); - // get all of the clients from the cache - const { mergedAllClients, mergedAllClientIds } = - await getClientSiteResourceAccess(updatedSiteResource, trx); + logger.debug( + `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` + ); + + // WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED + const mergedAllClients = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + clients, + eq(clientSiteResourcesAssociationsCache.clientId, clients.clientId) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + updatedSiteResource.siteResourceId + ) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: resolved merged clients count=${mergedAllClients.length} clientIds=[${mergedAllClients.map((c) => c.clientId).join(", ")}]` + ); const targets = await generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); + logger.debug( + `handleMessagingForUpdatedSiteResource: generated updated targets count=${targets ? targets.length : 0}` + ); + const oldDestinationStillInUseClientSitePairs = new Set(); + const oldAliasStillInUseClientSitePairs = new Set(); if ( existingSiteResource?.destination && allSiteIds.length > 0 && - mergedAllClientIds.length > 0 + mergedAllClients.length > 0 ) { + logger.debug( + `handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}` + ); + const oldDestinationStillInUseRows = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -1201,7 +1242,7 @@ export async function handleMessagingForUpdatedSiteResource( and( inArray( clientSiteResourcesAssociationsCache.clientId, - mergedAllClientIds + mergedAllClients.map((c) => c.clientId) ), inArray(siteNetworks.siteId, allSiteIds), eq( @@ -1220,6 +1261,14 @@ export async function handleMessagingForUpdatedSiteResource( `${row.clientId}:${row.siteId}` ); } + + logger.debug( + `handleMessagingForUpdatedSiteResource: old destination still in use rows=${oldDestinationStillInUseRows.length} uniqueClientSitePairs=${oldDestinationStillInUseClientSitePairs.size}` + ); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping old destination reuse check (missing existing destination or no sites/clients)" + ); } //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES @@ -1227,6 +1276,10 @@ export async function handleMessagingForUpdatedSiteResource( (id) => !updatedSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: removing sites removedSiteIds=[${removedSiteIds.join(", ")}] count=${removedSiteIds.length}` + ); + const targetsToRemoveBatch: { newtId: string; targets: any[]; @@ -1242,8 +1295,16 @@ export async function handleMessagingForUpdatedSiteResource( for (const siteId of removedSiteIds) { const newt = newtBySiteId.get(siteId); if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping remove for siteId=${siteId} because no newt found` + ); continue; } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing remove batches for siteId=${siteId} newtId=${newt.newtId}` + ); + targetsToRemoveBatch.push({ newtId: newt.newtId, targets: targets, @@ -1254,6 +1315,12 @@ export async function handleMessagingForUpdatedSiteResource( oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); + + if (oldDestinationStillInUseByASite && allSiteIds.length > 0) { + // nothing in the message anyway lets just continue + continue; + } + peerDataRemoves.push({ // this might happen twice after the rebuild function but that is okay clientId: client.clientId, @@ -1261,14 +1328,33 @@ export async function handleMessagingForUpdatedSiteResource( remoteSubnets: !oldDestinationStillInUseByASite ? generateRemoteSubnets([updatedSiteResource]) : [], - aliases: generateAliasConfig([updatedSiteResource]) + aliases: + allSiteIds.length == 0 + ? generateAliasConfig([updatedSiteResource]) + : [] }); } } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping removal batch generation because targets were empty" + ); } + logger.debug( + `handleMessagingForUpdatedSiteResource: remove batches prepared targetBatchCount=${targetsToRemoveBatch.length} peerDataCount=${peerDataRemoves.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removeSubnetProxyTargetsBatch" + ); + removeSubnetProxyTargetsBatch(targetsToRemoveBatch); + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removePeerDataBatch" + ); + removePeerDataBatch(peerDataRemoves); //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES @@ -1276,6 +1362,10 @@ export async function handleMessagingForUpdatedSiteResource( (id) => !existingSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: adding sites addedSiteIds=[${addedSiteIds.join(", ")}] count=${addedSiteIds.length}` + ); + const targetsToAddBatch: { newtId: string; targets: any[]; @@ -1291,8 +1381,16 @@ export async function handleMessagingForUpdatedSiteResource( for (const siteId of addedSiteIds) { const newt = newtBySiteId.get(siteId); if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping add for siteId=${siteId} because no newt found` + ); continue; } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing add batches for siteId=${siteId} newtId=${newt.newtId}` + ); + targetsToAddBatch.push({ newtId: newt.newtId, targets: targets, @@ -1307,10 +1405,26 @@ export async function handleMessagingForUpdatedSiteResource( }); } } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping add batch generation because targets were empty" + ); } + logger.debug( + `handleMessagingForUpdatedSiteResource: add batches prepared targetBatchCount=${targetsToAddBatch.length} peerDataCount=${peerDataAdds.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addSubnetProxyTargetsBatch" + ); + addSubnetProxyTargetsBatch(targetsToAddBatch); + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addPeerDataBatch" + ); + addPeerDataBatch(peerDataAdds); //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES @@ -1319,6 +1433,10 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: unchangedSiteIds=[${unchangedSiteIds.join(", ")}] count=${unchangedSiteIds.length}` + ); + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed const destinationChanged = existingSiteResource && @@ -1345,6 +1463,10 @@ export async function handleMessagingForUpdatedSiteResource( existingSiteResource.disableIcmp !== updatedSiteResource.disableIcmp); + logger.debug( + `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}` + ); + // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all if ( @@ -1361,6 +1483,11 @@ export async function handleMessagingForUpdatedSiteResource( portRangesChanged || fullDomainChanged || destinationPortChanged; + + logger.debug( + `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` + ); + const oldTargets = shouldUpdateTargets ? await generateSubnetProxyTargetV2( existingSiteResource, @@ -1374,13 +1501,24 @@ export async function handleMessagingForUpdatedSiteResource( ) : []; + logger.debug( + `handleMessagingForUpdatedSiteResource: target update payload sizes oldTargets=${oldTargets ? oldTargets.length : 0} newTargets=${newTargets ? newTargets.length : 0}` + ); + const peerDataUpdateBatch: Parameters[0] = []; for (const siteId of unchangedSiteIds) { const newt = newtBySiteId.get(siteId); + logger.debug( + `handleMessagingForUpdatedSiteResource: processing unchanged siteId=${siteId}` + ); + if (!newt) { + logger.error( + `handleMessagingForUpdatedSiteResource: missing newt for unchanged siteId=${siteId}` + ); throw new Error( "Newt not found for site during site resource update" ); @@ -1388,6 +1526,9 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if these items change if (shouldUpdateTargets) { + logger.debug( + `handleMessagingForUpdatedSiteResource: updating targets for siteId=${siteId} newtId=${newt.newtId}` + ); await updateTargets( newt.newtId, { @@ -1401,6 +1542,9 @@ export async function handleMessagingForUpdatedSiteResource( for (const client of mergedAllClients) { // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet if (!existingSiteResource.destination) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping peerData update for clientId=${client.clientId} siteId=${siteId} because existing destination is empty` + ); continue; } @@ -1440,8 +1584,20 @@ export async function handleMessagingForUpdatedSiteResource( } } + logger.debug( + `handleMessagingForUpdatedSiteResource: dispatching updatePeerDataBatch count=${peerDataUpdateBatch.length}` + ); + updatePeerDataBatch(peerDataUpdateBatch); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: no unchanged-site update required because no relevant fields changed" + ); } + + logger.debug( + `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` + ); } export async function rebuildClientAssociationsFromClient( From 75b87ffba7240158ef80dc2006c90df26c9a5537 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:49:51 -0400 Subject: [PATCH 107/264] Quiet log message --- server/private/lib/acmeCertSync.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index fb99e934e..56105ac38 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { ); continue; } - logger.debug( - `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` - ); + // logger.debug( + // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + // ); for (const cert of resolverData.Certificates) { allCerts.push(cert); } From d303fa05cb0e68ca1dfbaa02cdd55b3d1fea4a3c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:50:54 -0400 Subject: [PATCH 108/264] Comment out the sync --- server/routers/newt/handleNewtPingMessage.ts | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 56b8a2a24..c56c5f6d4 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -49,20 +49,22 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, newt.siteId)) - .limit(1); + // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN - if (!site) { - logger.warn( - `Newt ping message: site with ID ${newt.siteId} not found` - ); - return; - } + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, newt.siteId)) + // .limit(1); - await sendNewtSyncMessage(newt, site); + // if (!site) { + // logger.warn( + // `Newt ping message: site with ID ${newt.siteId} not found` + // ); + // return; + // } + + // await sendNewtSyncMessage(newt, site); } return { From b18a41e4aa59669e0266adedcd9e84ed99bae9e3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 15:36:45 -0400 Subject: [PATCH 109/264] adjust translation --- 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 9d8a7fbdb..30173d651 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1638,7 +1638,7 @@ "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", - "alertingNotifyEmails": "Email addresses", + "alertingNotifyEmails": "Email Addresses", "alertingEmailPlaceholder": "Add email and press Enter", "alertingWebhookMethod": "HTTP method", "alertingWebhookSecret": "Signing secret (optional)", From 2b38658ea6df240e9a33d6c2b1c24cd5e38a51bb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 15:55:12 -0400 Subject: [PATCH 110/264] make sidebar notification failures more resilient --- src/components/ProductUpdates.tsx | 83 +++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 0d88853a7..dad46e3b9 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -8,6 +8,7 @@ import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries"; +import { build } from "@server/build"; import { useQueries } from "@tanstack/react-query"; import { ArrowRight, @@ -39,22 +40,42 @@ export default function ProductUpdates({ }) { const { env } = useEnvContext(); + const productUpdatesEnabled = env.app.notifications.product_updates; + const versionCheckEnabled = + env.app.notifications.new_releases && build !== "saas"; + const data = useQueries({ queries: [ - productUpdatesQueries.list( - env.app.notifications.product_updates, - env.app.version - ), + productUpdatesQueries.list(productUpdatesEnabled, env.app.version), productUpdatesQueries.latestVersion( env.app.notifications.new_releases ) ], combine(result) { - if (result[0].isLoading || result[1].isLoading) return null; - return { - updates: result[0].data?.data ?? [], - latestVersion: result[1].data - }; + const [updatesQuery, versionQuery] = result; + + const updatesSettled = + !productUpdatesEnabled || + updatesQuery.isFetched || + updatesQuery.isError; + const versionSettled = + !versionCheckEnabled || + versionQuery.isFetched || + versionQuery.isError; + + if (!updatesSettled || !versionSettled) return null; + + const updates = updatesQuery.isError + ? [] + : Array.isArray(updatesQuery.data?.data) + ? updatesQuery.data.data + : []; + + const latestVersion = versionQuery.isError + ? undefined + : versionQuery.data; + + return { updates, latestVersion }; } }); const t = useTranslations(); @@ -76,19 +97,30 @@ export default function ProductUpdates({ if (!data) return null; - const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion; + const versionResponse = data.latestVersion?.data; + const latestVersion = versionResponse?.pangolin?.latestVersion; const currentVersion = env.app.version; - const showNewVersionPopup = Boolean( + let showNewVersionPopup = false; + if ( latestVersion && - valid(latestVersion) && - valid(currentVersion) && - ignoredVersionUpdate !== latestVersion && - gt(latestVersion, currentVersion) - ); + valid(latestVersion) && + valid(currentVersion) && + ignoredVersionUpdate !== latestVersion + ) { + try { + showNewVersionPopup = gt(latestVersion, currentVersion); + } catch { + showNewVersionPopup = false; + } + } + + const readUpdateIds = Array.isArray(productUpdatesRead) + ? productUpdatesRead + : []; const filteredUpdates = data.updates.filter( - (update) => !productUpdatesRead.includes(update.id) + (update) => !readUpdateIds.includes(update.id) ); if (filteredUpdates.length === 0 && !showNewVersionPopup) { @@ -133,17 +165,14 @@ export default function ProductUpdates({ show={filteredUpdates.length > 0} onDimissAll={() => setProductUpdatesRead([ - ...productUpdatesRead, + ...readUpdateIds, ...filteredUpdates.map( (update) => update.id ) ]) } onDimiss={(id) => - setProductUpdatesRead([ - ...productUpdatesRead, - id - ]) + setProductUpdatesRead([...readUpdateIds, id]) } />
@@ -151,11 +180,9 @@ export default function ProductUpdates({ { - setIgnoredVersionUpdate( - data.latestVersion?.data?.pangolin.latestVersion ?? null - ); + setIgnoredVersionUpdate(latestVersion ?? null); }} show={showNewVersionPopup} /> @@ -346,6 +373,10 @@ function NewVersionAvailable({ } }, [show]); + if (!version?.pangolin?.latestVersion) { + return null; + } + return ( {version && ( From 242123b8753e05487ef7e84b09f180fb6d777808 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 16:00:54 -0400 Subject: [PATCH 111/264] Implement non-redis lock --- server/lib/lock.ts | 124 ++++++++++++++++++++++++++++-- server/private/lib/lock.ts | 153 +++++++++++++++++++++++++------------ 2 files changed, 222 insertions(+), 55 deletions(-) diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 15d1f39e1..3cd1b8704 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -1,4 +1,24 @@ +const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; + +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + export class LockManager { + private clearExpiredLocalLock(lockKey: string): void { + const current = localLocks.get(lockKey); + if (current && current.expiresAt <= Date.now()) { + localLocks.delete(lockKey); + } + } + + private getLocalOwnerToken(): string { + return `${instanceId}:`; + } + /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock @@ -7,22 +27,57 @@ export class LockManager { */ async acquireLock( lockKey: string, - ttlMs: number = 30000 + ttlMs: number = 30000, + maxRetries: number = 3, + retryDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** * Release a lock using Lua script to ensure atomicity * @param lockKey - Unique identifier for the lock */ - async releaseLock(lockKey: string): Promise {} + async releaseLock(lockKey: string): Promise { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (existing && existing.owner === this.getLocalOwnerToken()) { + localLocks.delete(lockKey); + } + } /** * Force release a lock regardless of owner (use with caution) * @param lockKey - Unique identifier for the lock */ - async forceReleaseLock(lockKey: string): Promise {} + async forceReleaseLock(lockKey: string): Promise { + localLocks.delete(lockKey); + } /** * Check if a lock exists and get its info @@ -35,7 +90,20 @@ export class LockManager { ttl: number; owner?: string; }> { - return { exists: false, ownedByMe: false, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } /** @@ -45,6 +113,15 @@ export class LockManager { * @returns Promise - true if extended successfully */ async extendLock(lockKey: string, ttlMs: number): Promise { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing || existing.owner !== this.getLocalOwnerToken()) { + return false; + } + + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); return true; } @@ -62,7 +139,26 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); + + if (acquired) { + return true; + } + + if (attempt < maxRetries) { + const delay = + baseDelayMs * Math.pow(2, attempt) + Math.random() * 100; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** @@ -99,7 +195,21 @@ export class LockManager { activeLocksCount: number; locksOwnedByMe: number; }> { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } /** diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index a59bbc051..26577b2b0 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -11,14 +11,31 @@ * This file is not licensed under the AGPLv3. */ -import { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; import { v4 as uuidv4 } from "uuid"; const instanceId = uuidv4(); +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + export class LockManager { + private clearExpiredLocalLock(lockKey: string): void { + const current = localLocks.get(lockKey); + if (current && current.expiresAt <= Date.now()) { + localLocks.delete(lockKey); + } + } + + private getLocalOwnerToken(): string { + return `${instanceId}:`; + } + /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock @@ -32,12 +49,34 @@ export class LockManager { retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } - const lockValue = `${ - instanceId - }:${Date.now()}`; + const lockValue = `${instanceId}:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -53,11 +92,7 @@ export class LockManager { ); if (result === "OK") { - logger.debug( - `Lock acquired: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); return true; } @@ -65,17 +100,11 @@ export class LockManager { const existingValue = await redis.get(redisKey); if ( existingValue && - existingValue.startsWith( - `${instanceId}:` - ) + 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 - }` - ); + logger.debug(`Lock extended: ${lockKey} by ${instanceId}`); return true; } @@ -88,7 +117,10 @@ export class LockManager { await new Promise((resolve) => setTimeout(resolve, delay)); } } catch (error) { - logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); + logger.error( + `Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, + error + ); // On error, still retry if we have attempts left if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); @@ -109,6 +141,11 @@ export class LockManager { */ async releaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + if (existing && existing.owner === this.getLocalOwnerToken()) { + localLocks.delete(lockKey); + } return; } @@ -136,11 +173,7 @@ export class LockManager { )) as number; if (result === 1) { - logger.debug( - `Lock released: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock released: ${lockKey} by ${instanceId}`); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ @@ -159,6 +192,7 @@ export class LockManager { */ async forceReleaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { + localLocks.delete(lockKey); return; } @@ -186,7 +220,20 @@ export class LockManager { owner?: string; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { exists: false, ownedByMe: true, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } const redisKey = `lock:${lockKey}`; @@ -198,11 +245,7 @@ export class LockManager { ]); const exists = value !== null; - const ownedByMe = - exists && - value!.startsWith( - `${instanceId}:` - ); + const ownedByMe = exists && value!.startsWith(`${instanceId}:`); const owner = exists ? value!.split(":")[0] : undefined; return { @@ -225,6 +268,15 @@ export class LockManager { */ async extendLock(lockKey: string, ttlMs: number): Promise { if (!redis || !redis.status || redis.status !== "ready") { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing || existing.owner !== this.getLocalOwnerToken()) { + return false; + } + + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); return true; } @@ -255,9 +307,7 @@ export class LockManager { if (result === 1) { logger.debug( - `Lock extended: ${lockKey} by ${ - instanceId - } for ${ttlMs}ms` + `Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms` ); return true; } @@ -282,12 +332,13 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return true; - } - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const acquired = await this.acquireLock(lockKey, ttlMs); + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); if (acquired) { return true; @@ -319,10 +370,6 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return await fn(); - } - const acquired = await this.acquireLock(lockKey, ttlMs); if (!acquired) { @@ -346,7 +393,21 @@ export class LockManager { locksOwnedByMe: number; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } try { @@ -356,11 +417,7 @@ export class LockManager { if (keys.length > 0) { const values = await redis.mget(...keys); locksOwnedByMe = values.filter( - (value) => - value && - value.startsWith( - `${instanceId}:` - ) + (value) => value && value.startsWith(`${instanceId}:`) ).length; } From 6fe4eee336d78cabc822a858731eacf3bb0ad5bc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 16:32:46 -0400 Subject: [PATCH 112/264] improve org policy error message responses --- server/auth/sessions/app.ts | 41 ++++++++- server/middlewares/verifyAccessTokenAccess.ts | 3 +- server/middlewares/verifyAdmin.ts | 3 +- server/middlewares/verifyApiKeyAccess.ts | 3 +- server/middlewares/verifyClientAccess.ts | 8 +- server/middlewares/verifyDomainAccess.ts | 3 +- server/middlewares/verifyOrgAccess.ts | 7 +- server/middlewares/verifyResourceAccess.ts | 3 +- .../middlewares/verifyResourcePolicyAccess.ts | 3 +- server/middlewares/verifyRoleAccess.ts | 3 +- .../middlewares/verifySetResourceClients.ts | 3 +- server/middlewares/verifySetResourceUsers.ts | 3 +- server/middlewares/verifySiteAccess.ts | 3 +- .../verifySiteProvisioningKeyAccess.ts | 3 +- .../middlewares/verifySiteResourceAccess.ts | 3 +- server/middlewares/verifyTargetAccess.ts | 3 +- server/middlewares/verifyUserAccess.ts | 3 +- server/private/lib/checkOrgAccessPolicy.ts | 88 ++++++++++++++++--- server/routers/auth/changePassword.ts | 47 +--------- server/routers/auth/verifyTotp.ts | 13 +++ server/routers/resource/getExchangeToken.ts | 3 +- 21 files changed, 155 insertions(+), 94 deletions(-) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index f6cae441b..19875fc68 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -12,7 +12,7 @@ import { users } from "@server/db"; import { db } from "@server/db"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise { } } +export async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + + if (userSessions.length > 0) { + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + } + + await trx + .delete(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + }); + } catch (e) { + logger.error("Failed to invalidate user sessions except current", e); + } +} + export function serializeSessionCookie( token: string, isSecure: boolean, diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 528298727..07786e87d 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 0dbeac2cb..da4f88af9 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -56,8 +56,7 @@ export async function verifyAdmin( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 2522a1e8b..ea1bdac18 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -113,8 +113,7 @@ export async function verifyApiKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index 1d994b53f..3aee4f38c 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -107,8 +107,7 @@ export async function verifyClientAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } @@ -129,10 +128,7 @@ export async function verifyClientAccess( .where( and( eq(roleClients.clientId, client.clientId), - inArray( - roleClients.roleId, - req.userOrgRoleIds! - ) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 783132a1a..173c3a54b 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -88,8 +88,7 @@ export async function verifyDomainAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index e464f7b89..030b5f702 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getFirstString } from "@server/lib/requestParams"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -54,13 +55,15 @@ export async function verifyOrgAccess( userId, session: req.session }); + logger.debug("failed policy check", { + policyCheck + }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index f790a481a..2689cdb2d 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -105,8 +105,7 @@ export async function verifyResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts index 30fe48e8c..667680c0f 100644 --- a/server/middlewares/verifyResourcePolicyAccess.ts +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 380b82048..3264a3bd9 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -132,8 +132,7 @@ export async function verifyRoleAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts index 8f9c1ecaf..443483a28 100644 --- a/server/middlewares/verifySetResourceClients.ts +++ b/server/middlewares/verifySetResourceClients.ts @@ -45,8 +45,7 @@ export async function verifySetResourceClients( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceUsers.ts b/server/middlewares/verifySetResourceUsers.ts index 94600b9b4..cc9375e4a 100644 --- a/server/middlewares/verifySetResourceUsers.ts +++ b/server/middlewares/verifySetResourceUsers.ts @@ -40,8 +40,7 @@ export async function verifySetResourceUsers( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index c4d35a52f..50a940855 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts index 73393e1e9..9cb9a28f3 100644 --- a/server/middlewares/verifySiteProvisioningKeyAccess.ts +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index 8d5bd656f..c87518a9e 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -103,8 +103,7 @@ export async function verifySiteResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 8bbed6fca..24b8abd22 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -122,8 +122,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index fcc4d0cb9..83c344ae0 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -59,8 +59,7 @@ export async function verifyUserAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index b861c1ae6..9a03f9e09 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -21,6 +21,49 @@ import { } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; +function formatMaxSessionLengthRequirement( + maxSessionLengthHours: number +): string { + if (maxSessionLengthHours < 24) { + return `This organization requires you to log in every ${maxSessionLengthHours} hours.`; + } + + const maxDays = Math.round(maxSessionLengthHours / 24); + return `This organization requires you to log in every ${maxDays} days.`; +} + +function buildOrgAccessPolicyError( + policies: CheckOrgAccessPolicyResult["policies"] +): string | undefined { + if (!policies) { + return undefined; + } + + const errors: string[] = []; + + if (policies.requiredTwoFactor === false) { + errors.push( + "This organization requires two-factor authentication. Enable two-factor authentication on your account to continue." + ); + } + + if (policies.maxSessionLength?.compliant === false) { + errors.push( + `Your session has expired. ${formatMaxSessionLengthRequirement( + policies.maxSessionLength.maxSessionLengthHours + )}` + ); + } + + if (policies.passwordAge?.compliant === false) { + errors.push( + `Your password has expired. This organization requires you to change your password every ${policies.passwordAge.maxPasswordAgeDays} days.` + ); + } + + return errors.length > 0 ? errors.join(" ") : undefined; +} + export function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org @@ -36,13 +79,17 @@ export function enforceResourceSessionLength( if (sessionAgeMs > maxSessionLengthMs) { return { valid: false, - error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session has expired. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } else { return { valid: false, - error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } @@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy( if (!orgId) { return { allowed: false, - error: "Organization ID is required" + error: "Unable to verify organization access. Organization information is missing." }; } if (!userId) { - return { allowed: false, error: "User ID is required" }; + return { + allowed: false, + error: "Unable to verify organization access. User information is missing." + }; } if (!sessionId) { - return { allowed: false, error: "Session ID is required" }; + return { + allowed: false, + error: "Your session is invalid. Please log in again." + }; } if (build === "enterprise") { @@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { allowed: false, error: "Organization not found" }; + return { + allowed: false, + error: "This organization could not be found." + }; } } @@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { allowed: false, error: "User not found" }; + return { + allowed: false, + error: "Your account could not be found." + }; } } @@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy( .where(eq(sessions.sessionId, sessionId)); props.session = sessionQuery; if (!props.session) { - return { allowed: false, error: "Session not found" }; + return { + allowed: false, + error: "Your session has expired. Please log in again." + }; } } if (props.session.userId !== props.user.userId) { return { allowed: false, - error: "Session does not belong to the user" + error: "Your session is invalid. Please log in again." }; } @@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy( allowed = false; } + const policyError = buildOrgAccessPolicyError(policies); + return { allowed, - policies + policies, + error: allowed + ? undefined + : (policyError ?? + "You do not meet this organization's security requirements.") }; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 1a26b9117..256396763 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; -import { invalidateAllSessions } from "@server/auth/sessions/app"; -import { sessions, resourceSessions } from "@server/db"; -import { and, eq, ne, inArray } from "drizzle-orm"; +import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app"; +import { eq } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { sendEmail } from "@server/emails"; @@ -31,48 +30,6 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; -async function invalidateAllSessionsExceptCurrent( - userId: string, - currentSessionId: string -): Promise { - try { - await db.transaction(async (trx) => { - // Get all user sessions except the current one - const userSessions = await trx - .select() - .from(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - - // Delete resource sessions for the sessions we're invalidating - if (userSessions.length > 0) { - await trx.delete(resourceSessions).where( - inArray( - resourceSessions.userSessionId, - userSessions.map((s) => s.sessionId) - ) - ); - } - - // Delete the user sessions (except current) - await trx - .delete(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - }); - } catch (e) { - logger.error("Failed to invalidate user sessions except current", e); - } -} - export async function changePassword( req: Request, res: Response, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 207287ea0..5fc6cf13c 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { generateBackupCodes } from "@server/lib/totp"; +import { + invalidateAllSessions, + invalidateAllSessionsExceptCurrent +} from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -168,6 +172,15 @@ export async function verifyTotp( ); } + if (existingSession) { + await invalidateAllSessionsExceptCurrent( + user.userId, + existingSession.sessionId + ); + } else { + await invalidateAllSessions(user.userId); + } + sendEmail( TwoFactorAuthNotification({ email: user.email!, diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 23151d534..0561fadbf 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -80,8 +80,7 @@ export async function getExchangeToken( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (hasAccess.error || "Unknown error") + "" + (hasAccess.error || "Unknown error") ) ); } From a48032adb3faeb111037c4f21339537d47256378 Mon Sep 17 00:00:00 2001 From: mr1beast Date: Wed, 24 Jun 2026 21:25:57 +0000 Subject: [PATCH 113/264] New translations en-us.json (Danish) New translations en-us.json (Danish) --- messages/da-DK.json | 3600 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3600 insertions(+) create mode 100644 messages/da-DK.json diff --git a/messages/da-DK.json b/messages/da-DK.json new file mode 100644 index 000000000..cdf209ed0 --- /dev/null +++ b/messages/da-DK.json @@ -0,0 +1,3600 @@ +{ + "contactSalesEnable": "Kontakt salgsafdelingen for at aktivere denne funktion.", + "contactSalesBookDemo": "Book en demo", + "contactSalesOr": "eller", + "contactSalesContactUs": "kontakt os", + "setupCreate": "Opret organisationen, sitet og ressourcerne", + "headerAuthCompatibilityInfo": "Aktivér dette for at fremtvinge et 401 Uautoriseret-svar, når der mangler et autentificeringstoken. Dette kræves for browsere eller specifikke HTTP-biblioteker, der ikke sender legitimationsoplysninger uden en serverchallenge.", + "headerAuthCompatibility": "Udvidet kompatibilitet", + "setupNewOrg": "Ny organisation", + "setupCreateOrg": "Opret organisation", + "setupCreateResources": "Opret ressourcer", + "setupOrgName": "Organisationsnavn", + "orgDisplayName": "Dette er organisationens visningsnavn.", + "orgId": "Organisations-ID", + "setupIdentifierMessage": "Dette er den unikke identifikator for organisationen.", + "setupErrorIdentifier": "Organisations-ID'et er allerede taget. Vælg venligst en anden.", + "componentsErrorNoMemberCreate": "Du er i øjeblikket ikke medlem af nogen organisationer. Opret en organisation for at komme i gang.", + "componentsErrorNoMember": "Du er i øjeblikket ikke medlem af nogen organisationer.", + "welcome": "Velkommen!", + "welcomeTo": "Velkommen til", + "componentsCreateOrg": "Opret en organisation", + "componentsMember": "Du er {count, plural, =0 {ikke medlem af nogen organisationer} one {medlem af en organisation} other {medlem af # organisationer}}.", + "componentsInvalidKey": "Ugyldig eller udløbet licensnøgle registreret. Følg licensvilkårene for fortsat at kunne bruge alle funktionerne.", + "dismiss": "Afvis", + "subscriptionViolationMessage": "Du er uden for grænsen for din nuværende plan. Ret problemet ved at fjerne sites, brugere eller andre ressourcer, så du igen er inden for din plan.", + "trialBannerMessage": "Din prøveperiode udløber om {countdown}. Opgrader for at bevare adgangen.", + "trialBannerExpired": "Din prøveperiode er udløbet. Opgrader nu for at gendanne adgangen.", + "billingTrialBannerTitle": "Prøveversion aktiv", + "billingTrialBannerDescription": "Du har i øjeblikket en gratis prøveversion på Business-niveauet. Når prøveperioden slutter, vil din konto automatisk gå tilbage til funktioner og begrænsninger på Basis-niveauet. Opgrader når som helst for at beholde adgang til de den nuværende plans funktioner.", + "billingTrialBannerUpgrade": "Opgrader nu", + "billingTrialBadge": "Prøveversion", + "trialActive": "Gratis prøveversion aktiv", + "trialExpired": "Prøveperioden er udløbet", + "trialHasEnded": "Din prøveperiode har afsluttet.", + "trialDaysRemaining": "{count, plural, one {# dag tilbage} other {# dage tilbage}}", + "trialDaysLeftShort": "{days}d tilbage af prøveperioden", + "trialGoToBilling": "Gå til faktureringssiden", + "subscriptionViolationViewBilling": "Vis fakturering", + "componentsLicenseViolation": "Licensbrud: Denne server bruger {usedSites} sites, hvilket overskrider den licenserede grænse på {maxSites} sites. Følg licensvilkårene for fortsat at kunne bruge alle funktionerne.", + "componentsSupporterMessage": "Tak fordi du støtter Pangolin som {tier}!", + "inviteErrorNotValid": "Beklager, men det ser ud som invitationen du prøver at bruge ikke er blevet accepteret eller ikke er gyldig længere.", + "inviteErrorUser": "Vi beklager, men det ser ud til, at invitationen, du prøver at få adgang til, ikke er til denne bruger.", + "inviteLoginUser": "Venligst tjek at du er loget ind som korrekt bruger.", + "inviteErrorNoUser": "Beklager, men det ser ud til, at invitationen du prøver at få adgang til, ikke er til en eksisterende bruger.", + "inviteCreateUser": "Opret venligst en konto først.", + "goHome": "Gå hjem", + "inviteLogInOtherUser": "Log ind som en anden bruger", + "createAnAccount": "Opret konto", + "inviteNotAccepted": "Invitationen ikke accepteret", + "authCreateAccount": "Opret en konto for at komme i gang", + "authNoAccount": "Har du ikke en konto?", + "email": "E-mail", + "password": "Adgangskode", + "confirmPassword": "Bekræft adgangskode", + "createAccount": "Opret konto", + "viewSettings": "Vis indstillinger", + "delete": "Slet", + "name": "Navn", + "online": "Online", + "offline": "Offline", + "site": "Site", + "dataIn": "Data ind", + "dataOut": "Data ud", + "connectionType": "Forbindelsestype", + "tunnelType": "Tunneltype", + "local": "Lokal", + "edit": "Rediger", + "siteConfirmDelete": "Bekræft Sletning af Site", + "siteDelete": "Slet Site", + "siteMessageRemove": "Når sitet er fjernet, vil det ikke længere være tilgængeligt. Alle mål for sitet vil også blive fjernet.", + "siteQuestionRemove": "Er du sikker på at du vil fjerne sitet fra organisationen?", + "siteManageSites": "Administrer Sites", + "siteDescription": "Oprette og administrer sites for at aktivere forbindelse til private netværk", + "sitesBannerTitle": "Opret forbindelse til alle netværk", + "sitesBannerDescription": "Et netværk er en forbindelse til et eksternt netværk som tillader Pangolin at give adgang til ressourcer, enten offentlige eller private, til brugere hvor som helst. Installer netværksconnectoren (Newt) hvor som helst du kan køre en binærfil eller container for at oprette forbindelsen.", + "sitesBannerButtonText": "Installer site", + "approvalsBannerTitle": "Godkend eller afvis adgang til enhed", + "approvalsBannerDescription": "Gennemgå og godkend eller afvis anmodninger om adgang fra brugere. Når enhedsgodkendelser er påkrævet, skal brugere have administratorgodkendelse, før deres enheder kan oprette forbindelse til organisationens ressourcer.", + "approvalsBannerButtonText": "Læs mere", + "siteCreate": "Opret site", + "siteCreateDescription2": "Følg trinene nedenfor for at oprette og opret forbindelse til et nyt site", + "siteCreateDescription": "Opret et nyt site for at forbinde til ressourcer", + "close": "Luk", + "siteErrorCreate": "Fejl ved oprettelse af site", + "siteErrorCreateKeyPair": "Nøglepar eller standardindstillinger for site ikke fundet", + "siteErrorCreateDefaults": "Standardindstillinger for site ikke fundet", + "method": "Metode", + "siteMethodDescription": "Sådan eksponerer du forbindelser.", + "siteLearnNewt": "Lær hvordan du installerer Newt på dit system", + "siteSeeConfigOnce": "Du kan kun se konfigurationen én gang.", + "siteLoadWGConfig": "Indlæser WireGuard-konfiguration...", + "siteDocker": "Udvid for detaljer om Docker-deployment", + "toggle": "Skift", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "Lokale sites tunnelerer ikke, læs mere", + "siteConfirmCopy": "Jeg har kopieret konfigurationen", + "searchSitesProgress": "Søger i sites...", + "siteAdd": "Tilføj site", + "sitesTableViewPublicResources": "Vis offentlige ressourcer", + "sitesTableViewPrivateResources": "Vis private ressourcer", + "siteInstallNewt": "Installer Newt", + "siteInstallNewtDescription": "Få Newt til at køre på dit system", + "siteInstallKubernetesDocsDescription": "For mere og opdateret information om Kubernetes-installation, se docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "For installationsvejledning til Advantech-modemmer, se docs.pangolin.net/manage/sites/install-advantech.", + "WgConfiguration": "WireGuard Konfiguration", + "WgConfigurationDescription": "Brug følgende konfiguration til at oprette forbindelse til netværket.", + "operatingSystem": "Operativsystem", + "commands": "Kommandoer", + "recommended": "Anbefalet", + "siteNewtDescription": "For den beste brugeroplevelsen, brug Newt. Den bruger WireGuard i bakgrunnen og lader dig adressere dine private ressourcer med deres LAN-adresse på dit private netværk fra Pangolin-dashbordet.", + "siteRunsInDocker": "Kører i Docker", + "siteRunsInShell": "Kører i shell på macOS, Linux og Windows", + "siteErrorDelete": "Fejl ved sletning af sitet", + "siteErrorUpdate": "Kunne ikke opdatere sitet", + "siteErrorUpdateDescription": "En fejl opstod under opdatering af sitet.", + "siteUpdated": "Site opdateret", + "siteUpdatedDescription": "Sitet er blevet opdateret.", + "siteGeneralDescription": "Konfigurer de generelle indstillinger for dette site", + "siteSettingDescription": "Konfigurer indstillingerne for sitet", + "siteResourcesTab": "Ressourcer", + "siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.", + "siteResourcesSectionPublic": "Offentlige ressourcer", + "siteResourcesSectionPrivate": "Private ressourcer", + "siteResourcesSectionPublicDescription": "Ressourcer eksponert eksternt gennem domæner eller porte.", + "siteResourcesSectionPrivateDescription": "Ressourcer, der er tilgængelige på dit private netværk gennem sitet.", + "siteResourcesViewAllPublic": "Vis alle ressourcer", + "siteResourcesViewAllPrivate": "Vis alle ressourcer", + "siteResourcesDialogDescription": "Oversigt over offentlige og private ressourcer tilknyttet dette site.", + "siteResourcesShowMore": "Vis mere", + "siteResourcesPermissionDenied": "Du har ikke tilladelse til at liste disse ressourcer.", + "siteResourcesEmptyPublic": "Ingen offentlige ressourcer peger på dette site endnu.", + "siteResourcesEmptyPrivate": "Ingen private ressourcer er tilknyttet dette site endnu.", + "siteResourcesHowToAccess": "Hvordan få adgang", + "siteResourcesTargetsOnSite": "Mål på dette site", + "siteSetting": "{siteName} Indstillinger", + "siteNewtTunnel": "Newt-site (anbefalet)", + "siteNewtTunnelDescription": "Lekkeste måte at oprette et indgangspunkt til ethvert netværk. Ingen ekstra opsætning på.", + "siteWg": "Grunnleggende WireGuard", + "siteWgDescription": "Brug en hvilken som helst WireGuard-klient til at etablere en tunnel. Manuel NAT-opsætning er påkrævet.", + "siteWgDescriptionSaas": "Brug en hvilken som helst WireGuard-klient til at etablere en tunnel. Manuel NAT-opsætning er påkrævet. VIRKER KUN PÅ SELVHOSTEDE NODER", + "siteLocalDescription": "Kun lokale ressourcer. Ingen tunnelering.", + "siteLocalDescriptionSaas": "Lokale ressourcer. Ingen tunnelering. Kun tilgængelig på eksterne noder.", + "siteSeeAll": "Se alle sites", + "siteTunnelDescription": "Afgør, hvordan du vil oprette forbindelse til sitet", + "siteNewtCredentials": "Legitimationsoplysninger", + "siteNewtCredentialsDescription": "Sådan godkender sitet sig mod serveren", + "remoteNodeCredentialsDescription": "Sådan godkender den eksterne node sig mod serveren", + "siteCredentialsSave": "Gem brugeroplysninger", + "siteCredentialsSaveDescription": "Du vil kun kunne se dette én gang. Sørg for at kopiere det til et sikkert sted.", + "siteInfo": "Områdeinformation", + "status": "Status", + "shareTitle": "Administrer delbare links", + "shareDescription": "Opret delbare links for at give midlertidig eller permanent adgang til proxyressourcer", + "shareSearch": "Søg delbare links...", + "shareCreate": "Opret delbar link", + "shareErrorDelete": "Kunne ikke slette linket", + "shareErrorDeleteMessage": "En fejl opstod ved sletning af link", + "shareDeleted": "Link slettet", + "shareDeletedDescription": "Linket er blevet slettet", + "shareDelete": "Slet delbar link", + "shareDeleteConfirm": "Bekræft sletning af delbar link", + "shareQuestionRemove": "Er du sikker på, at du vil slette dette delingslink?", + "shareMessageRemove": "Når linket er slettet, vil det ikke længere fungere, og alle, der bruger det, mister adgang til ressourcen.", + "shareTokenDescription": "Adgangstoken kan sendes på to måter: som en queryparameter eller i request-headers. Disse skal sendes fra klienten på hver forespørgsel om autentificeret adgang.", + "accessToken": "Adgangstoken", + "usageExamples": "Brukseksempler", + "tokenId": "Token-ID", + "requestHeades": "Request headers", + "queryParameter": "Forespørgselsparametre", + "importantNote": "Vigtig bemærkning", + "shareImportantDescription": "Af sikkerhedshensyn anbefales det at bruge headers frem for queryparametre der det er muligt, da queryparametre kan loges i serverlogs eller browserhistorik.", + "token": "Token", + "shareTokenSecurety": "Hold adgangstoken sikker. Ikke del den i offentlig tilgængelige sites eller klientsidekode.", + "shareErrorFetchResource": "Kunne ikke hente ressourcer", + "shareErrorFetchResourceDescription": "En fejl opstod under hentning af ressourcerne", + "shareErrorCreate": "Mislykkedes med at oprette delingslenke", + "shareErrorCreateDescription": "Det opstod en fejl ved oprettelse af delingslinket", + "shareCreateDescription": "Alle med denne linket får adgang til ressourcen", + "shareTitleOptional": "Titel (valgfrit)", + "sharePathOptional": "Sti (valgfrit)", + "sharePathDescription": "Linket vil videresende brugere til denne stien efter autentificering.", + "expireIn": "Udløber om", + "neverExpire": "Udløber aldrig", + "shareExpireDescription": "Udløbstid er hvor længe linket vil være brukbar og give adgang til ressourcen. Efter denne tiden vil linket ikke længere fungere, og brugere som brugte denne linket vil miste adgangen til ressourcen.", + "shareSeeOnce": "Du vil kun kunne se dette link én gang. Sørg for at kopiere det.", + "shareAccessHint": "Alle med denne linket kan få adgang til ressourcen. Del forsiktig.", + "shareTokenUsage": "Se brug af adgangstoken", + "createLink": "Opret link", + "resourcesNotFound": "Ingen ressourcer fundet", + "resourceSearch": "Søg i ressourcer", + "machineSearch": "Søg efter maskiner", + "machinesSearch": "Søg efter maskinklienter...", + "machineNotFound": "Ingen maskiner fundet", + "userDeviceSearch": "Søg efter brugerenheder", + "userDevicesSearch": "Søg efter brugerenheder...", + "openMenu": "Åbn menu", + "resource": "Ressource", + "title": "Titel", + "created": "Oprettet", + "expires": "Udløber", + "never": "Aldrig", + "shareErrorSelectResource": "Vælg venligst en ressource", + "proxyResourceTitle": "Administrer offentlige ressourcer", + "proxyResourceDescription": "Opret og administrer ressourcer som er offentlig tilgængelige via en browser", + "publicResourcesBannerTitle": "Web-baseret offentlig adgang", + "publicResourcesBannerDescription": "Offentlige ressourcer er HTTPS-proxyer som er tilgængelige for alle på internettet via en browser. I modsætning til private ressourcer, kræver de ikke klientsoftware og kan inkludere identitets- og kontekstsensitive adgangspolitikker.", + "clientResourceTitle": "Administrer private ressourcer", + "clientResourceDescription": "Oprette og administrer ressourcer som kun er tilgængelige via en tilsluttet klient", + "privateResourcesBannerTitle": "Zero-Trust privat adgang", + "privateResourcesBannerDescription": "Private ressourcer bruger Zero-Trust-sikkerhed og sikrer, at brugere og maskiner kun kan få adgang til ressourcer, du eksplicit giver tilladelse til. Tilslut brugerenheder eller maskinklienter for at få adgang til disse ressourcer via et sikkert virtuelt privat netværk.", + "resourcesSearch": "Søg i ressourcer...", + "resourceAdd": "Tilføj ressource", + "resourceErrorDelte": "Fejl ved sletning af ressource", + "resourcePoliciesBannerTitle": "Genbrug autentificering og adgangsregler", + "resourcePoliciesBannerDescription": "Delte ressourcepolitikker lader dig definere autentificeringsmetoder og adgangsregler én gang og derefter knytte dem til flere offentlige ressourcer. Når du opdaterer en politik, arver alle tilknyttede ressourcer automatisk ændringen.", + "resourcePoliciesBannerButtonText": "Læs mere", + "resourcePoliciesTitle": "Administrer offentlige ressourcepolitikker", + "resourcePoliciesAttachedResourcesColumnTitle": "Ressourcer", + "resourcePoliciesAttachedResources": "{count} ressource(er)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ressource} other {# ressourcer}}", + "resourcePoliciesAttachedResourcesEmpty": "ingen ressourcer", + "resourcePoliciesDescription": "Opret og administrer autentiseringsretningslinjer for at kontrollere adgang til dine offentlige ressourcer", + "resourcePoliciesSearch": "Søg efter regler...", + "resourcePoliciesAdd": "Tilføj politik", + "resourcePoliciesDefaultBadgeText": "Standardpolitik", + "resourcePoliciesCreate": "Opret offentlig ressourcepolitik", + "resourcePoliciesCreateDescription": "Følg trinene nedenfor for at oprette en ny politik", + "resourcePolicyName": "Politiknavn", + "resourcePolicyNameDescription": "Give denne politiknavnet for at identifisere den på tværs af dine ressourcer", + "resourcePolicyNamePlaceholder": "f.eks. Intern Adgangspolitik", + "resourcePoliciesSeeAll": "Se Alle Politikker", + "resourcePolicyAuthMethodAdd": "Tilføj Autentificeringsmetode", + "resourcePolicyOtpEmailAdd": "Tilføj OTP-e-mails", + "resourcePolicyRulesAdd": "Tilføj Regler", + "resourcePolicyAuthMethodsDescription": "Tillad adgang til ressourcer via yderligere autentificeringsmetoder", + "resourcePolicyUsersRolesDescription": "Konfigurer hvilke brugere og roller, der kan besøge tilknyttede ressourcer", + "rulesResourcePolicyDescription": "Konfigurer regler for at kontrollere adgangen til ressourcer som er knyttet til denne politikken", + "authentication": "Autentificering", + "protected": "Beskyttet", + "notProtected": "Ikke beskyttet", + "resourceMessageRemove": "Når den er fjernet, vil ressourcen ikke længere være tilgængeligt. Alle mål knyttet til ressourcen vil også blive fjernet.", + "resourceQuestionRemove": "Er du sikker på at du vil fjerne ressourcen fra organisationen?", + "resourcePolicyMessageRemove": "Når ressourcepolitikken er fjernet, vil den ikke længere være tilgængelig. Alle tilknyttede ressourcer vil blive frakoblet og stå uden autentificering.", + "resourcePolicyQuestionRemove": "Er du sikker på at du vil fjerne ressourcepolitikken fra organisationen?", + "resourceHTTP": "HTTPS-ressource", + "resourceHTTPDescription": "Proxyforespørgsler over HTTPS ved at bruge et fuldt kvalificeret domænenavn.", + "resourceRaw": "Rå TCP/UDP-ressource", + "resourceRawDescription": "Proxyforespørgsler over rå TCP/UDP ved at bruge et portnummer.", + "resourceRawDescriptionCloud": "Proxyforespørgsler over rå TCP/UDP ved hjælp af et portnummer. Kræver, at sites opretter forbindelse til en ekstern node.", + "resourceCreate": "Opret ressource", + "resourceCreateDescription": "Følg trinene nedenfor for at oprette en ny ressource", + "resourceCreateGeneralDescription": "Konfigurer de grunnleggende ressourceindstillingerne inklusive navnet og typen", + "resourceSeeAll": "Se alle ressourcer", + "resourceCreateGeneral": "Generelt", + "resourceNameDescription": "Dette er visningsnavnet for ressourcen.", + "siteSelect": "Vælg site", + "siteSearch": "Søg i site", + "siteNotFound": "Ingen sites fundet.", + "selectCountry": "Vælg land", + "searchCountries": "Søg land...", + "noCountryFound": "Ingen land fundet.", + "siteSelectionDescription": "Dette site vil give forbindelse til målet.", + "resourceType": "Ressourcetype", + "resourceTypeDescription": "Dette styrer ressourceprotokolen, og hvordan den vises i browseren. Dette kan ikke ændres senere.", + "resourceDomainDescription": "Ressourcen vil blive serveret på dette fuldstændigt kvalificerede domænenavnet.", + "resourceHTTPSSettings": "HTTPS-indstillinger", + "resourceHTTPSSettingsDescription": "Konfigurer hvordan ressourcen skal nås over HTTPS", + "resourcePortDescription": "Den eksterne port på Pangolin-instansen eller noden, hvor ressourcen vil være tilgængelig.", + "domainType": "Domænetype", + "subdomain": "Underdomæne", + "baseDomain": "Basisdomæne", + "configure": "Konfigurer", + "subdomnainDescription": "Underdomænet hvor ressourcen vil være tilgængeligt.", + "resourceRawSettings": "TCP/UDP-indstillinger", + "resourceRawSettingsDescription": "Konfigurer hvordan ressourcen vil blive tilgængelig over TCP/UDP", + "protocol": "Protokol", + "protocolSelect": "Vælg en protokol", + "resourcePortNumber": "Portnummer", + "resourcePortNumberDescription": "Det eksterne portnummeret for proxyforespørgsler.", + "back": "Tilbage", + "cancel": "Annuller", + "resourceConfig": "Konfigurationsuddrag", + "resourceConfigDescription": "Kopiér og indsæt disse konfigurationsuddrag for at opsætte TCP/UDP-ressourcen.", + "resourceAddEntrypoints": "Traefik: Tilføj indgangspunkter", + "resourceExposePorts": "Gerbil: Eksponer Porte i Docker Compose", + "resourceLearnRaw": "Lær hvordan at konfigurere TCP/UDP-ressourcer", + "resourceBack": "Tilbage til ressourcer", + "resourceGoTo": "Gå til ressource", + "resourcePolicyDelete": "Slet Ressourcepolitik", + "resourcePolicyDeleteConfirm": "Bekræft sletning af ressourcepolitik", + "resourceDelete": "Slet ressource", + "resourceDeleteConfirm": "Bekræft sletning af ressource", + "labelDelete": "Slet etikett", + "labelAdd": "Tilføj etikett", + "labelCreateSuccessMessage": "Etikett oprettet med succes", + "labelDuplicateError": "Dupliker etikett", + "labelDuplicateErrorDescription": "En etikett med dette navnet findes allerede.", + "labelEditSuccessMessage": "Etikett ændret med succes", + "labelNameField": "Etikettnavn", + "labelColorField": "Etikettfarge", + "labelPlaceholder": "Eksempel: homelab", + "labelCreate": "Opret etikett", + "createLabelDialogTitle": "Opret etikett", + "createLabelDialogDescription": "Opret en ny etikett, der kan knyttes til denne organisation", + "labelEdit": "Rediger etikett", + "editLabelDialogTitle": "Opdater etikett", + "editLabelDialogDescription": "Rediger en ny etikett, der kan knyttes til denne organisation", + "labelDeleteConfirm": "Bekræft sletning af etikett", + "labelErrorDelete": "Kunne ikke slette etiket", + "labelMessageRemove": "Denne handling er permanent. Alle sites, ressourcer og klienter, der er taget med denne etiket, får fjernet etiketten.", + "labelQuestionRemove": "Er du sikker på at du vil fjerne etiketten fra organisationen?", + "visibility": "Synlighed", + "enabled": "Aktiveret", + "disabled": "Deaktiveret", + "general": "Generelt", + "generalSettings": "Generelle indstillinger", + "proxy": "Proxy", + "internal": "Intern", + "rules": "Regler", + "resourceSettingDescription": "Konfigurere indstillingerne på ressourcen", + "resourceSetting": "{resourceName} Indstillinger", + "resourcePolicySettingDescription": "Konfigurer indstillingerne for denne offentlige ressourcepolitik", + "resourcePolicySetting": "{policyName} Indstillinger", + "alwaysAllow": "Omgå autentificering", + "alwaysDeny": "Bloker adgang", + "passToAuth": "Send til autentificering", + "orgSettingsDescription": "Konfigurere organisationens indstillinger", + "orgGeneralSettings": "Organisationsindstillinger", + "orgGeneralSettingsDescription": "Administrer organisationens detaljer og konfiguration", + "saveGeneralSettings": "Gem generelle indstillinger", + "saveSettings": "Gem indstillinger", + "orgDangerZone": "Farezone", + "orgDangerZoneDescription": "Når du sletter denne organisation, er der ingen vej tilbage. Vær helt sikker.", + "orgDelete": "Slet organisation", + "orgDeleteConfirm": "Bekræft Sletning af Organisation", + "orgMessageRemove": "Denne handling er irreversibel og vil slette alle tilknyttede data.", + "orgMessageConfirm": "For at bekræfte, indtast venligst navnet på organisationen nedenfor.", + "orgQuestionRemove": "Er du sikker på at du vil fjerne organisationen?", + "orgUpdated": "Organisation opdateret", + "orgUpdatedDescription": "Organisationen er blevet opdateret.", + "orgErrorUpdate": "Kunne ikke opdatere organisationen", + "orgErrorUpdateMessage": "En fejl opstod under opdatering af organisationen.", + "orgErrorFetch": "Kunne ikke hente organisationer", + "orgErrorFetchMessage": "Det opstod en fejl under visning af organisationerne dine", + "orgErrorDelete": "Kunne ikke slette organisation", + "orgErrorDeleteMessage": "Det opstod en fejl under sletning af organisationen.", + "orgDeleted": "Organisation slettet", + "orgDeletedMessage": "Organisationen og tilhørende data er slettet.", + "deleteAccount": "Slet konto", + "deleteAccountDescription": "Slet din konto permanent, alle organisationer du ejer, og alle data i disse organisationer. Dette kan ikke fortrydes.", + "deleteAccountButton": "Slet konto", + "deleteAccountConfirmTitle": "Slet konto", + "deleteAccountConfirmMessage": "Dette vil slette din konto, alle organisationer du ejer og alle data i disse organisationer. Dette kan ikke fortrydes.", + "deleteAccountConfirmString": "Slet konto", + "deleteAccountSuccess": "Kontoen er slettet", + "deleteAccountSuccessMessage": "Din konto er slettet.", + "deleteAccountError": "Kunne ikke slette konto", + "deleteAccountPreviewAccount": "Din konto", + "deleteAccountPreviewOrgs": "Organisationer du ejer (og alle deres data)", + "orgMissing": "Organisations-ID Mangler", + "orgMissingMessage": "Kan ikke regenerere invitation uden en organisations-ID.", + "accessUsersManage": "Administrer brugere", + "accessUserManage": "Administrer brugere", + "accessUsersDescription": "Inviter og administrer brugere med adgang til denne organisation", + "accessUsersSearch": "Søg efter brugere...", + "accessUsersRoleFilterCount": "{count, plural, one {# rolle} other {# roller}}", + "accessUsersRoleFilterClear": "Fjern rollesøgefiltre", + "accessUserCreate": "Opret bruger", + "accessUserRemove": "Fjern bruger", + "username": "Brugernavn", + "identityProvider": "Identitetsudbyder", + "role": "Rolle", + "nameRequired": "Navn er påkrævet", + "accessRolesManage": "Administrer Roller", + "accessRolesDescription": "Opret og administrer roller for brugere i organisationen", + "accessRolesSearch": "Søg efter roller...", + "accessRolesAdd": "Tilføj rolle", + "accessRoleDelete": "Slet rolle", + "accessApprovalsManage": "Administrer godkendelser", + "accessApprovalsDescription": "Se og administrer ventende godkendelser for adgang til denne organisation", + "description": "Beskrivelse", + "inviteTitle": "Åbne invitationer", + "inviteDescription": "Administrer invitationer til andre brugere for at blive med i organisationen", + "inviteSearch": "Søg i invitationer...", + "minutes": "Minutter", + "hours": "Timer", + "days": "Dage", + "weeks": "Uger", + "months": "Måneder", + "years": "År", + "day": "{count, plural, one {én dag} other {# dage}}", + "apiKeysTitle": "API-nøgleinformation", + "apiKeysConfirmCopy2": "Du skal bekræfte at du har kopieret API-nøglen.", + "apiKeysErrorCreate": "Fejl ved oprettelse af API-nøgle", + "apiKeysErrorSetPermission": "Fejl ved indstilling af tilladelser", + "apiKeysCreate": "Generer API-nøgle", + "apiKeysCreateDescription": "Generer en ny API-nøgle for organisationen", + "apiKeysGeneralSettings": "Tilladelser", + "apiKeysGeneralSettingsDescription": "Find ud af, hvad denne API-nøgle kan gøre", + "apiKeysList": "Ny API-nøgle", + "apiKeysSave": "Gem API-nøgle", + "apiKeysSaveDescription": "Du vil kun kunne se dette én gang. Sørg for at kopiere det til et sikkert sted.", + "apiKeysInfo": "API-nøglen er:", + "apiKeysConfirmCopy": "Jeg har kopieret API-nøglen", + "generate": "Generér", + "done": "Færdig", + "apiKeysSeeAll": "Se alle API-nøgler", + "apiKeysPermissionsErrorLoadingActions": "Fejl ved indlæsning af API-nøglehandlinger", + "apiKeysPermissionsErrorUpdate": "Fejl ved indstilling af tilladelser", + "apiKeysPermissionsUpdated": "Tilladelser opdateret", + "apiKeysPermissionsUpdatedDescription": "Tilladelserne er blevet opdateret.", + "apiKeysPermissionsGeneralSettings": "Tilladelser", + "apiKeysPermissionsGeneralSettingsDescription": "Bestem, hvad denne API-nøgle kan gøre", + "apiKeysPermissionsSave": "Gem tilladelser", + "apiKeysPermissionsTitle": "Tilladelser", + "apiKeys": "API-nøgler", + "searchApiKeys": "Søg API-nøgler", + "apiKeysAdd": "Generer API-nøgle", + "apiKeysErrorDelete": "Fejl under sletning af API-nøgle", + "apiKeysErrorDeleteMessage": "Fejl ved sletning af API-nøgle", + "apiKeysQuestionRemove": "Er du sikker på at du vil fjerne API-nøglen fra organisationen?", + "apiKeysMessageRemove": "Når den er fjernet, vil API-nøglen ikke længere kunne bruges.", + "apiKeysDeleteConfirm": "Bekræft sletning af API-nøgle", + "apiKeysDelete": "Slet API-nøgle", + "apiKeysManage": "Administrer API-nøgler", + "apiKeysDescription": "API-nøgler bruges for at autentificere med integrasjons-API", + "provisioningKeysTitle": "Provisioneringsnøgle", + "provisioningKeysManage": "Administrer provisioneringsnøgler", + "provisioningKeysDescription": "Provisioneringsnøgler bruges til at godkende automatiseret site-provisionering for din organisation.", + "provisioningManage": "Provisionering", + "provisioningDescription": "Administrer provisioneringsnøgler og gennemgå ventende sites som venter på godkendelse.", + "pendingSites": "Ventende sites", + "siteApproveSuccess": "Med succes godkendelse af site", + "siteApproveError": "Fejl ved godkendelse af side", + "provisioningKeys": "Provisioneringsnøgler", + "searchProvisioningKeys": "Søg varer i provisioneringsnøgler...", + "provisioningKeysAdd": "Generer provisioneringsnøgle", + "provisioningKeysErrorDelete": "Fejl under sletning af provisioneringsnøgle", + "provisioningKeysErrorDeleteMessage": "Fejl under sletning af provisioneringsnøgle", + "provisioningKeysQuestionRemove": "Er du sikker på at du vil fjerne denne midlertidig nøglen fra organisationen?", + "provisioningKeysMessageRemove": "Når nøglen er fjernet, kan den ikke længere bruges til site-provisionering.", + "provisioningKeysDeleteConfirm": "Bekræft sletning af provisioneringsnøgle", + "provisioningKeysDelete": "Slet provisioneringsnøgle", + "provisioningKeysCreate": "Generer provisioneringsnøgle", + "provisioningKeysCreateDescription": "Generer en ny provisioneringsnøgle til organisationen", + "provisioningKeysSeeAll": "Se alle provisioneringsnøgler", + "provisioningKeysSave": "Gem den midlertidig nøglen", + "provisioningKeysSaveDescription": "Du kan kun se denne én gang. Kopiér det til et sikkert sted.", + "provisioningKeysErrorCreate": "Fejl under oprettelse af provisioneringsnøgle", + "provisioningKeysList": "Ny provisioneringsnøgle", + "provisioningKeysMaxBatchSize": "Maks størrelse på bunt", + "provisioningKeysUnlimitedBatchSize": "Ubegrænset mengde bunt (ingen begrænsning)", + "provisioningKeysMaxBatchUnlimited": "Ubegrænset", + "provisioningKeysMaxBatchSizeInvalid": "Angiv en gyldig sjakkstørrelse (1–1 000.000).", + "provisioningKeysValidUntil": "Gyldig til", + "provisioningKeysValidUntilHint": "Lad stå tomt for ingen udløb.", + "provisioningKeysValidUntilInvalid": "Angiv en gyldig dato og et gyldigt klokkeslæt.", + "provisioningKeysNumUsed": "Antal ganger brugt", + "provisioningKeysLastUsed": "Sidst brugt", + "provisioningKeysNoExpiry": "Ingen udløbsdato", + "provisioningKeysNeverUsed": "Aldrig", + "provisioningKeysEdit": "Rediger provisioneringsnøgle", + "provisioningKeysEditDescription": "Opdater maksimal størrelse for bunt og udløbstid for denne nøglen.", + "provisioningKeysApproveNewSites": "Godkend nye sites", + "provisioningKeysApproveNewSitesDescription": "Godkend automatisk sites som registrerer dig med denne nøglen.", + "provisioningKeysUpdateError": "Fejl under opdatering af provisioneringsnøgle", + "provisioningKeysUpdated": "Foreslå nøgle opdateret", + "provisioningKeysUpdatedDescription": "Dine ændringer er gemt.", + "provisioningKeysBannerTitle": "Provisioneringsnøgler til sites", + "provisioningKeysBannerDescription": "Generér en provisioneringsnøgle, og brug den med Newt-connectoren til automatisk oprettelse af sites ved første opstart — uden behov for separate legitimationsoplysninger til hvert site.", + "provisioningKeysBannerButtonText": "Læs mere", + "pendingSitesBannerTitle": "Ventende sites", + "pendingSitesBannerDescription": "Sites som opretter forbindelse ved brug af en provisioneringsnøgle vises her for vurdering.", + "pendingSitesBannerButtonText": "Læs mere", + "apiKeysSettings": "{apiKeyName} Indstillinger", + "userTitle": "Administrer alle brugere", + "userDescription": "Vis og administrer alle brugere i systemet", + "userAbount": "Om brugeradministration", + "userAbountDescription": "Denne tabel viser alle basisbrugerobjekter i systemet. Hver bruger kan tilhøre flere organisationer. Når du fjerner en bruger fra en organisation, slettes brugerens basisbrugerobjekt ikke – brugeren forbliver i systemet. For at fjerne en bruger helt fra systemet skal du slette basisbrugerobjektet med slet-handlingen i denne tabel.", + "userServer": "Serverbrugere", + "userSearch": "Søg serverbrugere...", + "userErrorDelete": "Fejl ved sletning af bruger", + "userDeleteConfirm": "Bekræft sletning af bruger", + "userDeleteServer": "Slet bruger fra server", + "userMessageRemove": "Brugeren vil blive fjernet fra alle organisationer og vil blive fuldstændigt fjernet fra serveren.", + "userQuestionRemove": "Er du sikker på at du vil slette brugeren permanent fra serveren?", + "licenseKey": "Licensnøgle", + "valid": "Gyldig", + "numberOfSites": "Antal sites", + "licenseKeySearch": "Søg licensnøgler...", + "licenseKeyAdd": "Tilføj licensnøgle", + "type": "Type", + "licenseKeyRequired": "Licensnøgle er påkrævet", + "licenseTermsAgree": "Du skal acceptere licensvilkårene", + "licenseErrorKeyLoad": "Fejl ved indlæsning af licensnøgler", + "licenseErrorKeyLoadDescription": "Det opstod en fejl ved indlæsning af licensnøgler.", + "licenseErrorKeyDelete": "Kunne ikke slette licensnøgle", + "licenseErrorKeyDeleteDescription": "Det opstod en fejl ved sletning af licensnøgle.", + "licenseKeyDeleted": "Licensnøgle slettet", + "licenseKeyDeletedDescription": "Licensnøglen er blevet slettet.", + "licenseErrorKeyActivate": "Aktivering af licensnøgle mislykkedes", + "licenseErrorKeyActivateDescription": "Det opstod en fejl under aktivering af licensnøglen.", + "licenseAbout": "Om Licensering", + "licenseBannerTitle": "Aktivér din enterprise-licens", + "licenseBannerDescription": "Lås op for virksomhedsfunktioner på din selvhostede Pangolin-instans. Køb en licensnøgle for at aktivere premium-funktioner, og indtast den nedenfor.", + "licenseBannerGetLicense": "Få en licens", + "licenseBannerViewDocs": "Vis dokumentation", + "communityEdition": "Community-udgave", + "licenseAboutDescription": "Dette er for virksomheds- og enterprise-brugere som bruger Pangolin i et kommercielt miljø. Hvis du bruger Pangolin til personlig brug, kan du ignorere denne sektionen.", + "licenseKeyActivated": "Licensnøgle aktiveret", + "licenseKeyActivatedDescription": "Licensnøglen er blevet aktiveret.", + "licenseErrorKeyRecheck": "En fejl opstod under bekræftelse af licensnøgler", + "licenseErrorKeyRecheckDescription": "Det opstod en fejl under bekræftelse af licensnøgler.", + "licenseErrorKeyRechecked": "Licensnøgler verificeret", + "licenseErrorKeyRecheckedDescription": "Alle licensnøgler er verificeret", + "licenseActivateKey": "Aktivér licensnøgle", + "licenseActivateKeyDescription": "Indtast en licensnøgle for at aktivere den.", + "licenseActivate": "Aktivér licens", + "licenseAgreement": "Ved at markere dette felt bekræfter du, at du har læst og accepterer de licensvilkår, der svarer til niveauet knyttet til din licensnøgle.", + "fossorialLicense": "Vis Fossorial kommerciel licens og abonnementsvilkår", + "licenseMessageRemove": "Dette vil fjerne licensnøglen og alle tilknyttede tilladelser givet af den.", + "licenseMessageConfirm": "For at bekræfte, indtast venligst licensnøglen nedenfor.", + "licenseQuestionRemove": "Er du sikker på at du vil slette licensnøglen?", + "licenseKeyDelete": "Slet Licensnøgle", + "licenseKeyDeleteConfirm": "Bekræft sletning af licensnøgle", + "licenseTitle": "Administrer licensstatus", + "licenseTitleDescription": "Se og administrer licensnøgler i systemet", + "licenseHost": "Hostlicens", + "licenseHostDescription": "Administrer hovedlicensnøglen for verten.", + "licensedNot": "Ikke licenseret", + "hostId": "Host-ID", + "licenseReckeckAll": "Bekræft alle nøgler", + "licenseSiteUsage": "Site Brug", + "licenseSiteUsageDecsription": "Vis antal sites som bruger denne licensen.", + "licenseNoSiteLimit": "Der er ingen grænse for antal sites, der bruger en ulicenseret host.", + "licensePurchase": "Køb licens", + "licensePurchaseSites": "Køb flere sites", + "licenseSitesUsedMax": "{usedSites} af {maxSites} sites brugt", + "licenseSitesUsed": "{count, plural, =0 {ingen sites} one {ét site} other {# sites}} i systemet.", + "licensePurchaseDescription": "Vælg hvor mange sites du vil {selectedMode, select, license {købe en licens for. Du kan altid tilføje flere sites senere.} other {tilføje din eksisterende licens.}}", + "licenseFee": "Licensafgift", + "licensePriceSite": "Pris per site", + "total": "I alt", + "licenseContinuePayment": "Fortsæt til betaling", + "pricingPage": "Prisoversigt", + "pricingPortal": "Se Købsportal", + "licensePricingPage": "For de mest opdaterede priser og rabatterne, venligst besøg", + "invite": "Invitationer", + "inviteRegenerate": "Regenerer invitationen", + "inviteRegenerateDescription": "Tilbagekald tidligere invitation og oprette en ny", + "inviteRemove": "Fjern invitation", + "inviteRemoveError": "Mislykkedes at fjerne invitation", + "inviteRemoveErrorDescription": "Det opstod en fejl under fjernelse af invitationen.", + "inviteRemoved": "Invitation fjernet", + "inviteRemovedDescription": "Invitationen for {email} er fjernet.", + "inviteQuestionRemove": "Er du sikker på at du vil fjerne invitationen?", + "inviteMessageRemove": "Når fjernet, vil denne invitationen ikke længere være gyldig. Du kan altid invitere brugeren igen senere.", + "inviteMessageConfirm": "For at bekræfte, indtast venligst invitationens e-mailadresse nedenfor.", + "inviteQuestionRegenerate": "Er du sikker på at du vil generere invitationen igen for {email}? Dette vil ugyldiggøre den forrige invitation.", + "inviteRemoveConfirm": "Bekræft fjernelse af invitation", + "inviteRegenerated": "Invitation fornyet", + "inviteSent": "En ny invitation er sendt til {email}.", + "inviteSentEmail": "Send e-mailnotifikation til brugeren", + "inviteGenerate": "En ny invitation er generert for {email}.", + "inviteDuplicateError": "Dupliker invitation", + "inviteDuplicateErrorDescription": "En invitation for denne bruger findes allerede.", + "inviteRateLimitError": "Anmodningsgrænse overskredet", + "inviteRateLimitErrorDescription": "Du har overskredet grænsen på 3 regenereringer pr. time. Prøv igen senere.", + "inviteRegenerateError": "Kunne ikke regenerere invitation", + "inviteRegenerateErrorDescription": "Det opstod en fejl under regenerering af invitationen.", + "inviteValidityPeriod": "Gyldighedsperiode", + "inviteValidityPeriodSelect": "Vælg gyldighetsperiode", + "inviteRegenerateMessage": "Invitationen er generert igen. Brugeren skal gå til linket nedenfor for at acceptere invitationen.", + "inviteRegenerateButton": "Regenerer", + "expiresAt": "Udløbstidspunkt", + "accessRoleUnknown": "Ukendt rolle", + "placeholder": "Pladsholder", + "userErrorOrgRemove": "En fejl opstod under fjernelse af bruger", + "userErrorOrgRemoveDescription": "Det opstod en fejl under fjernelse af brugeren.", + "userOrgRemoved": "Bruger fjernet", + "userOrgRemovedDescription": "Brugeren {email} er fjernet fra organisationen.", + "userQuestionOrgRemove": "Er du sikker på at du vil fjerne denne bruger fra organisationen?", + "userMessageOrgRemove": "Når denne bruger er fjernet, vil de ikke længere have adgang til organisationen. Du kan altid invitere dem igen senere, men de vil skulle acceptere invitationen igen.", + "userRemoveOrgConfirm": "Bekræft fjernelse af bruger", + "userRemoveOrg": "Fjern bruger fra organisation", + "userQuestionOrgRemoveSelf": "Er du sikker på at du vil fjerne dig selv fra denne organisation?", + "userMessageOrgRemoveSelf": "Du vil miste adgang umiddelbart. En administrator kan invitere dig tilbage senere, men du skal acceptere en ny invitation.", + "userRemoveOrgConfirmSelf": "Bekræft fjernelse af mig selv", + "userRemoveOrgSelf": "Fjern dig selv fra organisationen", + "userRemoveOrgSelfWarning": "Du vil miste adgangen til denne organisation umiddelbart.", + "userRemoveOrgConfirmPhraseSelf": "FJERN MIG SELV FRA ORG", + "users": "Brugere", + "accessRoleMember": "Medlem", + "accessRoleOwner": "Ejer", + "userConfirmed": "Bekræftet", + "idpNameInternal": "Intern", + "emailInvalid": "Ugyldig e-mailadresse", + "inviteValidityDuration": "Vælg venligst en varighed", + "accessRoleSelectPlease": "Vælg venligst en rolle", + "removeOwnAdminRoleConfirmTitle": "Fjern din administratoradgang?", + "removeOwnAdminRoleConfirmDescription": "Du vil ikke længere have administratorrettigheder i denne organisation efter gemning. En anden administrator kan gendanne adgang hvis nødvendig.", + "removeOwnAdminRoleConfirmButton": "Fjern min administratoradgang", + "removeOwnAdminRoleConfirmPhrase": "FJERN MIN ADMINISTRATORADGANG", + "ownerMustRetainAdminRole": "Organisationsejer skal beholde mindst én administratorrolle.", + "usernameRequired": "Brugernavn er påkrævet", + "idpSelectPlease": "Vælg venligst en identitetsudbyder", + "idpGenericOidc": "Generisk OAuth2/OIDC-udbyder.", + "accessRoleErrorFetch": "En fejl opstod under hentning af roller", + "accessRoleErrorFetchDescription": "En fejl opstod under hentning af rollene", + "idpErrorFetch": "En fejl opstod under hentning af identitetsudbydere", + "idpErrorFetchDescription": "En fejl opstod ved hentning af identitetsudbydere", + "userErrorExists": "Bruger findes allerede", + "userErrorExistsDescription": "Denne bruger er allerede medlem af organisationen.", + "inviteError": "Kunne ikke invitere bruger", + "inviteErrorDescription": "En fejl opstod under invitering af brugeren", + "userInvited": "Bruger inviteret", + "userInvitedDescription": "Brugeren er inviteret.", + "userErrorCreate": "Kunne ikke oprette bruger", + "userErrorCreateDescription": "Det opstod en fejl under oprettelse af brugeren", + "userCreated": "Bruger oprettet", + "userCreatedDescription": "Brugeren er blevet oprettet.", + "userTypeInternal": "Intern bruger", + "userTypeInternalDescription": "Inviter en bruger til at blive med direkte i organisationen.", + "userTypeExternal": "Ekstern bruger", + "userTypeExternalDescription": "Opret en bruger med en ekstern identitetsudbyder.", + "accessUserCreateDescription": "Følg stegene under for at oprette en ny bruger", + "userSeeAll": "Se alle brugere", + "userTypeTitle": "Brugertype", + "userTypeDescription": "Bestem hvordan du vil oprette brugeren", + "userSettings": "Brugerinformation", + "userSettingsDescription": "Indtast detaljerne for den nye brugeren", + "inviteEmailSent": "Send invitasjonsepost til bruger", + "inviteValid": "Gyldig for", + "selectDuration": "Vælg varighed", + "selectResource": "Vælg ressource", + "filterByResource": "Filtrer efter ressourcer", + "selectApprovalState": "Vælg godkendelsesstatus", + "filterByApprovalState": "Filtrer efter godkendelsesstatus", + "approvalListEmpty": "Ingen godkendelser", + "approvalState": "Godkendelses tilstand", + "approvalLoadMore": "Indlæs mere", + "loadingApprovals": "Indlæser godkendelser", + "approve": "Godkend", + "approved": "Godkendt", + "denied": "Afviste", + "deniedApproval": "Afvist godkendelse", + "all": "Alle", + "deny": "Afvis", + "viewDetails": "Vis detaljer", + "requestingNewDeviceApproval": "har anmodet om en ny enhed", + "resetFilters": "Nulstil filtre", + "totalBlocked": "Forespørgsler blokeret af Pangolin", + "totalRequests": "Totalt antal forespørgsler", + "requestsByCountry": "Forespørgsler fra land", + "requestsByDay": "Forespørgsler per dag", + "blocked": "Blokeret", + "allowed": "Tilladt", + "topCountries": "Flest land", + "accessRoleSelect": "Vælg rolle", + "inviteEmailSentDescription": "En e-mail er sendt til brugeren med adgangslinket nedenfor. De skal åbne linket for at acceptere invitationen.", + "inviteSentDescription": "Brugeren er blevet inviteret. De skal åbne linket nedenfor for at acceptere invitationen.", + "inviteExpiresIn": "Invitationen udløber om {days, plural, one {én dag} other {# dage}}.", + "idpTitle": "Identitetsudbyder", + "idpSelect": "Vælg identitetsudbyderen for den eksterne brugeren", + "idpNotConfigured": "Ingen identitetsudbydere er konfigureret. Venligst konfigurer en identitetsudbyder før du opretter eksterne brugere.", + "usernameUniq": "Dette skal matche det unikke brugernavn som findes i den valgte identitetsudbyder.", + "emailOptional": "E-mail (Valgfrit)", + "nameOptional": "Navn (valgfrit)", + "accessControls": "Adgangskontroller", + "userDescription2": "Administrer indstillingerne for denne bruger", + "accessRoleErrorAdd": "Kunne ikke tilføje bruger i rolle", + "accessRoleErrorAddDescription": "Det opstod en fejl under tildeling af brugeren til rollen.", + "userSaved": "Bruger gemt", + "userSavedDescription": "Brugeren er blevet opdateret.", + "autoProvisioned": "Automatisk provisioneret", + "autoProvisionSettings": "Autoprovisioneringsindstillinger", + "autoProvisionedDescription": "Tillad denne bruger at blive automatisk administrert af en identitetsudbyder", + "accessControlsDescription": "Administrer, hvad denne bruger kan få adgang til og gøre i organisationen", + "accessControlsSubmit": "Gem adgangskontroller", + "singleRolePerUserPlanNotice": "Din plan understøtter kun én rolle per bruger.", + "singleRolePerUserEditionNotice": "Denne udgaven understøtter kun én rolle per bruger.", + "roles": "Roller", + "accessUsersRoles": "Administrer brugere og roller", + "accessUsersRolesDescription": "Inviter brugere og legg dem til roller for at administrer adgang til organisationen", + "key": "Nøgle", + "createdAt": "Oprettet", + "proxyErrorInvalidHeader": "Ugyldig værdi for brugerdefineret host-header. Brug domænenavnformat, eller gem feltet tomt for at fjerne den brugerdefinerede host-header.", + "proxyErrorTls": "Ugyldig TLS-servernavn. Brug domænenavnformat, eller lad stå tomt for at fjerne TLS-servernavnet.", + "proxyEnableSSL": "Aktivér TLS", + "proxyEnableSSLDescription": "Aktivér SSL/TLS-kryptering for en sikker HTTPS-forbindelse til målene.", + "target": "Mål", + "configureTarget": "Konfigurer mål", + "targetErrorFetch": "Kunne ikke hente mål", + "targetErrorFetchDescription": "Det opstod en fejl under hentning af mål", + "siteErrorFetch": "Kunne ikke hente ressource", + "siteErrorFetchDescription": "Det opstod en fejl under hentning af ressource", + "targetErrorDuplicate": "Dupliker mål", + "targetErrorDuplicateDescription": "Et mål med disse indstillingerne findes allerede", + "targetWireGuardErrorInvalidIp": "Ugyldig mål-IP", + "targetWireGuardErrorInvalidIpDescription": "Mål-IP skal være i sitets subnet.", + "targetsUpdated": "Mål opdateret", + "targetsUpdatedDescription": "Mål og indstillinger opdateret med succes", + "targetsErrorUpdate": "Mislykkedes at opdatere mål", + "targetsErrorUpdateDescription": "En fejl opstod under opdatering af mål", + "targetTlsUpdate": "TLS-indstillinger opdateret", + "targetTlsUpdateDescription": "TLS-indstillinger er blevet opdateret", + "targetErrorTlsUpdate": "Mislykkedes under opdatering af TLS-indstillinger", + "targetErrorTlsUpdateDescription": "Det opstod en fejl under opdatering af TLS-indstillinger", + "proxyUpdated": "Proxyindstillinger opdateret", + "proxyUpdatedDescription": "Proxy indstillinger er blevet opdateret", + "proxyErrorUpdate": "En fejl opstod under opdatering af proxyindstillinger", + "proxyErrorUpdateDescription": "En fejl opstod under opdatering af proxyindstillinger", + "targetAddr": "Host", + "targetPort": "Port", + "targetProtocol": "Protokol", + "targetTlsSettings": "Sikker forbindelseskonfiguration", + "targetTlsSettingsDescription": "Konfigurer SSL/TLS-indstillinger for ressourcen", + "targetTlsSettingsAdvanced": "Avanserte TLS-indstillinger", + "targetTlsSni": "TLS servernavn", + "targetTlsSniDescription": "TLS-servernavnet som skal bruges for SNI. Lad stå tomt for at bruge standardverdien.", + "targetTlsSubmit": "Gem indstillinger", + "targets": "Målkonfiguration", + "targetsDescription": "Opsæt mål for routetrafik til backendtjenesterne", + "targetStickySessions": "Aktivér sticky sessions", + "targetStickySessionsDescription": "Behold forbindelser på samme backend-mål gennem hele sessionen.", + "methodSelect": "Vælg metode", + "targetSubmit": "Tilføj mål", + "targetNoOne": "Denne ressource har ikke nogen mål. Tilføj et mål for at konfigurere hvor du vil sende forespørgsler til backend.", + "targetNoOneDescription": "AT tilføje mere enn ét mål ovenfor vil aktivere load balancing.", + "targetsSubmit": "Gem indstillinger", + "addTarget": "Tilføj mål", + "proxyMultiSiteRoundRobinNodeHelp": "Round-robin-routing vil ikke fungere mellem steder der ikke er forbundet til samme node, men failover vil fungere.", + "targetErrorInvalidIp": "Ugyldig IP-adresse", + "targetErrorInvalidIpDescription": "Indtast en gyldig IP-adresse eller hostnavn", + "targetErrorInvalidPort": "Ugyldig port", + "targetErrorInvalidPortDescription": "Indtast venligst et gyldig portnummer", + "targetErrorNoSite": "Intet site valgt", + "targetErrorNoSiteDescription": "Vælg et site for målet", + "targetTargetsCleared": "Mål ryddet", + "targetTargetsClearedDescription": "Alle mål er blevet fjernet fra denne ressource", + "targetCreated": "Mål oprettet", + "targetCreatedDescription": "Målet er blevet oprettet", + "targetErrorCreate": "Kunne ikke oprette målet", + "targetErrorCreateDescription": "Det opstod en fejl under oprettelse af målet", + "tlsServerName": "TLS servernavn", + "tlsServerNameDescription": "Servernavnet som skal bruges for SNI", + "save": "Gem", + "proxyAdditional": "Yderligere Proxyindstillinger", + "proxyAdditionalDescription": "Konfigurer hvordan ressourcen håndterer proxy-indstillingerne", + "proxyCustomHeader": "Tilpasset host-header", + "proxyCustomHeaderDescription": "Host-header som skal settes ved videresendelse af forespørgsler. Lad stå tom for at bruge standardinnstillingen.", + "proxyAdditionalSubmit": "Gem proxyindstillinger", + "subnetMaskErrorInvalid": "Ugyldig subnetmaske. Skal være mellem 0 og 32.", + "ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat", + "ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet", + "path": "Sti", + "matchPath": "Match sti", + "ipAddressRange": "IP-område", + "rulesErrorFetch": "Kunne ikke hente regler", + "rulesErrorFetchDescription": "Det opstod en fejl under hentning af regler", + "rulesErrorDuplicate": "Duplikeret regel", + "rulesErrorDuplicateDescription": "En regel med disse indstillingerne findes allerede", + "rulesErrorInvalidIpAddressRange": "Ugyldig CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "Indtast et gyldig CIDR-site (f.eks. 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Ugyldig sti", + "rulesErrorInvalidUrlDescription": "Indtast en gyldig URL-sti eller et mønster (f.eks., /api/*).", + "rulesErrorInvalidIpAddress": "Ugyldig IP-adresse", + "rulesErrorInvalidIpAddressDescription": "Indtast en gyldig IPv4 eller IPv6 adresse.", + "rulesErrorUpdate": "Kunne ikke opdatere regler", + "rulesErrorUpdateDescription": "Det opstod en fejl under opdatering af regler", + "rulesUpdated": "Aktivér Regler", + "rulesUpdatedDescription": "Regelevalueringen er blevet opdateret", + "rulesMatchIpAddressRangeDescription": "Angiv en adresse i CIDR-format (f.eks., 103.21.244.0/22)", + "rulesMatchIpAddress": "Angiv en IP-adresse (f.eks. 103.21.244.12)", + "rulesMatchUrl": "Indtast en URL-sti eller et mønster (f.eks. /api/v1/todos eller /api/v1/*)", + "rulesErrorInvalidPriority": "Ugyldig prioritet", + "rulesErrorInvalidPriorityDescription": "Indtast et heltall på 1 eller højere.", + "rulesErrorDuplicatePriority": "Duplikerede prioriteter", + "rulesErrorDuplicatePriorityDescription": "Hver regel skal have et unikt prioritetstall.", + "rulesErrorValidation": "Ugyldige regler", + "rulesErrorValidationRuleDescription": "Regel {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Vælg en gyldig matchtype (sti, IP, CIDR, land, region eller ASN).", + "rulesErrorValueRequired": "Indtast en værdi for denne reglen.", + "rulesErrorInvalidCountry": "Ugyldig land", + "rulesErrorInvalidCountryDescription": "Vælg et gyldig land.", + "rulesErrorInvalidAsn": "Ugyldig ASN", + "rulesErrorInvalidAsnDescription": "Indtast en gyldig ASN (f.eks., AS15169).", + "ruleUpdated": "Regler opdateret", + "ruleUpdatedDescription": "Reglerne er opdateret", + "ruleErrorUpdate": "Operation mislykkedes", + "ruleErrorUpdateDescription": "En fejl opstod under gemmehandlingen", + "rulesPriority": "Prioritet", + "rulesReorderDragHandle": "Dra for at omorganisere regelprioriteringen", + "rulesAction": "Handling", + "rulesMatchType": "Matchtype", + "value": "Værdi", + "rulesAbout": "Om regler", + "rulesAboutDescription": "Regler giver mulighed til at kontrollere adgangen til ressourcen baseret på et sett af kriterier. Du kan oprette regler for at tillade eller afvise adgang baseret på IP-adresse eller URL-sti.", + "rulesActions": "Handlinger", + "rulesActionAlwaysAllow": "Tillad altid: Omgå alle autentiserings metoder", + "rulesActionAlwaysDeny": "Afvis altid: Bloker alle forespørgsler; ingen autentificering kan forsøges", + "rulesActionPassToAuth": "Send til autentificering: Tillad, at autentificeringsmetoder kan forsøges", + "rulesMatchCriteria": "Matchende kriterier", + "rulesMatchCriteriaIpAddress": "Match en specifik IP-adresse", + "rulesMatchCriteriaIpAddressRange": "Match et IP-adresseområde i CIDR-notation", + "rulesMatchCriteriaUrl": "Match en URL-sti eller et mønster", + "rulesEnable": "Aktivér regler", + "rulesEnableDescription": "Aktivér eller deaktivér regelevaluering for denne ressource", + "rulesResource": "Konfiguration af ressourceregler", + "rulesResourceDescription": "Konfigurer regler for at kontrollere adgang til ressourcen", + "ruleSubmit": "Tilføj regel", + "rulesNoOne": "Ingen regler endnu.", + "rulesOrder": "Regler evalueres efter prioritet i stigende rækkefølge.", + "rulesSubmit": "Gem regler", + "policyErrorCreate": "Fejl ved oprettelse af politik", + "policyErrorCreateDescription": "Det opstod en fejl under oprettelse af politikken", + "policyErrorCreateMessageDescription": "En uventet fejl opstod", + "policyErrorUpdate": "Fejl ved opdatering af politik", + "policyErrorUpdateDescription": "Det opstod en fejl ved opdatering af politikken", + "policyErrorUpdateMessageDescription": "En uventet fejl opstod", + "policyCreatedSuccess": "Ressourcepolitikken blev oprettet med succes", + "policyUpdatedSuccess": "Ressourcepolitikken blev opdateret med succes", + "authMethodsSave": "Gem indstillinger", + "policyAuthStackTitle": "Autentificering", + "policyAuthStackDescription": "Kontroller hvilke autentificeringsmetoder som kræves for at få adgang til denne ressource", + "policyAuthOrLogicTitle": "Flere autentificeringsmetoder aktive", + "policyAuthOrLogicBanner": "Besøgende kan autentificere sig med en hvilken som helst af de aktive metoder nedenfor. De behøver ikke at fuldføre dem alle.", + "policyAuthMethodActive": "Aktiv", + "policyAuthMethodOff": "Af", + "policyAuthSsoTitle": "Platform-SSO", + "policyAuthSsoDescription": "Kræv påloging gennem din organisations identitetsudbyder", + "policyAuthSsoSummary": "{idp} · {users} brugere, {roles} roller", + "policyAuthSsoDefaultIdp": "Standardudbyder", + "policyAuthAddDefaultIdentityProvider": "Tilføj standardidentitetsudbyder", + "policyAuthOtherMethodsTitle": "Andre metoder", + "policyAuthOtherMethodsDescription": "Valgfrie metoder, som besøgende kan bruge i stedet for eller ud over platform-SSO", + "policyAuthPasscodeTitle": "Adgangskode", + "policyAuthPasscodeDescription": "Kræv en delt alfanumerisk adgangskode for at få adgang til ressourcen", + "policyAuthPasscodeSummary": "Adgangskode sat", + "policyAuthPincodeTitle": "PIN-kode", + "policyAuthPincodeDescription": "En kort numerisk kode kræves for at få adgang til ressourcen", + "policyAuthPincodeSummary": "6-sifret PIN sat", + "policyAuthEmailTitle": "E-mail hviteliste", + "policyAuthEmailDescription": "Tillad angivne e-mailadresser med engangskode", + "policyAuthEmailSummary": "{count} adresser tilladt", + "policyAuthEmailOtpCallout": "Aktivering af e-mail hviteliste sender en engangskode til den besøgendes e-mail ved login.", + "policyAuthHeaderAuthTitle": "Grunnleggende Header Autentificering", + "policyAuthHeaderAuthDescription": "Bekræft et tilpasset HTTP-headernavn og værdi ved hver forespørgsel", + "policyAuthHeaderAuthSummary": "Header konfigureret", + "policyAuthHeaderName": "Headernavn", + "policyAuthHeaderValue": "Forventet værdi", + "policyAuthSetPasscode": "Angiv adgangskode", + "policyAuthSetPincode": "Sett PIN-kode", + "policyAuthSetEmailWhitelist": "Angiv e-mail hviteliste", + "policyAuthSetHeaderAuth": "Sett grunnleggende Header Autentificering", + "policyAccessRulesTitle": "Adgangsregler", + "policyAccessRulesEnableDescription": "Når aktiveret, bliver regler evalueret i faldende rækkefølge til en evaluerer til sann.", + "policyAccessRulesFirstMatch": "Regler evalueres oppefra og ned. Den første matchende regel bestemmer resultatet.", + "policyAccessRulesHowItWorks": "Regler matcher forespørgsler efter sti, IP-adresse, placering eller andre kriterier. Hver regel anvender en handling: omgå autentificering, blokere adgang, eller sende til autentificering. Hvis ingen regler matcher, fortsetter trafikken til autentificering.", + "policyAccessRulesFallthroughOff": "Når regler er deaktiveret, går all trafik gennem til autentificering.", + "policyAccessRulesFallthroughOn": "Når ingen regler matcher, fortsetter trafikken til autentificering.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", + "rulesSave": "Gem Regler", + "resourceErrorCreate": "Fejl under oprettelse af ressource", + "resourceErrorCreateDescription": "Det opstod en fejl under oprettelse af ressourcen", + "resourceErrorCreateMessage": "Fejl ved oprettelse af ressource:", + "resourceErrorCreateMessageDescription": "En uventet fejl opstod", + "sitesErrorFetch": "Fejl ved hentning af sites", + "sitesErrorFetchDescription": "En fejl opstod ved hentning af områdene", + "domainsErrorFetch": "Kunne ikke hente domæner", + "domainsErrorFetchDescription": "Der opstod en fejl under hentning af domænerne", + "none": "Ingen", + "unknown": "Ukendt", + "resources": "Ressourcer", + "resourcesDescription": "Ressourcer er proxyer til applikationer, der kører på det private netværk. Opret en ressource for enhver HTTP/HTTPS- eller rå TCP/UDP-tjeneste på dit private netværk. Hver ressource skal kobles til et site for at aktivere privat og sikker forbindelse gennem en krypteret WireGuard-tunnel.", + "resourcesWireGuardConnect": "Sikker forbindelse med WireGuard-kryptering", + "resourcesMultipleAuthenticationMethods": "Konfigurer flere autentificeringsmetoder", + "resourcesUsersRolesAccess": "Bruger- og rollebaseret adgangskontrol", + "resourcesErrorUpdate": "Mislykkedes at slå af/på ressource", + "resourcesErrorUpdateDescription": "En fejl opstod under opdatering af ressourcen", + "access": "Adgang", + "accessControl": "Adgangskontrol", + "shareLink": "{resource} Delbart link", + "resourceSelect": "Vælg ressource", + "shareLinks": "Delbare links", + "share": "Delbare links", + "shareDescription2": "Opret delbare links til ressourcer. Links giver midlertidig eller ubegrænset adgang til din ressource. Du kan konfigurere udløbsvarigheden på linket når du opretter en.", + "shareEasyCreate": "Enkelt at oprette og dele", + "shareConfigurableExpirationDuration": "Konfigurerbar udløbsvarighed", + "shareSecureAndRevocable": "Sikker og kan tilbagekaldes", + "nameMin": "Navn skal være mindst {len} tegn.", + "nameMax": "Navn kan ikke være længere enn {len} tegn.", + "sitesConfirmCopy": "Venligst bekræft at du har kopieret konfigurationen.", + "unknownCommand": "Ukjent kommando", + "newtErrorFetchReleases": "Mislykkedes at hente utgivelsesinfo: {err}", + "newtErrorFetchLatest": "Fejl ved hentning af seneste utgivelse: {err}", + "newtEndpoint": "Endpoint", + "newtId": "ID", + "newtSecretKey": "Sikkerhedsnøgle", + "newtVersion": "Version", + "architecture": "Arkitektur", + "sites": "Sites", + "siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.", + "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", + "siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet", + "userErrorNotAdminOrOwner": "Bruger er ikke administrator eller ejer", + "pangolinSettings": "Indstillinger - Pangolin", + "accessRoleYour": "Din rolle:", + "accessRoleSelect2": "Vælg roller", + "accessUserSelect": "Vælg brugere", + "otpEmailEnter": "Indtast én e-mail", + "otpEmailEnterDescription": "Trykk enter for at tilføje en e-mail efter at have tastet den ind i tekstfeltet.", + "otpEmailErrorInvalid": "Ugyldig e-mailadresse. Wildcardet (*) skal være hele lokaldelen.", + "otpEmailSmtpRequired": "SMTP påkrævet", + "otpEmailSmtpRequiredDescription": "SMTP skal være aktiveret på serveren for at bruge engangskode-autentificering.", + "otpEmailTitle": "Engangskode", + "otpEmailTitleDescription": "Kræv e-mailbaseret autentificering for ressourceadgang", + "otpEmailWhitelist": "E-mailwhitelist", + "otpEmailWhitelistList": "Whitelistede e-mails", + "otpEmailWhitelistListDescription": "Kun brugere med disse e-mailadresser får adgang til denne ressource. De bliver bedt om at indtaste en engangskode sendt til deres e-mail. Wildcards (*@example.com) kan bruges til at tillade enhver e-mailadresse fra et domæne.", + "otpEmailWhitelistSave": "Gem whitelist", + "passwordAdd": "Tilføj adgangskode", + "passwordRemove": "Fjern adgangskode", + "pincodeAdd": "Tilføj PIN-kode", + "pincodeRemove": "Fjern PIN-kode", + "resourceAuthMethods": "Autentificeringsmetoder", + "resourcePolicyAuthMethodsEmpty": "Ingen autentificeringsmetode", + "resourcePolicyOtpEmpty": "Ingen engangskode", + "resourcePolicyReadOnly": "Denne politikken er kun læsebar", + "resourcePolicyReadOnlyDescription": "Denne ressourcereglen deles på tværs af flere ressourcer, du kan ikke redigere den på denne side.", + "editSharedPolicy": "Rediger Delte Rekvireringer", + "resourcePolicyTypeSave": "Gem Ressourcetype", + "resourcePolicySelect": "Vælg ressourcepolitik", + "resourcePolicySelectError": "Vælg en ressourceregel", + "resourcePolicyNotFound": "Politik ikke fundet", + "resourcePolicySearch": "Søg efter regler", + "resourcePolicyRulesEmpty": "Ingen autentificeringsregler", + "resourceAuthMethodsDescriptions": "Tillad adgang til ressourcen via yderligere autentificeringsmetoder", + "resourceAuthSettingsSave": "Gemt med succes", + "resourceAuthSettingsSaveDescription": "Autentificeringsindstillinger er gemt", + "resourceErrorAuthFetch": "Kunne ikke hente data", + "resourceErrorAuthFetchDescription": "Det opstod en fejl ved hentning af data", + "resourceErrorPasswordRemove": "Fejl ved fjernelse af adgangskode for ressource", + "resourceErrorPasswordRemoveDescription": "Det opstod en fejl ved fjernelse af ressourceadgangskoden", + "resourceErrorPasswordSetup": "Fejl ved indstilling af ressourceadgangskode", + "resourceErrorPasswordSetupDescription": "Det opstod en fejl ved indstilling af ressourceadgangskoden", + "resourceErrorPincodeRemove": "Fejl ved fjernelse af ressource-PIN-koden", + "resourceErrorPincodeRemoveDescription": "Det opstod en fejl under fjernelse af ressource-pinkoden", + "resourceErrorPincodeSetup": "Fejl ved indstilling af ressource-PIN-kode", + "resourceErrorPincodeSetupDescription": "Det opstod en fejl under indstilling af ressourcens PIN-kode", + "resourceErrorUsersRolesSave": "Kunne ikke sette roller", + "resourceErrorUsersRolesSaveDescription": "En fejl opstod ved indstilling af rollene", + "resourceErrorWhitelistSave": "Mislykkedes at gem whitelist", + "resourceErrorWhitelistSaveDescription": "Det opstod en fejl under lagring af whitelisten", + "resourcePasswordSubmit": "Aktivér adgangskodebeskyttelse", + "resourcePasswordProtection": "Adgangskodebeskyttelse {status}", + "resourcePasswordRemove": "Ressourceadgangskode fjernet", + "resourcePasswordRemoveDescription": "Fjernelse af ressourceadgangskoden var med succes", + "resourcePasswordSetup": "Ressourceadgangskode sat", + "resourcePasswordSetupDescription": "Ressourceadgangskoden er blevet sat", + "resourcePasswordSetupTitle": "Angiv adgangskode", + "resourcePasswordSetupTitleDescription": "Sett et adgangskode for at beskytte denne ressource", + "resourcePincode": "PIN-kode", + "resourcePincodeSubmit": "Aktivér PIN-kodebeskyttelse", + "resourcePincodeProtection": "PIN-kodebeskyttelse {status}", + "resourcePincodeRemove": "Ressource PIN-kode fjernet", + "resourcePincodeRemoveDescription": "Ressourceadgangskoden blev fjernet", + "resourcePincodeSetup": "Ressource PIN-kode sat", + "resourcePincodeSetupDescription": "Ressource PIN-kode er sat med succes", + "resourcePincodeSetupTitle": "Angiv PIN-kode", + "resourcePincodeSetupTitleDescription": "Sett en PIN-kode for at beskytte denne ressource", + "resourceRoleDescription": "Administratorer har altid adgang til denne ressource.", + "resourcePolicySelectTitle": "Ressourceadgangspolitik", + "resourcePolicySelectDescription": "Vælg politiktype for autentificering", + "resourcePolicyTypeLabel": "Politiktype", + "resourcePolicyLabel": "Ressourcepolitik", + "resourcePolicyInline": "Inline ressourceregler", + "resourcePolicyInlineDescription": "Adgangspolitik som kun er gyldig for denne ressource", + "resourcePolicyShared": "Delte ressourceregler", + "resourcePolicySharedDescription": "Denne ressource bruger en delt politik.", + "sharedPolicy": "Delt politik", + "sharedPolicyNoneDescription": "Denne ressource har sin egen politik.", + "resourceSharedPolicyOwnDescription": "Denne ressource har sine egne autentiserings- og adgangskontroller.", + "resourceSharedPolicyInheritedDescription": "Denne ressource arver fra {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "Denne ressource bruger en delt politik. Nogen autentificeringsindstillinger kan redigeres på denne ressource for at tilføje politikken. For at ændre den underliggende politikken, skal du redigere {policyName}.", + "resourceSharedPolicyRulesNotice": "Denne ressource bruger en delt politik. Nogen adgangsregler kan redigeres på denne ressource. For at ændre den underliggende politikken, skal du redigere {policyName}.", + "resourceUsersRoles": "Adgangskontroller", + "resourceUsersRolesDescription": "Konfigurer hvilke brugere og roller som har adgang til denne ressource", + "resourceUsersRolesSubmit": "Gem adgangskontroller", + "resourceWhitelistSave": "Gem med succes", + "resourceWhitelistSaveDescription": "Whitelist-indstillinger er gemt", + "ssoUse": "Brug platform-SSO", + "ssoUseDescription": "Eksisterende brugere behøver kun at loge på én gang for alle ressourcer som har dette aktiveret.", + "proxyErrorInvalidPort": "Ugyldig portnummer", + "subdomainErrorInvalid": "Ugyldig underdomæne", + "domainErrorFetch": "Fejl ved hentning af domæner", + "domainErrorFetchDescription": "Der opstod en fejl ved hentning af domænerne", + "resourceErrorUpdate": "Mislykkedes at opdatere ressource", + "resourceErrorUpdateDescription": "Det opstod en fejl under opdatering af ressourcen", + "resourceUpdated": "Ressource opdateret", + "resourceUpdatedDescription": "Ressourcen er opdateret med succes", + "resourceErrorTransfer": "Kunne ikke overføre ressource", + "resourceErrorTransferDescription": "En fejl opstod under overføring af ressourcen", + "resourceTransferred": "Ressource overført", + "resourceTransferredDescription": "Ressourcen er overført med succes.", + "resourceErrorToggle": "Mislykkedes at veksle ressource", + "resourceErrorToggleDescription": "Det opstod en fejl ved opdatering af ressourcen", + "resourceVisibilityTitle": "Synlighed", + "resourceVisibilityTitleDescription": "Fuldstændigt aktivér eller deaktivér ressourcesynlighed", + "resourceGeneral": "Generelle indstillinger", + "resourceGeneralDescription": "Konfigurer navn, adresse og adgangspolitik for denne ressource.", + "resourceGeneralDetailsSubsection": "Ressourcedetaljer", + "resourceGeneralDetailsSubsectionDescription": "Angiv visningsnavn, identifikator og offentligt tilgængelig domæne for denne ressource.", + "resourceGeneralDetailsSubsectionPortDescription": "Angiv visningsnavn, identifikator og offentlig port for denne ressource.", + "resourceGeneralPublicAddressSubsection": "Offentlig adresse", + "resourceGeneralPublicAddressSubsectionDescription": "Konfigurer hvordan brugere får adgang til denne ressource.", + "resourceGeneralAuthenticationAccessSubsection": "Autentificering og adgang", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Vælg om denne ressource bruger sin egen politik eller arver fra en delt politik.", + "resourceEnable": "Aktivér ressource", + "resourceTransfer": "Overfør Ressource", + "resourceTransferDescription": "Overfør denne ressource til et andet site", + "resourceTransferSubmit": "Overfør ressource", + "siteDestination": "Destinasjonsområde", + "searchSites": "Søg i sites", + "countries": "Land", + "accessRoleCreate": "Opret rolle", + "accessRoleCreateDescription": "Opret en ny rolle for at gruppere brugere og administrer deres tilladelser.", + "accessRoleEdit": "Rediger rolle", + "accessRoleEditDescription": "Rediger rolleinformation.", + "accessRoleCreateSubmit": "Opret rolle", + "accessRoleCreated": "Rolle oprettet", + "accessRoleCreatedDescription": "Rollen er med succes oprettet.", + "accessRoleErrorCreate": "Kunne ikke oprette rolle", + "accessRoleErrorCreateDescription": "Det opstod en fejl under oprettelse af rollen.", + "accessRoleUpdateSubmit": "Opdater rolle", + "accessRoleUpdated": "Rollen opdateret", + "accessRoleUpdatedDescription": "Rollen er blevet opdateret.", + "accessApprovalUpdated": "Godkendelse behandlet", + "accessApprovalApprovedDescription": "Sett godkendelsesanmodning om at acceptere.", + "accessApprovalDeniedDescription": "Sett godkendelsesanmodning om at afvise.", + "accessRoleErrorUpdate": "Kunne ikke opdatere rolle", + "accessRoleErrorUpdateDescription": "Det opstod en fejl under opdatering af rollen.", + "accessApprovalErrorUpdate": "Kunne ikke administrer godkendelse", + "accessApprovalErrorUpdateDescription": "Det opstod en fejl under behandling af godkendelsen.", + "accessRoleErrorNewRequired": "Ny rolle kræves", + "accessRoleErrorRemove": "Kunne ikke fjerne rolle", + "accessRoleErrorRemoveDescription": "Det opstod en fejl under fjernelse af rollen.", + "accessRoleName": "Rollenavn", + "accessRoleQuestionRemove": "Du er ved at slette rollen `{name}`. Du kan ikke fortryde denne handling.", + "accessRoleRemove": "Fjern Rolle", + "accessRoleRemoveDescription": "Fjern en rolle fra organisationen", + "accessRoleRemoveSubmit": "Fjern Rolle", + "accessRoleRemoved": "Rolle fjernet", + "accessRoleRemovedDescription": "Rollen er med succes fjernet.", + "accessRoleRequiredRemove": "Før du sletter denne rolle, vælg venligst en ny rolle at overføre eksisterende medlemmer til.", + "network": "Netværk", + "manage": "Administrer", + "sitesNotFound": "Ingen sites fundet.", + "pangolinServerAdmin": "Server Admin - Pangolin", + "licenseTierProfessional": "Professionel licens", + "licenseTierEnterprise": "Enterprise-licens", + "licenseTierPersonal": "Personlig licens", + "licensed": "Licenseret", + "yes": "Ja", + "no": "Nej", + "sitesAdditional": "Yderligere sites", + "licenseKeys": "Licensnøgler", + "sitestCountDecrease": "Reducer antal sites", + "sitestCountIncrease": "Øk antal sites", + "idpManage": "Administrer Identitetsudbydere", + "idpManageDescription": "Vis og administrer identitetsudbydere i systemet", + "idpGlobalModeBanner": "Identitetsudbydere (IdPs) per organisation er deaktiveret på denne serveren. Den bruger globale IdP (delt på tværs af alle organisationer). Administrer globale IdP'er i adminpanelet. For at aktivere IdP per organisation, rediger serverkonfigurationen og sett IdP-tilstand til org. Se dokumentasjonen. Hvis du vil fortsætte at bruge globale IdPs og få denne til at forsvinne fra organisationens indstillinger, sat eksplicit tilstanden til global i konfigurationen.", + "idpGlobalModeBannerUpgradeRequired": "Identitetsudbydere (IdP'er) pr. organisation er deaktiveret på denne server. Den bruger globale IdP'er (delt på tværs af alle organisationer). Administrer globale IdP'er i administrationspanelet. For at bruge identitetsudbydere pr. organisation skal du opgradere til Enterprise-udgaven.", + "idpGlobalModeBannerLicenseRequired": "Identitetsudbydere (IdPs) per organisation er deaktiveret på denne serveren. Den bruger globale IdPs (delt på tværs af alle organisationer). Administrer globale IdPs i administrationspanelet. For at bruge identitetsudbydere per organisation, kræves en Enterprise-licens.", + "idpDeletedDescription": "Identitetsudbyder slettet med succes", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "Er du sikker på at du vil slette identitetsudbyder permanent?", + "idpMessageRemove": "Dette vil fjerne identitetsudbyderen og alle tilhørende konfigurationer. Brugere, der autentificerer sig via denne udbyder, vil ikke længere kunne logge ind.", + "idpMessageConfirm": "For at bekræfte, indtast venligst navnet på identitetsudbyderen nedenfor.", + "idpConfirmDelete": "Bekræft Sletning af Identitetsudbyder", + "idpDelete": "Slet identitetsudbyder", + "idp": "Identitetsudbydere", + "idpSearch": "Søg identitetsudbydere...", + "idpAdd": "Tilføj Identitetsudbyder", + "idpClientIdRequired": "Klient-ID er påkrævet.", + "idpClientSecretRequired": "Klienthemmelighed er påkrævet.", + "idpErrorAuthUrlInvalid": "Autentiserings-URL skal være en gyldig URL.", + "idpErrorTokenUrlInvalid": "Token-URL skal være en gyldig URL.", + "idpPathRequired": "Identifikatorbane er påkrævet.", + "idpScopeRequired": "Scopes kræves.", + "idpOidcDescription": "Konfigurer en OpenID Connect identitetsudbyder", + "idpCreatedDescription": "Identitetsudbyder oprettet med succes.", + "idpCreate": "Opret identitetsudbyder", + "idpCreateDescription": "Konfigurer en ny identitetsudbyder for brugerautentificering", + "idpSeeAll": "Se alle identitetsudbydere", + "idpSettingsDescription": "Konfigurer grunnleggende information for din identitetsudbyder", + "idpDisplayName": "Et visningsnavn for denne identitetsudbyder", + "idpAutoProvisionUsers": "Automatisk brugerprovisionering", + "idpAutoProvisionUsersDescription": "Når aktiveret, oprettes brugere automatisk i systemet ved første login, med mulighed til at tildele brugere til roller og organisationer.", + "idpAutoProvisionConfigureAfterCreate": "Du kan konfigurere autoprovisioneringsindstillingerne når identitetsudbyderen er oprettet.", + "licenseBadge": "EE", + "idpType": "Udbydertype", + "idpTypeDescription": "Vælg typen identitetsudbyder du ønsker at konfigurere", + "idpOidcConfigure": "OAuth2/OIDC-konfiguration", + "idpOidcConfigureDescription": "Konfigurer OAuth2/OIDC-leverandørens endpoints og legitimationsoplysninger", + "idpClientId": "Klient-ID", + "idpClientIdDescription": "OAuth2 klient-ID fra identitetsudbyderen", + "idpClientSecret": "Klienthemmelighed", + "idpClientSecretDescription": "Klient-hemmeligheten med OAuth2 fra identitetsudbyderen", + "idpAuthUrl": "Autorisations-URL", + "idpAuthUrlDescription": "OAuth2 autorisasjonsendepunkt URL", + "idpTokenUrl": "Token-URL", + "idpTokenUrlDescription": "OAuth2-tokenendepunkt-URL", + "idpOidcConfigureAlert": "Viktig information", + "idpOidcConfigureAlertDescription": "Efter at du har oprettet identitetsudbyderen, skal du konfigurere callback-URL'en i identitetsudbyderens indstillinger. Callback-URL vil blive tilføjet efter med succes oprettelse.", + "idpToken": "Token-konfiguration", + "idpTokenDescription": "Konfigurer hvordan brugerinformation trekkes ud fra ID-tokenet", + "idpJmespathAbout": "Om JMESPath", + "idpJmespathAboutDescription": "Stierne nedenfor bruger JMESPath-syntaks for at hente ud verdier fra ID-tokenet.", + "idpJmespathAboutDescriptionLink": "Læs mere om JMESPath", + "idpJmespathLabel": "Identifikatorsti", + "idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet", + "idpJmespathEmailPathOptional": "E-mailsti (Valgfrit)", + "idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet", + "idpJmespathNamePathOptional": "Navn Sti (Valgfrit)", + "idpJmespathNamePathOptionalDescription": "Stien til brugerens navn i ID-tokenet", + "idpOidcConfigureScopes": "Scopes", + "idpOidcConfigureScopesDescription": "Mellemrumsepareret liste over OAuth2-scopes at be om", + "idpSubmit": "Opret identitetsudbyder", + "orgPolicies": "Organisationspolitikker", + "idpSettings": "{idpName} Indstillinger", + "idpCreateSettingsDescription": "Konfigurer indstillingerne for identitetsudbyderen", + "roleMapping": "Rolletildeling", + "orgMapping": "Organisationsmapping", + "orgPoliciesSearch": "Søg i organisationens politikker...", + "orgPoliciesAdd": "Tilføj organisationspolitik", + "orgRequired": "Organisation er påkrævet", + "error": "Fejl", + "success": "Succes", + "orgPolicyAddedDescription": "Politik med succes tilføjet", + "orgPolicyUpdatedDescription": "Politikken er med succes opdateret", + "orgPolicyDeletedDescription": "Politik slettet med succes", + "defaultMappingsUpdatedDescription": "Standardtildelinger opdateret med succes", + "orgPoliciesAbout": "Om organisationens politikker", + "orgPoliciesAboutDescription": "Organisationspolitikker bruges til at kontrollere adgang til organisationer baseret på brugerens ID-token. Du kan angive JMESPath-udtryk for at udtrække rolle- og organisationsinformation fra ID-tokenet.", + "orgPoliciesAboutDescriptionLink": "Se dokumentation, for mere information.", + "defaultMappingsOptional": "Standardtildelinger (Valgfrit)", + "defaultMappingsOptionalDescription": "Standardtildelingerne bruges når det ikke er defineret en organisationspolitik for en organisation. Du kan angive standard rolle- og organisationstildelinger som det kan faldes tilbage på her.", + "defaultMappingsRole": "Standard rolletildeling", + "defaultMappingsRoleDescription": "Resultatet af dette udtryk skal returnere rollenavnet, som det er defineret i organisationen, som en streng.", + "defaultMappingsOrg": "Standard organisationstildeling", + "defaultMappingsOrgDescription": "Når denne er sat, skal udtrykket returnere organisations-IDen eller «true» for at brugeren skal få adgang til den organisationen. Når den ikke er sat, er det nok at definere en rolletildeling: brugeren får adgang så længe en gyldig rolletilknytning kan løses for dem i organisationen.", + "defaultMappingsSubmit": "Gem standardtildelinger", + "orgPoliciesEdit": "Rediger Organisationspolitik", + "org": "Organisation", + "orgSelect": "Vælg organisation", + "orgSearch": "Søg organisation", + "orgNotFound": "Ingen organisation fundet.", + "roleMappingPathOptional": "Rollemappingsti (Valgfrit)", + "orgMappingPathOptional": "Organisationstildelingssti (valgfrit)", + "orgPolicyUpdate": "Opdater politik", + "orgPolicyAdd": "Tilføj politik", + "orgPolicyConfig": "Konfigurer adgang for en organisation", + "idpUpdatedDescription": "Identitetsudbyder med succes opdateret", + "redirectUrl": "Omdirigerings-URL", + "orgIdpRedirectUrls": "Omdirigerings-URL'er", + "redirectUrlAbout": "Om omdirigerings-URL", + "redirectUrlAboutDescription": "Dette er URL'en som brugere vil blive omdirigeret efter autentificering. Du skal konfigurere denne URL'en i identitetsudbyderens indstillinger.", + "pangolinAuth": "Autentificering - Pangolin", + "verificationCodeLengthRequirements": "Din bekræftelseskode skal være 8 tegn.", + "errorOccurred": "Det opstod en fejl", + "emailErrorVerify": "Kunne ikke verificere e-mail:", + "emailVerified": "E-mailen er bekræftet! Omdirigerer deg...", + "verificationCodeErrorResend": "Kunne ikke sende bekræftelseskode igen:", + "verificationCodeResend": "Bekræftelseskode sendt igen", + "verificationCodeResendDescription": "Vi har sendt en ny bekræftelseskode til din e-mailadresse. Tjek venligst din indbakke.", + "emailVerify": "Bekræft e-mail", + "emailVerifyDescription": "Indtast bekræftelseskoden, der er sendt til din e-mailadresse.", + "verificationCode": "Bekræftelseskode", + "verificationCodeEmailSent": "Vi har sendt en bekræftelseskode til din e-mailadresse.", + "submit": "Send", + "emailVerifyResendProgress": "Sender igen...", + "emailVerifyResend": "Har du ikke modtaget en kode? Klik her for at sende igen", + "passwordNotMatch": "Adgangskoderne matcher ikke", + "signupError": "Det opstod en fejl ved registrering", + "pangolinLogoAlt": "Pangolin Logo", + "inviteAlready": "Ser ud til at du er blevet inviteret!", + "inviteAlreadyDescription": "For at acceptere invitationen skal du logge ind eller oprette en konto.", + "signupQuestion": "Har du allerede en konto?", + "login": "Log ind", + "resourceNotFound": "Ressource ikke fundet", + "resourceNotFoundDescription": "Ressourcen du prøver at få adgang til findes ikke.", + "pincodeRequirementsLength": "PIN skal være præcis 6 cifre", + "pincodeRequirementsChars": "PIN skal kun inneholde tal", + "passwordRequirementsLength": "Adgangskode skal være mindst 1 tegn langt", + "passwordRequirementsTitle": "Adgangskodekrav:", + "passwordRequirementLength": "Mindst 8 tegn lang", + "passwordRequirementUppercase": "Mindst én stort bogstav", + "passwordRequirementLowercase": "Mindst ét lille bogstav", + "passwordRequirementNumber": "Mindst ét tal", + "passwordRequirementSpecial": "Mindst ét specialtegn", + "passwordRequirementsMet": "✓ Adgangskoden opfylder alle krav", + "passwordStrength": "Adgangskodestyrke", + "passwordStrengthWeak": "Svag", + "passwordStrengthMedium": "Medium", + "passwordStrengthStrong": "Stærk", + "passwordRequirements": "Krav:", + "passwordRequirementLengthText": "8+ tegn", + "passwordRequirementUppercaseText": "Stort bogstav (A-Z)", + "passwordRequirementLowercaseText": "Lille bogstav (a-z)", + "passwordRequirementNumberText": "Tal (0-9)", + "passwordRequirementSpecialText": "Specialtegn (!@#$%...)", + "passwordsDoNotMatch": "Adgangskoderne matcher ikke", + "otpEmailRequirementsLength": "OTP skal være mindst 1 tegn lang.", + "otpEmailSent": "OTP sendt", + "otpEmailSentDescription": "En OTP er sendt til din e-mail", + "otpEmailErrorAuthenticate": "Mislykkedes at autentificere med e-mail", + "pincodeErrorAuthenticate": "Kunne ikke autentificere med PIN-kode", + "passwordErrorAuthenticate": "Kunne ikke autentificere med adgangskode", + "poweredBy": "Drevet af", + "authenticationRequired": "Autentificering påkrævet", + "authenticationMethodChoose": "Vælg din foretrukne metode for at få adgang til {name}", + "authenticationRequest": "Du skal autentificere dig for at få adgang til {name}", + "user": "Bruger", + "pincodeInput": "6-sifret PIN-kode", + "pincodeSubmit": "Log ind med PIN", + "passwordSubmit": "Log ind med adgangskode", + "otpEmailDescription": "En engangskode vil blive sendt til denne e-mailen.", + "otpEmailSend": "Send engangskode", + "otpEmail": "Engangskode (OTP)", + "otpEmailSubmit": "Send OTP", + "backToEmail": "Tilbage til E-mail", + "noSupportKey": "Serveren kører uden en supporterlicens. Overvej at støtte prosjektet!", + "accessDenied": "Adgang nægtet", + "accessDeniedDescription": "Du har ikke adgang til denne ressource. Hvis dette er en fejl, venligst kontakt administratoren.", + "accessTokenError": "Fejl ved tjek af adgangstoken", + "accessGranted": "Adgang givet", + "accessUrlInvalid": "Ugyldig adgangs-URL", + "accessGrantedDescription": "Du har fått adgang til denne ressource. Omdirigerer deg...", + "accessUrlInvalidDescription": "Denne delings-URL er ugyldig. Kontakt venligst ressourceejeren for en ny URL.", + "tokenInvalid": "Ugyldigt token", + "pincodeInvalid": "Ugyldig kode", + "passwordErrorRequestReset": "Forespørgsel om nulstilling mislykkedes", + "passwordErrorReset": "Kunne ikke nulstille adgangskode:", + "passwordResetSuccess": "Adgangskoden er nulstillet! Går tilbage til login...", + "passwordReset": "Nulstil adgangskode", + "passwordResetDescription": "Følg trinene for at nulstille din adgangskode", + "passwordResetSent": "Vi sender en kode til nulstilling af adgangskoden til denne e-mailadresse.", + "passwordResetCode": "Nulstillingskode", + "passwordResetCodeDescription": "Tjek e-mailen din for nulstillingskoden.", + "generatePasswordResetCode": "Lag nulstillingskode for adgangskode", + "passwordResetCodeGenerated": "Adgangskode nulstillingskoden er generert", + "passwordResetCodeGeneratedDescription": "Del denne kode med brugeren. De kan bruge den til at nulstille adgangskoden.", + "passwordResetUrl": "Nulstillings-URL", + "passwordNew": "Ny adgangskode", + "passwordNewConfirm": "Bekræft ny adgangskode", + "changePassword": "Skift adgangskode", + "changePasswordDescription": "Opdater adgangskoden for din konto", + "oldPassword": "Nuværende adgangskode", + "newPassword": "Ny adgangskode", + "confirmNewPassword": "Bekræft ny adgangskode", + "changePasswordError": "Kunne ikke ændre adgangskode", + "changePasswordErrorDescription": "Der opstod en fejl under ændring af adgangskoden", + "changePasswordSuccess": "Adgangskoden er ændret", + "changePasswordSuccessDescription": "Dit adgangskode blev opdateret", + "passwordExpiryRequired": "Adgangskodeudløb kræves", + "passwordExpiryDescription": "Denne organisation kræver at du bytter adgangskode hver {maxDays} dag.", + "changePasswordNow": "Bytt adgangskode nu", + "pincodeAuth": "Godkendelseskode", + "pincodeSubmit2": "Send kode", + "passwordResetSubmit": "Be om nulstilling", + "passwordResetAlreadyHaveCode": "Indtast koden", + "passwordResetSmtpRequired": "Kontakt din administrator", + "passwordResetSmtpRequiredDescription": "En adgangskode nulstillingskode kræves for at nulstille adgangskoden. Kontakt systemansvarlig for assistanse.", + "passwordBack": "Tilbage til adgangskode", + "loginBack": "Gå tilbage til loginsiden for hovedkontoen", + "signup": "Tilmeld dig", + "loginStart": "Log ind for at komme i gang", + "idpOidcTokenValidating": "Validerer OIDC-token", + "idpOidcTokenResponse": "Valider OIDC-tokensvar", + "idpErrorOidcTokenValidating": "Fejl ved validering af OIDC-token", + "idpConnectingTo": "Opretter forbindelse til {name}", + "idpConnectingToDescription": "Validerer din identitet", + "idpConnectingToProcess": "Opretter forbindelse til...", + "idpConnectingToFinished": "Tilsluttet", + "idpErrorConnectingTo": "Det opstod et problem med at oprette forbindelse til {name}. Venligst kontakt din administrator.", + "idpErrorNotFound": "IdP ikke fundet", + "inviteInvalid": "Ugyldig invitation", + "labels": "Etiketter", + "orgLabelsDescription": "Administrer etiketter i denne organisation.", + "addLabels": "Tilføj etiketter", + "siteLabelsTab": "Etiketter", + "siteLabelsDescription": "Administrer etiketter knyttet til dette sitet.", + "labelsNotFound": "Ingen etiketter fundet.", + "labelsEmptyCreateHint": "Start at skrive ovenfor for at oprette en etikett.", + "labelSearch": "Søg efter etiketter", + "labelSearchOrCreate": "Søg eller opret en etikett", + "accessLabelFilterCount": "{count, plural, one {en etikett} other {# etiketter}}", + "labelOverflowCount": "+{count, plural, one {en etikett} other {# etiketter}}", + "accessLabelFilterClear": "Fjern etikettfiltre", + "accessFilterClear": "Fjern filtre", + "selectColor": "Vælg farve", + "createNewLabel": "Opret ny org-etikett \"{label}\"", + "inviteInvalidDescription": "Invitationslinket er ugyldig.", + "inviteErrorWrongUser": "Invitationen er ikke for denne bruger", + "inviteErrorUserNotExists": "Brugeren findes ikke. Venligst opret en konto først.", + "inviteErrorLoginRequired": "Du skal være loget ind for at acceptere en invitation", + "inviteErrorExpired": "Invitationen kan have udløbet", + "inviteErrorRevoked": "Invitationen kan have blevet trukket tilbage", + "inviteErrorTypo": "Det kan være en stavefejl i invitationslinket", + "pangolinSetup": "Opsætning - Pangolin", + "orgNameRequired": "Organisationsnavn er påkrævet", + "orgIdRequired": "Organisations-ID er påkrævet", + "orgIdMaxLength": "Organisations-ID må højst være 32 tegn", + "orgErrorCreate": "En fejl opstod under oprettelse af organisation", + "pageNotFound": "Siden blev ikke fundet", + "pageNotFoundDescription": "Ups! Siden, du leder efter, findes ikke.", + "overview": "Oversigt", + "home": "Hjem", + "settings": "Indstillinger", + "usersAll": "Alle brugere", + "license": "Licens", + "pangolinDashboard": "Dashboard - Pangolin", + "noResults": "Ingen resultater fundet.", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "Inntastede tager", + "tagsEnteredDescription": "Dette er tagene du har tastet ind.", + "tagsWarnCannotBeLessThanZero": "maxTags og minTags kan ikke være mindre end 0", + "tagsWarnNotAllowedAutocompleteOptions": "Tag ikke tilladt i henhold til autofuldførelsesmuligheder", + "tagsWarnInvalid": "Ugyldigt tag i henhold til validateTag", + "tagWarnTooShort": "Tag {tagText} er for kort", + "tagWarnTooLong": "Tag {tagText} er for langt", + "tagsWarnReachedMaxNumber": "Maksimalt antal tilladte tager er nådd", + "tagWarnDuplicate": "Duplikeret tag {tagText} blev ikke tilføjet", + "supportKeyInvalid": "Ugyldig nøgle", + "supportKeyInvalidDescription": "Din supporternøgle er ugyldig.", + "supportKeyValid": "Gyldig nøgle", + "supportKeyValidDescription": "Din supporternøgle er valideret. Tak for din støtte!", + "supportKeyErrorValidationDescription": "Kunne ikke validere supporternøgle.", + "supportKey": "Støtt utviklingen og adopter en Pangolin!", + "supportKeyDescription": "Køb en supporternøgle for at hjelpe oss med at fortsætte utviklingen af Pangolin for fællesskabet. Dit bidrag lader oss bruge mere tid på at vedligeholde og tilføje nye funktioner i applikationen for alle. Vi vil aldrig bruge dette til at legge funktioner bak en betalingsmur. Dette er atskilt fra enhver kommerciel udgave.", + "supportKeyPet": "Du vil også få adoptere og møte din helt egen kæledyr-Pangolin!", + "supportKeyPurchase": "Betalinger behandles via GitHub. Etterpå kan du hente din nøgle på", + "supportKeyPurchaseLink": "vores hjemmeside", + "supportKeyPurchase2": "og løse den ind her.", + "supportKeyLearnMore": "Læs mere.", + "supportKeyOptions": "Vælg venligst den mulighed, der passer dig bedst.", + "supportKetOptionFull": "Full supporter", + "forWholeServer": "For hele serveren", + "lifetimePurchase": "Livstidskøb", + "supporterStatus": "Supporterstatus", + "buy": "Køb", + "supportKeyOptionLimited": "Begrænset supporter", + "forFiveUsers": "For 5 eller færre brugere", + "supportKeyRedeem": "Løs ind supporternøgle", + "supportKeyHideSevenDays": "Skjul i 7 dage", + "supportKeyEnter": "Indtast supporternøgle", + "supportKeyEnterDescription": "Møt din helt egen kæledyr-Pangolin!", + "githubUsername": "GitHub-brugernavn", + "supportKeyInput": "Supporternøgle", + "supportKeyBuy": "Køb supporternøgle", + "logoutError": "Fejl ved logout", + "signingAs": "Logget ind som", + "serverAdmin": "Serveradministrator", + "managedSelfhosted": "Administreret selvhostet", + "otpEnable": "Aktivér tofaktor", + "otpDisable": "Deaktivér tofaktor", + "logout": "Log ud", + "licenseTierProfessionalRequired": "Professionel udgave påkrævet", + "licenseTierProfessionalRequiredDescription": "Denne funktion er kun tilgængelig i den professionelle udgave.", + "actionGetOrg": "Hent organisation", + "updateOrgUser": "Opdater organisationsbruger", + "createOrgUser": "Opret organisationsbruger", + "actionUpdateOrg": "Opdater organisation", + "actionRemoveInvitation": "Fjern invitation", + "actionUpdateUser": "Opdater bruger", + "actionGetUser": "Hent bruger", + "actionGetOrgUser": "Hent organisationsbruger", + "actionListOrgDomains": "Vis organisationsdomæner", + "actionGetDomain": "Hent domæne", + "actionCreateOrgDomain": "Opret domæne", + "actionUpdateOrgDomain": "Opdater domæne", + "actionDeleteOrgDomain": "Slet domæne", + "actionGetDNSRecords": "Hent DNS-poster", + "actionRestartOrgDomain": "Genstart Domæne", + "actionCreateSite": "Opret site", + "actionDeleteSite": "Slet site", + "actionGetSite": "Hent site", + "actionListSites": "Vis sites", + "actionApplyBlueprint": "Brug blueprint", + "actionListBlueprints": "Vis blueprints", + "actionGetBlueprint": "Hent blueprint", + "setupToken": "Opsætningstoken", + "setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.", + "setupTokenRequired": "Opsætningstoken er nødvendig", + "actionUpdateSite": "Opdater site", + "actionResetSiteBandwidth": "Nulstil organisationsbåndbredde", + "actionListSiteRoles": "Vis tilladte områderoller", + "actionCreateResource": "Opret ressource", + "actionDeleteResource": "Slet ressource", + "actionGetResource": "Hent ressource", + "actionListResource": "Vis ressourcer", + "actionUpdateResource": "Opdater ressource", + "actionListResourceUsers": "Vis ressourcebrugere", + "actionSetResourceUsers": "Angiv ressourcebrugere", + "actionSetAllowedResourceRoles": "Angiv tilladte ressourceroller", + "actionListAllowedResourceRoles": "Vis tilladte ressourceroller", + "actionSetResourcePassword": "Angiv ressourceadgangskode", + "actionSetResourcePincode": "Angiv ressource-PIN-kode", + "actionSetResourceEmailWhitelist": "Angiv e-mailwhitelist for ressource", + "actionGetResourceEmailWhitelist": "Hent e-mailwhitelist for ressource", + "actionCreateTarget": "Opret mål", + "actionDeleteTarget": "Slet mål", + "actionGetTarget": "Hent mål", + "actionListTargets": "Vis mål", + "actionUpdateTarget": "Opdater mål", + "actionCreateRole": "Opret rolle", + "actionDeleteRole": "Slet rolle", + "actionGetRole": "Hent rolle", + "actionListRole": "Vis roller", + "actionUpdateRole": "Opdater rolle", + "actionListAllowedRoleResources": "Vis tilladte rolleressourcer", + "actionInviteUser": "Inviter bruger", + "actionRemoveUser": "Fjern bruger", + "actionListUsers": "Vis brugere", + "actionAddUserRole": "Tilføj brugerrolle", + "actionSetUserOrgRoles": "Angiv brugerroller", + "actionGenerateAccessToken": "Generer adgangstoken", + "actionDeleteAccessToken": "Slet adgangstoken", + "actionListAccessTokens": "Vis adgangstokens", + "actionCreateResourceRule": "Opret ressourceregel", + "actionDeleteResourceRule": "Slet ressourceregel", + "actionListResourceRules": "Vis ressourceregler", + "actionUpdateResourceRule": "Opdater ressourceregel", + "actionListOrgs": "Vis organisationer", + "actionCheckOrgId": "Tjek ID", + "actionCreateOrg": "Opret organisation", + "actionDeleteOrg": "Slet organisation", + "actionListApiKeys": "Vis API-nøgler", + "actionListApiKeyActions": "Vis API-nøglehandlinger", + "actionSetApiKeyActions": "Angiv tilladte handlinger for API-nøgle", + "actionCreateApiKey": "Opret API-nøgle", + "actionDeleteApiKey": "Slet API-nøgle", + "actionCreateIdp": "Opret IdP", + "actionUpdateIdp": "Opdater IdP", + "actionDeleteIdp": "Slet IdP", + "actionListIdps": "Vis IdP'er", + "actionGetIdp": "Hent IdP", + "actionCreateIdpOrg": "Opret IdP-organisationspolitik", + "actionDeleteIdpOrg": "Slet IdP-organisationspolitik", + "actionListIdpOrgs": "Vis IdP-organisationer", + "actionUpdateIdpOrg": "Opdater IdP-organisation", + "actionCreateClient": "Opret Klient", + "actionDeleteClient": "Slet klient", + "actionArchiveClient": "Arkiver klient", + "actionUnarchiveClient": "Fjern arkivering klient", + "actionBlockClient": "Bloker kunde", + "actionUnblockClient": "Fjern blokering af klient", + "actionUpdateClient": "Opdater klient", + "actionListClients": "Vis klienter", + "actionGetClient": "Hent klient", + "actionCreateSiteResource": "Opret siteressource", + "actionDeleteSiteResource": "Slet Siteressource", + "actionGetSiteResource": "Hent Siteressource", + "actionListSiteResources": "Vis Siteressourcer", + "actionUpdateSiteResource": "Opdater Siteressource", + "actionListInvitations": "Vis invitationer", + "actionExportLogs": "Eksportér logs", + "actionViewLogs": "Vis logs", + "noneSelected": "Ingen valgt", + "orgNotFound2": "Ingen organisationer fundet.", + "search": "Søg…", + "searchPlaceholder": "Søg...", + "emptySearchOptions": "Ingen valg fundet", + "create": "Opret", + "orgs": "Organisationer", + "loginError": "Der opstod en uventet fejl. Prøv venligst igen.", + "loginRequiredForDevice": "Login er påkrævet for din enhed.", + "passwordForgot": "Glemt adgangskoden dit?", + "otpAuth": "Tofaktorgodkendelse", + "otpAuthDescription": "Indtast koden fra autentiseringsappen din eller en af dine engangs reservekoder.", + "otpAuthSubmit": "Send kode", + "idpContinue": "Eller fortsæt med", + "otpAuthBack": "Tilbage til adgangskode", + "navbar": "Navigationsmenu", + "navbarDescription": "Hovednavigasjonsmeny for applikationen", + "navbarDocsLink": "Dokumentation", + "otpErrorEnable": "Kunne ikke aktivere 2FA", + "otpErrorEnableDescription": "En fejl opstod under aktivering af 2FA", + "otpSetupCheckCode": "Indtast venligst en 6-sifret kode", + "otpSetupCheckCodeRetry": "Ugyldig kode. Prøv venligst igen.", + "otpSetup": "Aktivér tofaktorgodkendelse", + "otpSetupDescription": "Sikre din konto med et ekstra lag med beskyttelse", + "otpSetupScanQr": "Skann denne QR-koden med autentiseringsappen din eller indtast den hemmelige nøgle manuelt:", + "otpSetupSecretCode": "Godkendelseskode", + "otpSetupSuccess": "Tofaktorgodkendelse aktiveret", + "otpSetupSuccessStoreBackupCodes": "Din konto er nu sikrere. Ikke glem at gem reservekodene dine.", + "otpErrorDisable": "Kunne ikke deaktivere 2FA", + "otpErrorDisableDescription": "En fejl opstod under deaktivering af 2FA", + "otpRemove": "Deaktivér tofaktorgodkendelse", + "otpRemoveDescription": "Deaktivér tofaktorgodkendelse for din konto", + "otpRemoveSuccess": "Tofaktorgodkendelse deaktiveret", + "otpRemoveSuccessMessage": "Tofaktorgodkendelse er deaktiveret for din konto. Du kan aktivere den tilbage når som helst.", + "otpRemoveSubmit": "Deaktivér 2FA", + "paginator": "Side {current} af {last}", + "paginatorToFirst": "Gå til første side", + "paginatorToPrevious": "Gå til forrige side", + "paginatorToNext": "Gå til næste side", + "paginatorToLast": "Gå til sidste side", + "copyText": "Kopiér tekst", + "copyTextFailed": "Kunne ikke kopiere tekst: ", + "copyTextClipboard": "Kopiér til udklipsholder", + "inviteErrorInvalidConfirmation": "Ugyldig bekræftelse", + "passwordRequired": "Adgangskode er påkrævet", + "allowAll": "Tillad alle", + "permissionsAllowAll": "Tillad alle rettigheter", + "githubUsernameRequired": "GitHub-brugernavn er påkrævet", + "supportKeyRequired": "supporternøgle er påkrævet", + "passwordRequirementsChars": "Adgangskoden skal være mindst 8 tegn", + "language": "Sprog", + "verificationCodeRequired": "Kode er påkrævet", + "userErrorNoUpdate": "Ingen bruger at opdatere", + "siteErrorNoUpdate": "Ingen site at opdatere", + "resourceErrorNoUpdate": "Ingen ressource at opdatere", + "authErrorNoUpdate": "Ingen autentificeringsinfo at opdatere", + "orgErrorNoUpdate": "Ingen organisation at opdatere", + "orgErrorNoProvided": "Ingen organisation angivet", + "apiKeysErrorNoUpdate": "Ingen API-nøgle at opdatere", + "sidebarOverview": "Oversigt", + "sidebarHome": "Hjem", + "sidebarSites": "Sites", + "sidebarApprovals": "Godkendelsesanmodninger", + "sidebarResources": "Ressourcer", + "sidebarProxyResources": "Offentlig", + "sidebarClientResources": "Privat", + "sidebarPolicies": "Delte politikker", + "sidebarResourcePolicies": "Offentlige ressourcer", + "sidebarAccessControl": "Adgangskontrol", + "sidebarLogsAndAnalytics": "Logs og analyser", + "sidebarTeam": "Lag", + "sidebarUsers": "Brugere", + "sidebarAdmin": "Administrator", + "sidebarInvitations": "Invitationer", + "sidebarRoles": "Roller", + "sidebarShareableLinks": "Delbare links", + "sidebarApiKeys": "API-nøgler", + "sidebarProvisioning": "Provisionering", + "sidebarSettings": "Indstillinger", + "sidebarAllUsers": "Alle brugere", + "sidebarIdentityProviders": "Identitetsudbydere", + "sidebarLicense": "Licens", + "sidebarClients": "Klienter", + "sidebarUserDevices": "Bruger Enheder", + "sidebarMachineClients": "Maskiner", + "sidebarDomains": "Domæner", + "sidebarGeneral": "Administrer", + "sidebarLogAndAnalytics": "Logs og analyser", + "sidebarBluePrints": "Blueprints", + "sidebarAlerting": "Varsling", + "sidebarHealthChecks": "Sundhedstjek", + "sidebarOrganization": "Organisation", + "sidebarManagement": "Administration", + "sidebarBillingAndLicenses": "Fakturering & licenser", + "sidebarLogsAnalytics": "Analyser", + "alertingTitle": "Varsling", + "alertingDescription": "Definer kilder, triggere og handlinger for varsler", + "alertingRules": "Varslingsregler", + "alertingSearchRules": "Søg i regler…", + "alertingAddRule": "Opret regel", + "alertingColumnSource": "Kilde", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Handlinger", + "alertingColumnEnabled": "Aktiveret", + "alertingDeleteQuestion": "Bekræft venligst, at du vil slette denne varslingsregel.", + "alertingDeleteRule": "Slet varslingsregel", + "alertingRuleDeleted": "Varslingsregel slettet", + "alertingRuleSaved": "Varslingsregel gemt", + "alertingRuleSavedCreatedDescription": "Din nye varslingsregel blev oprettet. Du kan fortsætte at redigere den på denne side.", + "alertingRuleSavedUpdatedDescription": "Dine ændringer i denne varslingsreglen blev gemt.", + "alertingEditRule": "Rediger varslingsregel", + "alertingCreateRule": "Opret varslingsregel", + "alertingRuleCredenzaDescription": "Vælg hvad som skal overvåges, når det skal varsles, og hvordan du vil blive informert", + "alertingRuleNamePlaceholder": "Produktionssite nede", + "alertingRuleEnabled": "Regel aktiveret", + "alertingSectionSource": "Kilde", + "alertingSourceType": "Kildetype", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Sundhedstjek", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Sundhedstjek", + "alertingPickResources": "Ressourcer", + "alertingAllSites": "Alle sites", + "alertingAllSitesDescription": "Varsler for alle sites", + "alertingSpecificSites": "Specifikke sites", + "alertingSpecificSitesDescription": "Vælg specifikke sites for overvågning", + "alertingAllHealthChecks": "Alle sundhedstjek", + "alertingAllHealthChecksDescription": "Varsler for alle sundhedstjek", + "alertingSpecificHealthChecks": "Specifikke sundhedstjek", + "alertingSpecificHealthChecksDescription": "Vælg specifikke sundhedstjek for overvågning", + "alertingAllResources": "Alle ressourcer", + "alertingAllResourcesDescription": "Varsler for alle ressourcer", + "alertingSpecificResources": "Specifikke ressourcer", + "alertingSpecificResourcesDescription": "Vælg specifikke ressourcer for overvågning", + "alertingSelectResources": "Vælg ressourcer…", + "alertingResourcesSelected": "{count} ressourcer valgt", + "alertingResourcesEmpty": "Ingen ressourcer med mål i de første 10 resultaterne.", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "Når skal det varsles", + "alertingTriggerSiteOnline": "Site er online", + "alertingTriggerSiteOffline": "Site er offline", + "alertingTriggerSiteToggle": "Ændringer i sitestatus", + "alertingTriggerHcHealthy": "Sundhedstjek sund", + "alertingTriggerHcUnhealthy": "Sundhedstjek usund", + "alertingTriggerHcToggle": "Ændringer i sundhedstjekstatus", + "alertingTriggerResourceHealthy": "Ressource sund", + "alertingTriggerResourceUnhealthy": "Ressource usund", + "alertingTriggerResourceDegraded": "Ressource forringet", + "alertingSearchHealthChecks": "Søg i sundhedstjek…", + "alertingHealthChecksEmpty": "Ingen tilgængelige sundhedstjek.", + "alertingTriggerResourceToggle": "Ændringer i ressourcestatus", + "alertingSourceResource": "Ressource", + "alertingSectionActions": "Handlinger", + "alertingAddAction": "Tilføj handling", + "alertingActionNotify": "E-mail", + "alertingActionNotifyDescription": "Send e-mailvarsler til brugere eller roller", + "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send en HTTP-forespørgsel til et tilpasset endpoint", + "alertingExternalIntegration": "Ekstern integration", + "alertingExternalPagerDutyDescription": "Send varsler til PagerDuty for hændelseshåndtering", + "alertingExternalOpsgenieDescription": "Rute varsler til Opsgenie for vagthåndtering", + "alertingExternalServiceNowDescription": "Opret ServiceNow hændelser fra varslingshændelser", + "alertingExternalIncidentIoDescription": "Utløs Incident.io arbejdsgange fra varsels hændelser", + "alertingActionType": "Handlings type", + "alertingNotifyUsers": "Brugere", + "alertingNotifyRoles": "Roller", + "alertingNotifyEmails": "E-mailadresser", + "alertingEmailPlaceholder": "Tilføj e-mail og trykk Enter", + "alertingWebhookMethod": "HTTP-metode", + "alertingWebhookSecret": "Signeringshemmelighed (valgfrit)", + "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Tilføj header", + "alertingSelectSites": "Vælg sites…", + "alertingSitesSelected": "{count} sites valgt", + "alertingSelectHealthChecks": "Vælg sundhedstjek…", + "alertingHealthChecksSelected": "{count} sundhedstjek valgt", + "alertingNoHealthChecks": "Ingen mål med sundhedstjek aktiveret", + "alertingHealthCheckStub": "Valg af sundhedstjekkilde er ikke færdiggjort endnu - du kan stadig konfigurere triggere og handlinger.", + "alertingSelectUsers": "Vælg brugere…", + "alertingUsersSelected": "{count} brugere valgt", + "alertingSelectRoles": "Vælg roller…", + "alertingRolesSelected": "{count} roller valgt", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryAllSites": "Alle sites", + "alertingSummaryHealthChecks": "Sundhedstjek ({count})", + "alertingSummaryAllHealthChecks": "Alle sundhedstjek", + "alertingSummaryResources": "Ressourcer ({count})", + "alertingSummaryAllResources": "Alle ressourcer", + "alertingErrorNameRequired": "Indtast et navn", + "alertingErrorActionsMin": "Tilføj mindst én handling", + "alertingErrorPickSites": "Vælg mindst ét site", + "alertingErrorPickHealthChecks": "Vælg mindst én sundhedstjek", + "alertingErrorPickResources": "Vælg mindst én ressource", + "alertingErrorTriggerSite": "Vælg en trigger for site", + "alertingErrorTriggerHealth": "Vælg en trigger for sundhedstjek", + "alertingErrorTriggerResource": "Vælg en trigger for ressource", + "alertingErrorNotifyRecipients": "Vælg brugere, roller, eller mindst én e-mail", + "alertingConfigureSource": "Konfigurer kilde", + "alertingConfigureTrigger": "Konfigurer trigger", + "alertingConfigureActions": "Konfigurer handlinger", + "alertingBackToRules": "Tilbage til regler", + "alertingRuleCooldown": "Cooldown (sekunder)", + "alertingRuleCooldownDescription": "Minimumstid mellem gentagne varsler for samme regel. Sæt til 0 for at udløse hver gang.", + "alertingDraftBadge": "Kladde - gem for at gem denne reglen", + "alertingSidebarHint": "Klik på et steg på lærredet for at redigere det her.", + "alertingGraphCanvasTitle": "Regelflow", + "alertingGraphCanvasDescription": "Visuel oversigt over kilde, trigger og handlinger. Vælg en node for at redigere den i panelet.", + "alertingNodeNotConfigured": "Ikke konfigureret endnu", + "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", + "alertingNodeRoleSource": "Kilde", + "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleAction": "Handling", + "alertingTabRules": "Varslingsregler", + "alertingTabHealthChecks": "Sundhedstjek", + "alertingRulesBannerTitle": "Bli varslet", + "alertingRulesBannerDescription": "Hver regel binder sammen hvad som skal overvåges (et site, sundhedstjek eller ressource), når det skal varsles (for eksempel offline eller usund), og hvordan varsle teamet dit via e-mail, webhooks eller integrationer. Brug denne listen for at oprette, aktivere og administrer disse reglerne.", + "alertingHealthChecksBannerTitle": "Overvåg sundhed & ressourcer", + "alertingHealthChecksBannerDescription": "Sundhedstjek er HTTP- eller TCP-monitorer, du definerer én gang. Du kan derefter bruge dem som kilder i varslingsregler, så du bliver varslet, når et mål bliver sundt eller usundt. Sundhedstjek på ressourcer vises også her.", + "standaloneHcTableTitle": "Sundhedstjek", + "standaloneHcSearchPlaceholder": "Søg i sundhedstjek…", + "standaloneHcAddButton": "Opret sundhedstjek", + "standaloneHcCreateTitle": "Opret sundhedstjek", + "standaloneHcEditTitle": "Rediger sundhedstjek", + "standaloneHcDescription": "Konfigurer en HTTP- eller TCP-sundhedstjek for brug i varslingsregler.", + "standaloneHcNameLabel": "Navn", + "standaloneHcNamePlaceholder": "Min HTTP-monitor", + "standaloneHcDeleteTitle": "Slet sundhedstjek", + "standaloneHcDeleteQuestion": "Bekræft venligst, at du vil slette dette sundhedstjek.", + "standaloneHcDeleted": "Sundhedstjek slettet", + "standaloneHcSaved": "Sundhedstjek gemt", + "standaloneHcColumnHealth": "Sundhed", + "standaloneHcColumnMode": "Tilstand", + "standaloneHcColumnTarget": "Mål", + "standaloneHcHealthStateHealthy": "Sund", + "standaloneHcHealthStateUnhealthy": "Usund", + "standaloneHcHealthStateUnknown": "Ukendt", + "standaloneHcFilterAnySite": "Alle sites", + "standaloneHcFilterAnyResource": "Alle ressourcer", + "standaloneHcFilterMode": "Tilstand", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Sundhed", + "standaloneHcFilterEnabled": "Aktiveret", + "standaloneHcFilterEnabledOn": "Aktiveret", + "standaloneHcFilterEnabledOff": "Deaktiveret", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Ressource {id}", + "blueprints": "Blueprints", + "blueprintsLog": "Blueprint-log", + "blueprintsDescription": "Se tidligere blueprint-applikationer og deres resultater, eller brug et nyt blueprint", + "blueprintAdd": "Tilføj blueprint", + "blueprintGoBack": "Se alle blueprints", + "blueprintCreate": "Oprette skabelon", + "blueprintCreateDescription2": "Følg trinene nedenfor for at oprette og bruge en ny blueprint", + "blueprintDetails": "Blueprint detaljer", + "blueprintDetailsDescription": "Se resultatet af den påførte blåkopien og alle fejl som opstod", + "blueprintInfo": "Blueprint information", + "message": "Meddelelse", + "blueprintContentsDescription": "Definere indholdet til YAML som beskriver infrastrukturen", + "blueprintErrorCreateDescription": "Det opstod en fejl da blueprintet blev tilføjet", + "blueprintErrorCreate": "Fejl ved oprettelse af blueprint", + "searchBlueprintProgress": "Søg efter plantegninger...", + "appliedAt": "Anvendt den", + "source": "Kilde", + "contents": "Indhold", + "parsedContents": "Parset indhold (skrivebeskyttet)", + "enableDockerSocket": "Aktivér Docker-blueprint", + "enableDockerSocketDescription": "Aktivér Docker Socket etikett skrubbing for blueprint etiketter. Socket sti skal angives til site-connectoren. Læs om hvordan dette fungerer i dokumentasjonen.", + "newtAutoUpdate": "Aktivér Automatisk Opdatering af Site", + "newtAutoUpdateDescription": "Når dette er aktiveret, vil site-connectorer automatisk downloade den nyeste version og genstarte sig selv. Dette kan tilsidesættes pr. site.", + "siteAutoUpdate": "Automatisk Opdatering af Site", + "siteAutoUpdateLabel": "Aktivér Automatisk Opdatering", + "siteAutoUpdateDescription": "Når dette er aktiveret, vil dette sites connector automatisk downloade den nyeste version og genstarte sig selv.", + "siteAutoUpdateOrgDefault": "Organisation standard: {state}", + "siteAutoUpdateOverriding": "Overstyrer organisationens indstilling", + "siteAutoUpdateResetToOrg": "Nulstil til Organisationsstandard", + "siteAutoUpdateEnabled": "aktiveret", + "siteAutoUpdateDisabled": "deaktiveret", + "viewDockerContainers": "Vis Docker-containere", + "containersIn": "Containere i {siteName}", + "selectContainerDescription": "Vælg en hvilken som helst container for at bruge den som værtsnavn for dette mål. Klik på en port for at bruge den.", + "containerName": "Navn", + "containerImage": "Bilde", + "containerState": "Tilstand", + "containerNetworks": "Netværk", + "containerHostnameIp": "Hostnavn/IP", + "containerLabels": "Etiketter", + "containerLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}", + "containerLabelsTitle": "Containeretiketter", + "containerLabelEmpty": "", + "containerPorts": "Porte", + "containerPortsMore": "+{count} til", + "containerActions": "Handlinger", + "select": "Vælg", + "noContainersMatchingFilters": "Ingen containere fundet som matcher de nuværende filtre.", + "showContainersWithoutPorts": "Vis containere uden porte", + "showStoppedContainers": "Vis stoppede containere", + "noContainersFound": "Ingen containere fundet. Sørg for at Docker-containere kører.", + "searchContainersPlaceholder": "Søg blandt {count} containere...", + "searchResultsCount": "{count, plural, one {ét resultat} other {# resultater}}", + "filters": "Filtre", + "filterOptions": "Filterindstillinger", + "filterPorts": "Porte", + "filterStopped": "Stoppet", + "clearAllFilters": "Ryd alle filtre", + "columns": "Kolonner", + "toggleColumns": "Vis/skjul kolonner", + "refreshContainersList": "Opdater containerliste", + "searching": "Søger...", + "noContainersFoundMatching": "Ingen containere fundet som matcher \"{filter}\".", + "light": "lys", + "dark": "mørk", + "system": "system", + "theme": "Tema", + "subnetRequired": "Subnet er påkrævet", + "initialSetupTitle": "Førstegangsopsætning af server", + "initialSetupDescription": "Opret den første serveradministratorkontoen. Det kan kun findes én serveradministrator. Du kan altid ændre denne logininformationen senere.", + "createAdminAccount": "Opret administratorkonto", + "setupErrorCreateAdmin": "En fejl opstod under oprettelsen af serveradministratorkontoen.", + "certificateStatus": "Certifikat", + "certificateStatusAutoRefreshHint": "Status opdateres automatisk.", + "loading": "Indlæser", + "loadingEllipsis": "Indlæser...", + "loadingAnalytics": "Indlæser analyser", + "restart": "Genstart", + "domains": "Domæner", + "domainsDescription": "Opret og administrer domæner som er tilgængelige i organisationen", + "domainsSearch": "Søg i domæner...", + "domainAdd": "Tilføj domæne", + "domainAddDescription": "Registrer et nyt domæne med organisationen", + "domainCreate": "Opret domæne", + "domainCreatedDescription": "Domæne blev oprettet", + "domainDeletedDescription": "Domæne blev slettet", + "domainQuestionRemove": "Er du sikker på at du vil fjerne domænet?", + "domainMessageRemove": "Når domænet er fjernet, vil det ikke længere være forbundet med organisationen.", + "domainConfirmDelete": "Bekræft sletning af domæne", + "domainDelete": "Slet domæne", + "domain": "Domæne", + "selectDomainTypeNsName": "Domenedelegering (NS)", + "selectDomainTypeNsDescription": "Dette domænet og alle dets underdomæner. Brug dette når du vil kontrollere en hel domænezone.", + "selectDomainTypeCnameName": "Enkelt domæne (CNAME)", + "selectDomainTypeCnameDescription": "Kun dette specifikke domænet. Brug dette for individuelle underdomæner eller specifikke domæneposter.", + "selectDomainTypeWildcardName": "Wildcard-domæne", + "selectDomainTypeWildcardDescription": "Dette domænet og dets underdomæner.", + "domainDelegation": "Enkelt domæne", + "selectType": "Vælg en type", + "actions": "Handlinger", + "refresh": "Opdater", + "refreshError": "Kunne ikke opdatere data", + "verified": "Verificeret", + "pending": "Afventer", + "pendingApproval": "Venter på godkendelse", + "sidebarBilling": "Fakturering", + "billing": "Fakturering", + "orgBillingDescription": "Administrer faktureringsinformation og abonnementer", + "github": "GitHub", + "pangolinHosted": "Hostet af Pangolin", + "fossorial": "Fossorial", + "completeAccountSetup": "Fuldfør kontoopsætning", + "completeAccountSetupDescription": "Angiv adgangskoden dit for at komme i gang", + "accountSetupSent": "Vi sender en opsætningskode for kontoen til denne e-mailadressen.", + "accountSetupCode": "Opsætningskode", + "accountSetupCodeDescription": "Tjek din e-mail for opsætningskoden.", + "passwordCreate": "Opret adgangskode", + "passwordCreateConfirm": "Bekræft adgangskode", + "accountSetupSubmit": "Send opsætningskode", + "completeSetup": "Fuldfør opsætning", + "accountSetupSuccess": "Kontoopsætning fuldført! Velkommen til Pangolin!", + "documentation": "Dokumentation", + "saveAllSettings": "Gem alle indstillinger", + "saveResourceTargets": "Gem indstillinger", + "saveResourceHttp": "Gem indstillinger", + "saveProxyProtocol": "Gem indstillinger", + "settingsUpdated": "Indstillinger opdateret", + "settingsUpdatedDescription": "Indstillinger opdateret med succes", + "settingsErrorUpdate": "Kunne ikke opdatere indstillinger", + "settingsErrorUpdateDescription": "En fejl opstod under opdatering af indstillinger", + "sidebarCollapse": "Skjul", + "sidebarExpand": "Udvid", + "productUpdateMoreInfo": "{noOfUpdates} flere opdateringer", + "productUpdateInfo": "{noOfUpdates} opdateringer", + "productUpdateWhatsNew": "Hvad er nyt", + "productUpdateTitle": "Opdateringer om produktet", + "productUpdateEmpty": "Ingen opdateringer", + "dismissAll": "Afvis alle", + "pangolinUpdateAvailable": "Opdatering tilgængelig", + "pangolinUpdateAvailableInfo": "Version {version} er klar til at installere", + "pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater", + "newtUpdateAvailable": "Opdatering tilgængelig", + "newtUpdateAvailableInfo": "En ny version af Newt er tilgængeligt. Opdater venligst til den nyeste version for den bedste oplevelse.", + "pangolinNodeUpdateAvailableInfo": "En ny version af Pangolin Node er tilgængeligt. Opdater venligst til den nyeste version for den bedste oplevelse.", + "domainPickerEnterDomain": "Domæne", + "domainPickerPlaceholder": "minapp.eksempel.no", + "domainPickerDescription": "Indtast hele domænet til ressourcen for at se tilgængelige alternativer.", + "domainPickerDescriptionSaas": "Indtast et fullt domæne, underdomæne eller kun et navn for at se tilgængelige alternativer", + "domainPickerTabAll": "Alle", + "domainPickerTabOrganization": "Organisation", + "domainPickerTabProvided": "Levert", + "domainPickerSortAsc": "A-AT", + "domainPickerSortDesc": "AT-A", + "domainPickerCheckingAvailability": "Kontrollerer tilgængelighed...", + "domainPickerNoMatchingDomains": "Ingen matchende domæner fundet. Prøv et andet domæne, eller kontroller organisationens domæneindstillinger.", + "domainPickerOrganizationDomains": "Organisationsdomæner", + "domainPickerProvidedDomains": "Leverte domæner", + "domainPickerSubdomain": "Underdomæne: {subdomain}", + "domainPickerNamespace": "Navnerom: {namespace}", + "domainPickerShowMore": "Vis mere", + "regionSelectorTitle": "Vælg Region", + "domainPickerRemoteExitNodeWarning": "Leverede domæner understøttes ikke, når sites kobles til eksterne exitnoder. For ressourcer, der skal være tilgængelige på eksterne noder, skal du i stedet bruge et brugerdefineret domæne.", + "regionSelectorInfo": "At vælge en region hjelper oss med at give bedre ydeevne for din placering. Du behøver ikke være i samme region som serveren.", + "regionSelectorPlaceholder": "Vælg en region", + "regionSelectorComingSoon": "Kommer snart", + "billingLoadingSubscription": "Indlæser abonnement...", + "billingFreeTier": "Gratis nivå", + "billingWarningOverLimit": "Advarsel: Du har overskredet en eller flere forbrugsgrænser. Dine sites vil ikke opret forbindelse til før du ændrer dit abonnement eller justerer forbruget.", + "billingUsageLimitsOverview": "Oversikt over forbrugsgrænser", + "billingMonitorUsage": "Overvåg forbruget din i forhold til konfigurerte grænse. Hvis du behøver økte grænse, venligst kontakt support@pangolin.net.", + "billingDataUsage": "Dataforbrug", + "billingSites": "Sites", + "billingUsers": "Brugere", + "billingDomains": "Domæner", + "billingOrganizations": "Orger", + "billingRemoteExitNodes": "Eksterne noder", + "billingNoLimitConfigured": "Ingen grænse konfigureret", + "billingEstimatedPeriod": "Estimert faktureringsperiode", + "billingIncludedUsage": "Inklusive Brug", + "billingIncludedUsageDescription": "Brug inklusive i din nuværende abonnementsplan", + "billingFreeTierIncludedUsage": "Gratis nivå forbrugsgrænser", + "billingIncluded": "inklusive", + "billingEstimatedTotal": "Estimert Totalt:", + "billingNotes": "Notater", + "billingEstimateNote": "Dette er et estimat baseret på din nuværende brug.", + "billingActualChargesMayVary": "Faktiske omkostninger kan variere.", + "billingBilledAtEnd": "Du vil blive fakturert ved slutten af faktureringsperioden.", + "billingModifySubscription": "Ændre abonnement", + "billingStartSubscription": "Start abonnement", + "billingRecurringCharge": "Indgående Avgift", + "billingManageSubscriptionSettings": "Administrer abonnementsindstillinger", + "billingNoActiveSubscription": "Du har ikke et aktivt abonnement. Start dit abonnement for at øge bruksgrensene.", + "billingFailedToLoadSubscription": "Kunne ikke indlæse abonnement", + "billingFailedToLoadUsage": "Kunne ikke indlæse bruksdata", + "billingFailedToGetCheckoutUrl": "Mislykkedes at få betalingslenke", + "billingPleaseTryAgainLater": "Prøv venligst igen senere.", + "billingCheckoutError": "Kasserror", + "billingFailedToGetPortalUrl": "Mislykkedes at hente portal URL", + "billingPortalError": "Portalfeil", + "billingDataUsageInfo": "Du bliver opkrævet for al data, der overføres gennem dine sikre tunneler, når du er forbundet til skyen. Dette inkluderer både indgående og udgående trafik på alle dine sites. Når du når din grænse, vil dine sites blive frakoblet, indtil du opgraderer din plan eller reducerer forbruget. Data belastes ikke ved brug af exitnode-grupper.", + "billingSInfo": "Hvor mange sites du kan bruge", + "billingUsersInfo": "Hvor mange brugere du kan bruge", + "billingDomainInfo": "Hvor mange domæner du kan bruge", + "billingRemoteExitNodesInfo": "Hvor mange fjernnoder du kan bruge", + "billingLicenseKeys": "Licensnøgler", + "billingLicenseKeysDescription": "Administrer dine licensnøgleabonnementer", + "billingLicenseSubscription": "Licens abonnement", + "billingInactive": "Inaktiv", + "billingLicenseItem": "Licens artikkel", + "billingQuantity": "Antal", + "billingTotal": "totalt", + "billingModifyLicenses": "Ændre licensabonnement", + "domainNotFound": "Domæne ikke fundet", + "domainNotFoundDescription": "Denne ressource er deaktiveret fordi domænet ikke længere findes i systemet vores. Venligst angiv et nyt domæne for denne ressource.", + "failed": "Mislykkedes", + "createNewOrgDescription": "Opret en ny organisation", + "organization": "Organisation", + "primary": "Primær", + "port": "Port", + "securityKeyManage": "Administrer sikkerhedsnøgler", + "securityKeyDescription": "Tilføj eller fjern sikkerhedsnøgler for adgangskodeløs autentificering", + "securityKeyRegister": "Registrer ny sikkerhedsnøgle", + "securityKeyList": "Dine sikkerhedsnøgler", + "securityKeyNone": "Ingen sikkerhedsnøgler er registreret endnu", + "securityKeyNameRequired": "Navn er påkrævet", + "securityKeyRemove": "Fjern", + "securityKeyLastUsed": "Sist brugt: {date}", + "securityKeyNameLabel": "Navn på sikkerhedsnøgle", + "securityKeyRegisterSuccess": "Sikkerhedsnøgle registreret", + "securityKeyRegisterError": "Kunne ikke registrere sikkerhedsnøgle", + "securityKeyRemoveSuccess": "Sikkerhedsnøgle fjernet", + "securityKeyRemoveError": "Kunne ikke fjerne sikkerhedsnøgle", + "securityKeyLoadError": "Kunne ikke indlæse ind sikkerhedsnøgler", + "securityKeyLogin": "Brug sikkerhedsnøgle", + "securityKeyAuthError": "Kunne ikke autentificere med sikkerhedsnøgle", + "securityKeyRecommendation": "Registrer en reserve-sikkerhedsnøgle på en anden enhed for at sikre at du altid har adgang til din konto.", + "registering": "Registrerer...", + "securityKeyPrompt": "Venligst bekræft din identitet med sikkerhedsnøglen. Sørg for at sikkerhedsnøglen er forbundet til og klar.", + "securityKeyBrowserNotSupported": "Browseren din understøtter ikke sikkerhedsnøgler. Venligst brug en moderne browser som Chrome, Firefox eller Safari.", + "securityKeyPermissionDenied": "Tillad venligst adgang til din sikkerhedsnøgle for at fortsætte loginprocessen.", + "securityKeyRemovedTooQuickly": "Hold venligst sikkerhedsnøglen tilsluttet, indtil loginprocessen er fuldført.", + "securityKeyNotSupported": "Sikkerhedsnøglen din er muligvis ikke kompatibel. Venligst prøv en anden sikkerhedsnøgle.", + "securityKeyUnknownError": "Der opstod et problem med at bruge din sikkerhedsnøgle. Prøv venligst igen.", + "twoFactorRequired": "Tofaktorgodkendelse er påkrævet for at registrere en sikkerhedsnøgle.", + "twoFactor": "Tofaktorgodkendelse", + "twoFactorAuthentication": "To-faktor autentificering", + "twoFactorDescription": "Denne organisation kræver to-faktor-autentificering.", + "enableTwoFactor": "Aktivér to-faktor autentificering", + "organizationSecurityPolicy": "Politikker for organisations sikkerhed", + "organizationSecurityPolicyDescription": "Denne organisation har sikkerhedskrav som skal opfyldes før du får adgang til den", + "securityRequirements": "Krav Til Sikkerhed", + "allRequirementsMet": "Alle krav er opfyldt", + "completeRequirementsToContinue": "Fuldfør kravene nedenfor for at fortsætte adgangen til denne organisation", + "youCanNowAccessOrganization": "Du har nu adgang til denne organisation", + "reauthenticationRequired": "Økt lengde", + "reauthenticationDescription": "Denne organisation kræver at du logs på alle {maxDays} dage.", + "reauthenticationDescriptionHours": "Denne organisation kræver at du logs ind hver {maxHours} time.", + "reauthenticateNow": "Log ind tilbage", + "adminEnabled2FaOnYourAccount": "Din administrator har aktiveret tofaktorgodkendelse for {email}. Fuldfør venligst opsætningsprocessen for at fortsætte.", + "securityKeyAdd": "Tilføj sikkerhedsnøgle", + "securityKeyRegisterTitle": "Registrer ny sikkerhedsnøgle", + "securityKeyRegisterDescription": "Opret forbindelse til sikkerhedsnøglen og indtast et navn for at identifisere den", + "securityKeyTwoFactorRequired": "Tofaktorgodkendelse påkrævet", + "securityKeyTwoFactorDescription": "Indtast venligst koden for tofaktorgodkendelse for at registrere sikkerhedsnøglen", + "securityKeyTwoFactorRemoveDescription": "Indtast venligst koden for tofaktorgodkendelse for at fjerne sikkerhedsnøglen", + "securityKeyTwoFactorCode": "Tofaktorkode", + "securityKeyRemoveTitle": "Fjern sikkerhedsnøgle", + "securityKeyRemoveDescription": "Indtast adgangskoden dit for at fjerne sikkerhedsnøglen \"{name}\"", + "securityKeyNoKeysRegistered": "Ingen sikkerhedsnøgler registreret", + "securityKeyNoKeysDescription": "Tilføj en sikkerhedsnøgle for at øge sikkerheden for din konto", + "createDomainRequired": "Domæne er påkrævet", + "createDomainAddDnsRecords": "Tilføj DNS-poster", + "createDomainAddDnsRecordsDescription": "Tilføj følgende DNS-poster hos din domæneudbyder for at fuldføre opsætningen.", + "createDomainNsRecords": "NS-poster", + "createDomainRecord": "Post", + "createDomainType": "Type:", + "createDomainName": "Navn:", + "createDomainValue": "Værdi:", + "createDomainCnameRecords": "CNAME-poster", + "createDomainARecords": "A-poster", + "createDomainRecordNumber": "Post {number}", + "createDomainTxtRecords": "TXT-poster", + "createDomainSaveTheseRecords": "Gem disse posterne", + "createDomainSaveTheseRecordsDescription": "Sørg for at gem disse DNS-posterne, da du ikke vil se dem tilbage.", + "createDomainDnsPropagation": "DNS-propagering", + "createDomainDnsPropagationDescription": "DNS-ændringer kan tage lidt tid at propagere over internettet. Dette kan tage fra nogen få minutter til 48 timer, afhængigt af din DNS-udbyder og TTL-indstillinger.", + "resourcePortRequired": "Portnummer er påkrævet for ikke-HTTP-ressourcer", + "resourcePortNotAllowed": "Portnummer skal ikke angis for HTTP-ressourcer", + "billingPricingCalculatorLink": "Pris Kalkulator", + "billingYourPlan": "Din funktionsplan", + "billingViewOrModifyPlan": "Vis eller ændre nuværende abonnement", + "billingViewPlanDetails": "Se Planleggings detaljer", + "billingUsageAndLimits": "Brug og grænse", + "billingViewUsageAndLimits": "Se planets grænse og nuværende brug", + "billingCurrentUsage": "Nuværende brug", + "billingMaximumLimits": "Maks antal grænse", + "billingRemoteNodes": "Eksterne noder", + "billingUnlimited": "Ubegrænset", + "billingPaidLicenseKeys": "Betalt licensnøgler", + "billingManageLicenseSubscription": "Administrer abonnementet for betalte licensnøgler selv hostet", + "billingCurrentKeys": "Nuværende nøgler", + "billingModifyCurrentPlan": "Ændre nuværende plan", + "billingManageLicenseSubscriptionDescription": "Administrer dit abonnement for betalte selvhostede licensnøgler og download fakturaer.", + "billingConfirmUpgrade": "Bekræft opgradering", + "billingConfirmDowngrade": "Bekræft nedgradering", + "billingConfirmUpgradeDescription": "Du er ved at opgradere dit abonnement. Gå gennem de nye grænserne og pris nedenfor.", + "billingConfirmDowngradeDescription": "Du er ved at nedgradere din plan. Gå gennem de nye grænserne og pris nedenfor.", + "billingPlanIncludes": "Plan Inkluderer", + "billingProcessing": "Behandler...", + "billingConfirmUpgradeButton": "Bekræft opgradering", + "billingConfirmDowngradeButton": "Bekræft nedgradering", + "billingLimitViolationWarning": "Brug overbelastede grænse for ny plan", + "billingLimitViolationDescription": "Dit nuværende forbrug overskrider grænserne for denne plan. Efter nedgradering deaktiveres alle handlinger, indtil du reducerer forbruget inden for de nye grænser. Gennemgå venligst funktionerne nedenfor, som i øjeblikket er over grænserne. Overskredne grænser:", + "billingFeatureLossWarning": "Fremhev tilgængelig notifikation", + "billingFeatureLossDescription": "Ved at nedgradere vil funktioner der ikke er tilgængelige i den nye planen automatisk blive deaktiveret. Nogen indstillinger og konfigurationer kan gå tapt. Venligst gennemgå prismatrisen for at forstå hvilke funktioner der ikke længere vil være tilgængelige.", + "billingUsageExceedsLimit": "Nuværende brug ({current}) overskrider grænsen ({limit})", + "billingPastDueTitle": "Betalingen har forfalden", + "billingPastDueDescription": "Betalingen er forfalden. Opdater venligst din betalingsmetode for fortsat at bruge din nuværende funktionsplan. Hvis problemet ikke løses, bliver dit abonnement annulleret, og du bliver nulstillet til gratisniveauet.", + "billingUnpaidTitle": "Abonnement ubetalt", + "billingUnpaidDescription": "Dit abonnement er ubetalt, og du er blevet nulstillet til gratisniveauet. Opdater venligst din betalingsmetode for at gendanne abonnementet.", + "billingIncompleteTitle": "Betaling ufullstendig", + "billingIncompleteDescription": "Betalingen er ufullstendig. Venligst fuldfør betalingsprosessen for at aktivere abonnementet.", + "billingIncompleteExpiredTitle": "Betaling udløbet", + "billingIncompleteExpiredDescription": "Din betaling blev aldrig fuldført og er udløbet. Du er blevet nulstillet til gratisniveauet. Abonnér venligst igen for at gendanne adgangen til betalte funktioner.", + "billingManageSubscription": "Administrer dit abonnement", + "billingResolvePaymentIssue": "Venligst løs dit betalingsproblem før du opgraderer eller nedgraderer betalingen", + "signUpTerms": { + "IAgreeToThe": "Jeg accepterer", + "termsOfService": "brugervilkårene", + "and": "og", + "privacyPolicy": "privatlivspolitikken" + }, + "signUpMarketing": { + "keepMeInTheLoop": "Hold mig opdateret med nyheder, opdateringer og nye funktioner via e-mail." + }, + "siteRequired": "Site er påkrævet.", + "olmTunnel": "Olm-tunnel", + "olmTunnelDescription": "Brug Olm for klientforbindelse", + "errorCreatingClient": "Fejl ved oprettelse af klient", + "clientDefaultsNotFound": "Klientstandarder ikke fundet", + "createClient": "Opret klient", + "createClientDescription": "Oprette en ny klient for at få adgang til private ressourcer", + "seeAllClients": "Se alle klienter", + "clientInformation": "Klientinformation", + "clientNamePlaceholder": "Klientnavn", + "address": "Adresse", + "subnetPlaceholder": "Subnet", + "addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.", + "selectSites": "Vælg sites", + "sitesDescription": "Klienten vil have forbindelse til de valgte områdene", + "clientInstallOlm": "Installer Olm", + "clientInstallOlmDescription": "Få Olm til at køre på dit system", + "clientOlmCredentials": "Legitimationsoplysninger", + "clientOlmCredentialsDescription": "Dette er hvordan klienten vil godkende med serveren", + "olmEndpoint": "Endpoint", + "olmId": "ID", + "olmSecretKey": "Sikkerhedsnøgle", + "clientCredentialsSave": "Gem brugeroplysninger", + "clientCredentialsSaveDescription": "Du vil kun kunne se dette én gang. Sørg for at kopiere det til et sikkert sted.", + "generalSettingsDescription": "Konfigurer de generelle indstillingerne for denne klienten", + "clientUpdated": "Klient opdateret", + "clientUpdatedDescription": "Klienten er blevet opdateret.", + "clientUpdateFailed": "Kunne ikke opdatere klient", + "clientUpdateError": "En fejl opstod under opdatering af klienten.", + "sitesFetchFailed": "Kunne ikke hente sites", + "sitesFetchError": "En fejl opstod under hentning af sites.", + "olmErrorFetchReleases": "En fejl opstod under hentning af Olm-utgivelser.", + "olmErrorFetchLatest": "En fejl opstod under hentning af den nyeste Olm-utgivelsen.", + "enterCidrRange": "Indtast CIDR-site", + "resourceEnableProxy": "Aktivér offentlig proxy", + "resourceEnableProxyDescription": "Aktivér offentlig proxying til denne ressource. Dette giver adgang til ressourcen udefra netværket gennem skyen på en åben port. Kræver Traefik-konfiguration.", + "externalProxyEnabled": "Ekstern proxy aktiveret", + "addNewTarget": "Tilføj nyt mål", + "targetsList": "Liste over mål", + "advancedMode": "Avanceret tilstand", + "advancedSettings": "Avanserte indstillinger", + "targetErrorDuplicateTargetFound": "Duplikat af mål fundet", + "healthCheckHealthy": "Sund", + "healthCheckUnhealthy": "Usund", + "healthCheckUnknown": "Ukendt", + "healthCheck": "Sundhedstjek", + "configureHealthCheck": "Konfigurer Sundhedstjek", + "configureHealthCheckDescription": "Opsæt overvågning af din ressource for at sikre at den altid er tilgængelig", + "enableHealthChecks": "Aktivér Sundhedstjek", + "healthCheckDisabledStateDescription": "Når deaktiveret vil sitet ikke udføre sundhedstjek, og tilstanden vil blive betragtet som ukendt.", + "enableHealthChecksDescription": "Overvåg sundheden for dette mål. Du kan overvåge et andet endpoint end målet, hvis det er nødvendigt.", + "healthScheme": "Metode", + "healthSelectScheme": "Vælg metode", + "healthCheckPortInvalid": "Porten skal være mellem 1 og 65535", + "healthCheckPath": "Sti", + "healthHostname": "IP / Host", + "healthPort": "Port", + "healthCheckPathDescription": "Stien for at tjekke helsestatus.", + "healthyIntervalSeconds": "Sund intervall (sek)", + "unhealthyIntervalSeconds": "Usundt interval (sek.)", + "IntervalSeconds": "Sundt interval", + "timeoutSeconds": "Tidsavbrudd (sek)", + "timeIsInSeconds": "Tid er i sekunder", + "requireDeviceApproval": "Kræv enhedsgodkendelse", + "requireDeviceApprovalDescription": "Brugere med denne rolle skal have nye enheder godkendt af en administrator, før de kan oprette forbindelse og få adgang til ressourcer.", + "sshSettings": "SSH Indstillinger", + "sshAccess": "SSH-adgang", + "rdpSettings": "RDP Indstillinger", + "vncSettings": "VNC Indstillinger", + "sshServer": "SSH-server", + "rdpServer": "RDP-server", + "vncServer": "VNC-server", + "sshServerDescription": "Opsæt autentiseringsmetoden, daemonplasseringen, og serverens destination", + "rdpServerDescription": "Konfigurer destinationen og porten til RDP-serveren", + "vncServerDescription": "Konfigurer destinationen og porten til VNC-serveren", + "sshServerMode": "Tilstand", + "sshServerModeStandard": "Standard SSH-server", + "sshServerModePangolin": "Pangolin SSH", + "sshServerModeStandardDescription": "Ruter kommandoer over netværk til en SSH-server som OpenSSH.", + "sshServerModeNative": "Innfødt SSH Server", + "sshServerModeNativeDescription": "Udfører kommandoer direkte i verten via Site-connectoren. Ingen netværkskonfiguration kræves.", + "sshAuthenticationMethod": "Autentificeringsmetode", + "sshAuthMethodManual": "Manuel autentificering", + "sshAuthMethodManualDescription": "Kræver eksisterende vertlegitimation. Omgår automatisk provisionering.", + "sshAuthMethodAutomated": "Automatisk Provisionering", + "sshAuthMethodAutomatedDescription": "Opretter automatisk brugere, grupper og sudo-tilladelser på verten.", + "sshAuthDaemonLocation": "Autentificering Daemon-plassering", + "sshDaemonLocationSiteDescription": "Utføres lokalt på maskinen som er host for site-connectoren.", + "sshDaemonLocationRemote": "På Ekstern Host", + "sshDaemonLocationRemoteDescription": "Utføres på en separat målenhet på samme netværk.", + "sshDaemonDisclaimer": "Sørg for, at din målenhed er korrekt konfigureret til at køre autentificeringsdaemonen, før du fuldfører denne opsætning, ellers mislykkes provisioneringen.", + "sshDaemonPort": "Daemon-port", + "sshServerDestination": "Serverens Destination", + "sshServerDestinationDescription": "Konfigurer destinationen for SSH-serveren", + "destination": "Destination", + "destinationRequired": "Destination er påkrævet.", + "domainRequired": "Domæne er påkrævet.", + "proxyPortRequired": "Port er påkrævet.", + "invalidPathConfiguration": "Ugyldig sti-konfiguration.", + "invalidRewritePathConfiguration": "Ugyldig omskrivningssti-konfiguration.", + "bgTargetMultiSiteDisclaimer": "Ved at vælge flere sites aktiveres robust routing og failover for høj tilgængelighed.", + "roleAllowSsh": "Tillad SSH", + "roleAllowSshAllow": "Tillad", + "roleAllowSshDisallow": "Forby", + "roleAllowSshDescription": "Tillad brugere med denne rolle at oprette forbindelse til ressourcer via SSH. Når deaktiveret får rollen ikke adgang til SSH.", + "sshSudoMode": "Sudo adgang", + "sshSudoModeNone": "Ingen", + "sshSudoModeNoneDescription": "Brugeren kan ikke køre kommandoer med sudo.", + "sshSudoModeFull": "Full Sudo", + "sshSudoModeFullDescription": "Brugeren kan køre hvilken som helst kommando med sudo.", + "sshSudoModeCommands": "Kommandoer", + "sshSudoModeCommandsDescription": "Brugeren kan kun køre de angitte kommandoene med sudo.", + "sshSudo": "Tillad sudo", + "sshSudoCommands": "Sudo kommandoer", + "sshSudoCommandsDescription": "Liste over kommandoer brugeren har lov til at køre med sudo, separert med komma, mellemrum eller nye linjer. Absolutte stier skal bruges.", + "sshCreateHomeDir": "Opret hjemmappe", + "sshUnixGroups": "Unix grupper", + "sshUnixGroupsDescription": "Unix-grupper at tilføje brugeren i på målverten, separert med komma, mellemrum eller nye linjer.", + "roleTextFieldPlaceholder": "Indtast verdier, eller slipp en.txt eller.csv fil", + "roleTextImportTitle": "Importer fra fil", + "roleTextImportDescription": "Importerer {fileName} til {fieldLabel}.", + "roleTextImportSkipHeader": "Hopp over første rad (header)", + "roleTextImportOverride": "Erstatte eksisterende", + "roleTextImportAppend": "Tilføj eksisterende", + "roleTextImportMode": "Importtilstand", + "roleTextImportPreview": "Forhåndsvisning", + "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer at importere} one {ét element at importere} other {# elementer at importere}}", + "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", + "roleTextImportConfirm": "Import", + "roleTextImportInvalidFile": "Ustøttet filtype", + "roleTextImportInvalidFileDescription": "Kun.txt og.csv filer er støttet.", + "roleTextImportEmpty": "Ingen elementer fundet i filen", + "roleTextImportEmptyDescription": "Filen indeholder ingen importerbare elementer.", + "retryAttempts": "Forsøg igen", + "expectedResponseCodes": "Forventede svarkoder", + "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sund status. Hvis den bliver stående tom, regnes 200-300 som sund.", + "customHeaders": "Brugerdefinerede headers", + "customHeadersDescription": "Headers som er adskilt med linje: Overskriftsnavn: værdi", + "headersValidationError": "Header skal være i formatet: header-navn: værdi.", + "saveHealthCheck": "Gem Sundhedstjek", + "healthCheckSaved": "Sundhedstjek Gemt", + "healthCheckSavedDescription": "Sundhedstjekkonfigurationen blev gemt", + "healthCheckError": "Sundhedstjekfeil", + "healthCheckErrorDescription": "Det opstod en fejl under lagring af sundhedstjekkonfigurationen", + "healthCheckPathRequired": "Sundhedstjeksti er påkrævet", + "healthCheckMethodRequired": "HTTP-metode er påkrævet", + "healthCheckIntervalMin": "Sjekkeintervallet skal være mindst 5 sekunder", + "healthCheckTimeoutMin": "Timeout skal være mindst 1 sekund", + "healthCheckRetryMin": "Forsøg igen skal være mindst 1", + "healthCheckMode": "Tjek tilstand", + "healthCheckStrategy": "Strategi", + "healthCheckModeDescription": "TCP-tilstand verificerer kun forbindelsen. HTTP-tilstand validerer HTTP-responsen.", + "healthyThreshold": "Sunnhets terskel", + "healthyThresholdDescription": "Suksesser på rad som kræves før man markerer som sund.", + "unhealthyThreshold": "Usund terskel", + "unhealthyThresholdDescription": "Fejl på rad som kræves før man markerer som usund.", + "healthCheckHealthyThresholdMin": "Sunnhet terskel skal være mindst 1", + "healthCheckUnhealthyThresholdMin": "Usund terskel skal være mindst 1", + "httpMethod": "HTTP-metode", + "selectHttpMethod": "Vælg HTTP-metode", + "domainPickerSubdomainLabel": "Underdomæne", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard-underdomæner er en betalt funktion. Opgrader venligst for at få adgang til denne funktion.", + "domainPickerBaseDomainLabel": "Basisdomæne", + "domainPickerSearchDomains": "Søg i domæner...", + "domainPickerNoDomainsFound": "Ingen domæner fundet", + "domainPickerLoadingDomains": "Indlæser domæner...", + "domainPickerSelectBaseDomain": "Vælg basisdomæne...", + "domainPickerNotAvailableForCname": "Ikke tilgængelig for CNAME-domæner", + "domainPickerEnterSubdomainOrLeaveBlank": "Indtast underdomæne eller la feltet stå tomt for at bruge basisdomæne.", + "domainPickerEnterSubdomainToSearch": "Indtast et underdomæne for at søge og vælge blandt tilgængelige gratis domæner.", + "domainPickerFreeDomains": "Gratis domæner", + "domainPickerSearchForAvailableDomains": "Søg efter tilgængelige domæner", + "domainPickerNotWorkSelfHosted": "Merk: Gratis tilbudte domæner er ikke tilgængelig for selvhostede instanser akkurat nu.", + "resourceDomain": "Domæne", + "resourceEditDomain": "Rediger domæne", + "siteName": "Områdenavn", + "proxyPort": "Port", + "resourcesTableProxyResources": "Offentlig", + "resourcesTableClientResources": "Privat", + "resourcesTableNoProxyResourcesFound": "Ingen proxy-ressourcer fundet.", + "resourcesTableNoInternalResourcesFound": "Ingen interne ressourcer fundet.", + "resourcesTableDestination": "Destination", + "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Alias adresse", + "resourcesTableAliasAddressInfo": "Denne adressen er en del af organisationens subnet. Den bruges til at løse aliasposter ved hjælp af intern DNS-opløsning.", + "resourcesTableClients": "Klienter", + "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgængelig internt når de er forbundet til med en klient.", + "resourcesTableHealthy": "Frisk", + "resourcesTableDegraded": "Nedgradert", + "resourcesTableUnhealthy": "Usund", + "resourcesTableUnknown": "Ukendt", + "resourcesTableNotMonitored": "Ikke overvåket", + "resourcesTableNoTargets": "Ingen mål", + "editInternalResourceDialogEditClientResource": "Rediger Private Ressourcer", + "editInternalResourceDialogUpdateResourceProperties": "Opdater ressourcekonfigurationen og få adgangskontroller for {resourceName}", + "editInternalResourceDialogResourceProperties": "Ressourceegenskaber", + "editInternalResourceDialogName": "Navn", + "editInternalResourceDialogProtocol": "Protokol", + "editInternalResourceDialogSitePort": "Områdeport", + "editInternalResourceDialogTargetConfiguration": "Målkonfiguration", + "editInternalResourceDialogCancel": "Annuller", + "editInternalResourceDialogSaveResource": "Gem ressource", + "editInternalResourceDialogSuccess": "Succes", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressource opdateret med succes", + "editInternalResourceDialogError": "Fejl", + "editInternalResourceDialogFailedToUpdateInternalResource": "Mislykkedes at opdatere intern ressource", + "editInternalResourceDialogNameRequired": "Navn er påkrævet", + "editInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn", + "editInternalResourceDialogProxyPortMin": "Proxy-port skal være mindst 1", + "editInternalResourceDialogProxyPortMax": "Proxy-port skal være mindre end 65536", + "editInternalResourceDialogInvalidIPAddressFormat": "Ugyldig IP-adresseformat", + "editInternalResourceDialogDestinationPortMin": "Destinasjonsport skal være mindst 1", + "editInternalResourceDialogDestinationPortMax": "Destinasjonsport skal være mindre end 65536", + "editInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", + "editInternalResourceDialogMode": "Tilstand", + "editInternalResourceDialogModePort": "Port", + "editInternalResourceDialogModeHost": "Host", + "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeHttp": "HTTP", + "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", + "editInternalResourceDialogScheme": "Skema", + "editInternalResourceDialogEnableSsl": "Aktivér TLS", + "editInternalResourceDialogEnableSslDescription": "Aktivér SSL/TLS-kryptering for sikre HTTPS-forbindelser til destinationen.", + "editInternalResourceDialogDestination": "Destination", + "editInternalResourceDialogDestinationHostDescription": "IP-adressen eller værtsnavnet til ressourcen på sitets netværk.", + "editInternalResourceDialogDestinationIPDescription": "IP eller hostnavn til ressourcen på sitets netværk.", + "editInternalResourceDialogDestinationCidrDescription": "CIDR-området til ressourcen på sitets netværk.", + "editInternalResourceDialogAlias": "Alias", + "editInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", + "createInternalResourceDialogNoSitesAvailable": "Ingen tilgængelige steder", + "createInternalResourceDialogNoSitesAvailableDescription": "Du skal have mindst ét Newt-site med et konfigureret subnet for at oprette interne ressourcer.", + "createInternalResourceDialogClose": "Luk", + "createInternalResourceDialogCreateClientResource": "Opret privat ressource", + "createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen", + "createInternalResourceDialogResourceProperties": "Ressourceegenskaber", + "createInternalResourceDialogName": "Navn", + "createInternalResourceDialogSite": "Site", + "selectSite": "Vælg site...", + "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", + "noSitesFound": "Ingen sites fundet.", + "createInternalResourceDialogProtocol": "Protokol", + "createInternalResourceDialogTcp": "TCP", + "createInternalResourceDialogUdp": "UDP", + "createInternalResourceDialogSitePort": "Områdeport", + "createInternalResourceDialogSitePortDescription": "Brug denne port for at få adgang til ressourcen på sitet, når du er tilsluttet med en klient.", + "createInternalResourceDialogTargetConfiguration": "Målkonfiguration", + "createInternalResourceDialogDestinationIPDescription": "IP eller hostnavn til ressourcen på sitets netværk.", + "createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressourcen kan nås.", + "createInternalResourceDialogCancel": "Annuller", + "createInternalResourceDialogCreateResource": "Opret ressource", + "createInternalResourceDialogSuccess": "Succes", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressource oprettet med succes", + "createInternalResourceDialogError": "Fejl", + "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke oprette intern ressource", + "createInternalResourceDialogNameRequired": "Navn er påkrævet", + "createInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn", + "createInternalResourceDialogPleaseSelectSite": "Vælg venligst et site", + "createInternalResourceDialogProxyPortMin": "Proxy-port skal være mindst 1", + "createInternalResourceDialogProxyPortMax": "Proxy-port skal være mindre end 65536", + "createInternalResourceDialogInvalidIPAddressFormat": "Ugyldig IP-adresseformat", + "createInternalResourceDialogDestinationPortMin": "Destinasjonsport skal være mindst 1", + "createInternalResourceDialogDestinationPortMax": "Destinasjonsport skal være mindre end 65536", + "createInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", + "createInternalResourceDialogMode": "Tilstand", + "createInternalResourceDialogModePort": "Port", + "createInternalResourceDialogModeHost": "Host", + "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogModeHttp": "HTTP", + "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", + "scheme": "Skema", + "createInternalResourceDialogScheme": "Skema", + "createInternalResourceDialogEnableSsl": "Aktivér TLS", + "createInternalResourceDialogEnableSslDescription": "Aktivér SSL/TLS-kryptering for sikre HTTPS-forbindelser til destinationen.", + "createInternalResourceDialogDestination": "Destination", + "createInternalResourceDialogDestinationHostDescription": "IP-adressen eller værtsnavnet til ressourcen på sitets netværk.", + "createInternalResourceDialogDestinationCidrDescription": "CIDR-området til ressourcen på sitets netværk.", + "createInternalResourceDialogAlias": "Alias", + "createInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", + "internalResourceDownstreamSchemeRequired": "Skema er påkrævet for HTTP-ressourcer", + "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressourcer", + "siteConfiguration": "Konfiguration", + "siteAcceptClientConnections": "Acceptere klientforbindelser", + "siteAcceptClientConnectionsDescription": "Tillad brugere og klienter at få adgang til ressourcer på denne side. Dette kan endres senere.", + "siteAddress": "Siteadresse (avanceret)", + "siteAddressDescription": "Den interne adresse til sitet. Skal falde inden for organisationens subnet.", + "siteNameDescription": "Visningsnavnet på sitet, som kan ændres senere.", + "autoLoginExternalIdp": "Automatisk login med ekstern IdP", + "autoLoginExternalIdpDescription": "Omdiriger brugeren umiddelbart til den eksterne identitetsudbyderen for autentificering.", + "selectIdp": "Vælg IdP", + "selectIdpPlaceholder": "Vælg en IdP...", + "selectIdpRequired": "Vælg venligst en IdP når automatisk login er aktiveret.", + "autoLoginTitle": "Omdirigering", + "autoLoginDescription": "Omdirigerer dig til den eksterne identitetsudbyderen for autentificering.", + "autoLoginProcessing": "Forbereder autentificering...", + "autoLoginRedirecting": "Omdirigerer til login...", + "autoLoginError": "Fejl ved automatisk login", + "autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL modtaget fra identitetsudbyderen.", + "autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.", + "remoteExitNodeManageRemoteExitNodes": "Eksterne noder", + "remoteExitNodeDescription": "Selvhost din egen eksterne relay- og proxyservernode", + "remoteExitNodes": "Noder", + "searchRemoteExitNodes": "Søg noder...", + "remoteExitNodeAdd": "Tilføj Node", + "remoteExitNodeErrorDelete": "Fejl ved sletning af node", + "remoteExitNodeQuestionRemove": "Er du sikker på at du vil fjerne noden fra organisationen?", + "remoteExitNodeMessageRemove": "Når noden er fjernet, vil ikke længere være tilgængeligt.", + "remoteExitNodeConfirmDelete": "Bekræft sletning af Node", + "remoteExitNodeDelete": "Slet Node", + "sidebarRemoteExitNodes": "Eksterne noder", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Sikkerhedsnøgle", + "remoteExitNodeCreate": { + "title": "Opret ekstern node", + "description": "Opret en ny selvhostet ekstern relay- og proxyservernode", + "viewAllButton": "Vis alle noder", + "strategy": { + "title": "Oprettelsesstrategi", + "description": "Vælg, hvordan du vil oprette den eksterne node", + "adopt": { + "title": "Adopter node", + "description": "Vælg dette, hvis du allerede har legitimationsoplysninger til noden." + }, + "generate": { + "title": "Generér nøgler", + "description": "Vælg dette, hvis du vil generere nye nøgler til noden." + } + }, + "adopt": { + "title": "Adopter eksisterende node", + "description": "Indtast oplysningerne for den eksisterende node, du vil adoptere", + "nodeIdLabel": "Node-ID", + "nodeIdDescription": "ID’et til den eksisterende node, du vil adoptere", + "secretLabel": "Sikkerhedsnøgle", + "secretDescription": "Den hemmelige nøgle til en eksisterende node", + "submitButton": "Adopter node" + }, + "generate": { + "title": "Genererede legitimationsoplysninger", + "description": "Brug disse genererede oplysninger til at konfigurere noden", + "nodeIdTitle": "Node-ID", + "secretTitle": "Sikkerhed", + "saveCredentialsTitle": "Tilføj legitimationsoplysninger til konfigurationen", + "saveCredentialsDescription": "Tilføj disse legitimationsoplysninger i din selvhostede Pangolin-nodekonfigurationsfil for at fuldføre forbindelsen.", + "submitButton": "Opret node" + }, + "validation": { + "adoptRequired": "Node-ID og hemmelighed er påkrævet, når du adopterer en eksisterende node" + }, + "errors": { + "loadDefaultsFailed": "Fejl ved indlæsning af standardværdier", + "defaultsNotLoaded": "Standardværdier er ikke indlæst", + "createFailed": "Kan ikke oprette node" + }, + "success": { + "created": "Node oprettet" + } + }, + "remoteExitNodeSelection": "Nodevalg", + "remoteExitNodeSelectionDescription": "Vælg en node til at sende trafik gennem for dette lokale site", + "remoteExitNodeRequired": "En node skal vælges for lokale sites", + "noRemoteExitNodesAvailable": "Ingen noder tilgængelige", + "noRemoteExitNodesAvailableDescription": "Ingen noder er tilgængelige for denne organisation. Opret en node først for at bruge lokale sites.", + "exitNode": "Exitnode", + "country": "Land", + "rulesMatchCountry": "I øjeblikket baseret på kilde-IP", + "region": "Fylke", + "selectRegion": "Vælg region", + "searchRegions": "Søg efter sites...", + "noRegionFound": "Ingen region fundet.", + "rulesMatchRegion": "Vælg en regional gruppering af land", + "rulesErrorInvalidRegion": "Ugyldig site", + "rulesErrorInvalidRegionDescription": "Vælg venligst et gyldig site.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "[country name] Nord-Afrika", + "regionEasternAfrica": "Øst-Afrika", + "regionMiddleAfrica": "Middle Africa", + "regionSouthernAfrica": "Sør-Afrika", + "regionWesternAfrica": "[country name] Vest-Afrika", + "regionAmericas": "Amerika", + "regionCaribbean": "Karibia", + "regionCentralAmerica": "Sentral-Amerika", + "regionSouthAmerica": "Sør-Amerika", + "regionNorthernAmerica": "Nord-Amerika", + "regionAsia": "Asia", + "regionCentralAsia": "Sentral-Asia", + "regionEasternAsia": "Øst-Asia", + "regionSouthEasternAsia": "Sørøst-Asia", + "regionSouthernAsia": "Sørlige Asia", + "regionWesternAsia": "Vest-Asia", + "regionEurope": "Europa", + "regionEasternEurope": "Øst-Europa", + "regionNorthernEurope": "Nord-Europa", + "regionSouthernEurope": "Sydeuropa", + "regionWesternEurope": "Vest-Europa", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia og New Zealand", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", + "managedSelfHosted": { + "title": "Administreret selvhostet", + "description": "Sikre, selvhostede Pangolin-servere med lav vedligeholdelse og ekstra bells and whistles", + "introTitle": "Administreret self-hosted Pangolin", + "introDescription": "er en mulighed udviklet til personer, der ønsker enkelhed og ekstra pålidelighed, mens de stadig holder deres data private og selvhostede.", + "introDetail": "Med dette valg kører du stadig din egen Pangolin-node — tunneller, TLS-terminering og trafik ligger på din server. Forskellen er, at administration og overvågning håndteres via vores cloud-dashboard, som låser op for en række fordele:", + "benefitSimplerOperations": { + "title": "Nemmere operationer", + "description": "Ingen grund til at køre din egen e-mailserver eller opsætte kompleks varsling. Du får sundhedstjek og nedetidsvarsler out of the box." + }, + "benefitAutomaticUpdates": { + "title": "Automatiske opdateringer", + "description": "Cloud-dashboardet udvikler sig hurtigt, så du får nye funktioner og fejlrettelser uden at skulle hente nye containere manuelt hver gang." + }, + "benefitLessMaintenance": { + "title": "Mindre vedligeholdelse", + "description": "Ingen databasestyring, sikkerhedskopier eller ekstra infrastruktur for at administrer. Vi håndterer det i skyen." + }, + "benefitCloudFailover": { + "title": "Cloud-failover", + "description": "Hvis EK-gruppen din går ned, kan tunnellerne midlertidig mislykkes i at nu våre cloud-endpoints til du tager den tilbage online." + }, + "benefitHighAvailability": { + "title": "Høj tilgængelighed (PoPs)", + "description": "Du kan også tilføje flere noder til din konto for redundans og bedre ydeevne." + }, + "benefitFutureEnhancements": { + "title": "Fremtidige forbedringer", + "description": "Vi planlægger at tilføje flere analyser, varsler og administrationsværktøjer for at gøre din installation endnu mere robust." + }, + "docsAlert": { + "text": "Læs mere om Managed Self-Hosted-muligheden i vores", + "documentation": "dokumentation" + }, + "convertButton": "Konverter denne node til manuel drift" + }, + "internationaldomaindetected": "Internasjonalt domæne registreret", + "willbestoredas": "Vil blive gemt som:", + "roleMappingDescription": "Bestem hvordan roller tildeles brugere når de logs ind med denne identitetsudbyder.", + "selectRole": "Vælg en rolle", + "roleMappingExpression": "Udtryk", + "selectRolePlaceholder": "Vælg en rolle", + "selectRoleDescription": "Vælg en rolle at tildele alle brugere fra denne identitetsudbyder", + "roleMappingExpressionDescription": "Indtast et JMESPath udtryk for at hente rolleinformation fra ID-nøglen", + "idpTenantIdRequired": "Bedriftens ID kræves", + "invalidValue": "Ugyldig værdi", + "idpTypeLabel": "Identitet udbyder type", + "roleMappingExpressionPlaceholder": "F.eks. indeholder(grupper, 'admin') && 'Admin' ⋅'Medlem'", + "roleMappingModeFixedRoles": "Fast roller", + "roleMappingModeMappingBuilder": "Kartlegger bygger", + "roleMappingModeRawExpression": "Rå udtryk", + "roleMappingFixedRolesPlaceholderSelect": "Vælg en eller flere roller", + "roleMappingFixedRolesPlaceholderFreeform": "Indtast rollenavn (eksakt treff per organisation)", + "roleMappingFixedRolesDescriptionSameForAll": "Tildele den samme rollen som er sat til hver automatisk midlertidig bruger.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "For standardpolitikker, indtast rollenavne som findes i hver organisation der brugerne tilbydes. Navn skal stemme præcis.", + "roleMappingClaimPath": "Kræv sti", + "roleMappingClaimPathPlaceholder": "grupper", + "roleMappingClaimPathDescription": "Sti i i token payload som indeholder kildeverdier (for eksempel grupper).", + "roleMappingMatchValue": "Treff værdi", + "roleMappingAssignRoles": "Tildele roller", + "roleMappingAddMappingRule": "Tilføj tilordningsregel", + "roleMappingRawExpressionResultDescription": "Udtryk skal vurderes til en streng eller en tekststreng.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Udtryk skal evaluere til en streng (en rollenavn).", + "roleMappingMatchValuePlaceholder": "Match værdi (for eksempel: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Angiv rollenavn (eksakt per org)", + "roleMappingBuilderFreeformRowHint": "Rollenavn skal samsvare med en rolle i hver målorganisation.", + "roleMappingRemoveRule": "Fjern", + "idpGoogleConfiguration": "Google Konfiguration", + "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimationsoplysningerne", + "idpGoogleClientIdDescription": "Google OAuth2 Client ID", + "idpGoogleClientSecretDescription": "Google OAuth2-klienten hemmelighed", + "idpAzureConfiguration": "Azure Entra ID konfiguration", + "idpAzureConfigurationDescription": "Konfigurer Azure Entra ID OAuth2 legitimationsoplysninger", + "idpTenantId": "Leietaker-ID", + "idpTenantIdPlaceholder": "tenant-id", + "idpAzureTenantIdDescription": "Azure leant ID (fundet i Azure Active Directory-oversikten)", + "idpAzureClientIdDescription": "Azure App registrerings klient-ID", + "idpAzureClientSecretDescription": "Azure App Registrering Klient Hemmelig", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google Konfiguration", + "idpAzureConfigurationTitle": "Azure Entra ID konfiguration", + "idpTenantIdLabel": "Leietaker-ID", + "idpAzureClientIdDescription2": "Azure App registrerings klient-ID", + "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", + "idpGoogleDescription": "Google OAuth2/OIDC udbyder", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "subnet": "Subnet", + "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", + "customDomain": "Brugerdefineret domæne", + "authPage": "Autentiseringssider", + "authPageDescription": "Sett et brugerdefineret domæne for organisationens autentiseringssider", + "authPageDomain": "Autentiseringsside domæne", + "authPageBranding": "Brugerdefineret merkevarebygging", + "authPageBrandingDescription": "Konfigurer merkevarebyggingen der vises på autentiseringssidene for denne organisation", + "authPageBrandingUpdated": "Autentiseringsside-markedsføring opdateret med succes", + "authPageBrandingRemoved": "Autentiseringsside-markedsføring fjernet med succes", + "authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside", + "authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?", + "authPageBrandingDeleteConfirm": "Bekræft sletning af merkevarebygging", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logoen URL eller sti", + "brandingLogoPathDescription": "Indtast en URL eller en lokal sti.", + "brandingLogoURLDescription": "Indtast en offentligt tilgængelig webadresse til din logobillede.", + "brandingPrimaryColor": "Primærfarge", + "brandingLogoWidth": "Bredde (px)", + "brandingLogoHeight": "Høyde (px)", + "brandingOrgTitle": "Titel for organisationens autentiseringsside", + "brandingOrgDescription": "{orgName} vil blive erstattet med organisationens navn", + "brandingOrgSubtitle": "Undertittel for organisationens autentiseringsside", + "brandingResourceTitle": "Titel for ressourcens autentiseringsside", + "brandingResourceSubtitle": "Undertittel for ressourcens autentiseringsside", + "brandingResourceDescription": "{resourceName} vil blive erstattet med organisationens navn", + "saveAuthPageDomain": "Gem domæne", + "saveAuthPageBranding": "Gem merkevarebygging", + "removeAuthPageBranding": "Fjern merkevarebygging", + "noDomainSet": "Ingen domæne valgt", + "changeDomain": "Ændre domæne", + "selectDomain": "Vælg domæne", + "restartCertificate": "Genstart certifikat", + "editAuthPageDomain": "Rediger auth-sidens domæne", + "setAuthPageDomain": "Angiv autoriseringsside domæne", + "failedToFetchCertificate": "Kunne ikke hente certifikat", + "failedToRestartCertificate": "Kan ikke starte certifikat", + "addDomainToEnableCustomAuthPages": "Brugere vil kunne få adgang til organisationens loginside og fuldføre ressourceautentificering ved at bruge dette domænet.", + "selectDomainForOrgAuthPage": "Vælg et domæne for organisationens autentiseringsside", + "domainPickerProvidedDomain": "Givet domæne", + "domainPickerFreeProvidedDomain": "Gratis angivet domæne", + "domainPickerFreeDomainsPaidFeature": "Angitte domæner er en betalingsfunktion. Abonner for at få et domæne inklusive i din plan – ingen behov for at tage med dit eget.", + "domainPickerVerified": "Bekræftet", + "domainPickerUnverified": "Uverifisert", + "domainPickerManual": "Manuel", + "domainPickerInvalidSubdomainStructure": "Ugyldige tegn bliver saniteret, når de gemmes.", + "domainPickerError": "Fejl", + "domainPickerErrorLoadDomains": "Kan ikke indlæse organisationens domæner", + "domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domænetilgængelighed", + "domainPickerInvalidSubdomain": "Ugyldig underdomæne", + "domainPickerInvalidSubdomainRemoved": "Inndata \"{sub}\" blev fjernet fordi det ikke er gyldig.", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gøres gyldigt for {domain}.", + "domainPickerSubdomainSanitized": "Underdomænet som blev sanivert", + "domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"", + "orgAuthSignInTitle": "Organisationslogin", + "orgAuthChooseIdpDescription": "Vælg din identitet udbyder for at fortsætte", + "orgAuthNoIdpConfigured": "Denne organisation har ikke nogen identitetstjeneste konfigureret. Du kan i stedet logge ind med din Pangolin-identitet.", + "orgAuthSignInWithPangolin": "Log ind med Pangolin", + "orgAuthSignInToOrg": "Organisationens identitetsudbyder (SSO)", + "orgAuthSelectOrgTitle": "Organisationslogin", + "orgAuthSelectOrgDescription": "Indtast organisations-ID-en din for at fortsætte", + "orgAuthOrgIdPlaceholder": "din-organisation", + "orgAuthOrgIdHelp": "Indtast organisationens unikke identifikator", + "orgAuthSelectOrgHelp": "Efter at have skrevet ind din organisations-ID, bliver du videresendt til din organisations loginside hvor du kan bruge SSO eller organisationens legitimationsoplysninger.", + "orgAuthRememberOrgId": "Husk denne organisations-ID-en", + "orgAuthBackToSignIn": "Tilbage til standard login", + "orgAuthNoAccount": "Har du ikke en konto?", + "subscriptionRequiredToUse": "Et abonnement er påkrævet for at bruge denne funktion.", + "mustUpgradeToUse": "Du skal opgradere dit abonnement for at bruge denne funktion.", + "subscriptionRequiredTierToUse": "Denne funktion kræver {tier} eller højere.", + "upgradeToTierToUse": "Opgrader til {tier} eller højere for at bruge denne funktion.", + "subscriptionTierTier1": "Hjem", + "subscriptionTierTier2": "Lag", + "subscriptionTierTier3": "Forretninger", + "subscriptionTierEnterprise": "Bedrift", + "idpDisabled": "Identitetsudbydere er deaktiveret.", + "orgAuthPageDisabled": "Organisationens informationsside er deaktiveret.", + "domainRestartedDescription": "Domæneverificeringen blev genstartet", + "resourceAddEntrypointsEditFile": "Rediger fil: config/Traefik/Traefik_config.yml", + "resourceExposePortsEditFile": "Rediger fil: docker-compose.yml", + "emailVerificationRequired": "E-mailbekræftelse er nødvendig. Log ind igen via {dashboardUrl}/auth/login og fuldfør dette trinnet. Kom derefter tilbage her.", + "twoFactorSetupRequired": "Opsætning af tofaktorgodkendelse er påkrævet. Log venligst ind igen via {dashboardUrl}/auth/login og fuldfør dette trin. Kom derefter tilbage hertil.", + "additionalSecurityRequired": "Ekstra sikkerhed kræves", + "organizationRequiresAdditionalSteps": "Denne organisation kræver yderligere sikkerhedstrin, før du får adgang til ressourcer.", + "completeTheseSteps": "Fuldfør disse trinene", + "enableTwoFactorAuthentication": "Aktivér to-faktor autentificering", + "completeSecuritySteps": "Fuldfør sikkerhedstrinene", + "securitySettings": "Sikkerhed indstillinger", + "dangerSection": "Farezone", + "dangerSectionDescription": "Slet permanent alle data tilknyttet denne organisation", + "securitySettingsDescription": "Konfigurer sikkerhedspolitikker for organisationen", + "requireTwoFactorForAllUsers": "Kræv to-faktor autentificering for alle brugere", + "requireTwoFactorDescription": "Når aktiveret skal alle interne brugere i denne organisation have to-faktorautentisering aktiveret for at få adgang til organisationen.", + "requireTwoFactorDisabledDescription": "Denne funktion kræver en gyldig licens (Enterprise) eller aktivt abonnement (SaaS)", + "requireTwoFactorCannotEnableDescription": "Du skal aktivere to-faktor-autentificering for din konto før det håndhæves for alle brugere", + "maxSessionLength": "Maksimal øktlengde", + "maxSessionLengthDescription": "Angiv maksimal varighed for brugersessioner. Efter denne periode skal brugerne logge ind igen.", + "maxSessionLengthDisabledDescription": "Denne funktion kræver en gyldig licens (Enterprise) eller aktivt abonnement (SaaS)", + "selectSessionLength": "Vælg øktlengde", + "unenforced": "Tvungen", + "1Hour": "1 time", + "3Hours": "3 timer", + "6Hours": "6 timer", + "12Hours": "12 timer", + "1DaySession": "1 dag", + "3Days": "3 dage", + "7Days": "7 dage", + "14Days": "14 dage", + "30DaysSession": "30 dage", + "90DaysSession": "90 dage", + "180DaysSession": "180 dage", + "passwordExpiryDays": "Adgangskode udløber", + "editPasswordExpiryDescription": "Angiv antal dage før brugere skal ændre adgangskoden sitt.", + "selectPasswordExpiry": "Vælg adgangskodeudløb", + "30Days": "30 dage", + "1Day": "1 dag", + "60Days": "60 dage", + "90Days": "90 dage", + "180Days": "180 dage", + "1Year": "1 år", + "subscriptionBadge": "Abonnement kræves", + "securityPolicyChangeWarning": "Advarsel om ændring af sikkerhedsregler", + "securityPolicyChangeDescription": "Du er ved at ændre indstillingerne for sikkerhedspolitik. Når du har gemt, skal du muligvis logge ind igen for at opfylde disse politikopdateringer. Alle brugere, der ikke matcher, skal også autentificere sig igen.", + "securityPolicyChangeConfirmMessage": "Jeg bekræfter", + "securityPolicyChangeWarningText": "Dette vil påvirke alle brugere i organisationen", + "authPageErrorUpdateMessage": "Det opstod en fejl under opdatering af indstillingerne for godkendelsessiden", + "authPageErrorUpdate": "Kunne ikke opdatere autoriseringssiden", + "authPageDomainUpdated": "Autentiseringsside-domænet blev opdateret med succes", + "healthCheckNotAvailable": "Lokal", + "rewritePath": "Omskriv sti", + "rewritePathDescription": "Valgfrit omskrive stien før videresendelse til målet.", + "continueToApplication": "Fortsæt til applikationen", + "checkingInvite": "Kontrollerer invitation", + "setResourceHeaderAuth": "setResourceHeaderAuth", + "resourceHeaderAuthRemove": "Fjern header-auth", + "resourceHeaderAuthRemoveDescription": "Header autentificering fjernet.", + "resourceErrorHeaderAuthRemove": "Kunne ikke fjerne header-autentificering", + "resourceErrorHeaderAuthRemoveDescription": "Kunne ikke fjerne header-autentificering for ressourcen.", + "resourceHeaderAuthProtectionEnabled": "Header autentificering aktiveret", + "resourceHeaderAuthProtectionDisabled": "Header autentificering deaktiveret", + "headerAuthRemove": "Fjern header-auth", + "headerAuthAdd": "Tilføj header-godkendelse", + "resourceErrorHeaderAuthSetup": "Kunne ikke angive header-autentificering", + "resourceErrorHeaderAuthSetupDescription": "Kunne ikke sette topplinje autentificering for ressourcen.", + "resourceHeaderAuthSetup": "Header godkendelsessæt var med succes", + "resourceHeaderAuthSetupDescription": "Header-autentificering er blevet gemt.", + "resourceHeaderAuthSetupTitle": "Angiv header-godkendelse", + "resourceHeaderAuthSetupTitleDescription": "Angiv grunnleggende auth legitimationsoplysninger (brugernavn og adgangskode) for at beskytte denne ressource med HTTP Header autentificering. Adgang til det ved hjælp af formatet HTTPS://username:password@resource.example.com", + "resourceHeaderAuthSubmit": "Angiv header-godkendelse", + "actionSetResourceHeaderAuth": "Angiv header-godkendelse", + "enterpriseEdition": "Enterprise Edition", + "unlicensed": "Ikke licenseret", + "beta": "beta", + "manageUserDevices": "Bruger Enheder", + "manageUserDevicesDescription": "Se og administrer enheder som brugere bruger for privat forbindelse til ressourcer", + "downloadClientBannerTitle": "Download Pangolin-klienten", + "downloadClientBannerDescription": "Download Pangolin-klienten til dit system for at oprette forbindelse til Pangolin-netværket og få privat adgang til ressourcer.", + "manageMachineClients": "Administrer maskinneklienter", + "manageMachineClientsDescription": "Opret og administrer klienter som servere og systemer bruger for privat forbindelse til ressourcer", + "machineClientsBannerTitle": "Servere og automatiserte systemer", + "machineClientsBannerDescription": "Maskinklienter er for servere og automatiserte systemer der ikke er tilknyttet en specifik bruger. De autentiserer med en ID og et hemmelighetsnummer, og kan køre med Pangolin CLI, Olm CLI eller Olm som en container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Container", + "clientsTableUserClients": "Bruger", + "clientsTableMachineClients": "Maskin", + "licenseTableValidUntil": "Gyldig til", + "saasLicenseKeysSettingsTitle": "Bedriftstillatelse Licenser", + "saasLicenseKeysSettingsDescription": "Generer og administrer Enterprise licensnøgler for selvbetjente Pangolin forekomster", + "sidebarEnterpriseLicenses": "Licenser", + "generateLicenseKey": "Generér licensnøgle", + "generateLicenseKeyForm": { + "validation": { + "emailRequired": "Indtast venligst en gyldig e-mailadresse", + "useCaseTypeRequired": "Vælg venligst en brugstype", + "firstNameRequired": "Fornavn er påkrævet", + "lastNameRequired": "Efternavn er påkrævet", + "primaryUseRequired": "Beskriv din primære brug", + "jobTitleRequiredBusiness": "Jobtitel er påkrævet til erhvervsbrug", + "industryRequiredBusiness": "Branche skal til forretningsbruk", + "stateProvinceRegionRequired": "Stat/provins/region er påkrævet", + "postalZipCodeRequired": "Postnummer er påkrævet", + "companyNameRequiredBusiness": "Firmanavn er påkrævet til erhvervsbrug", + "countryOfResidenceRequiredBusiness": "Bopælsland er påkrævet til erhvervsbrug", + "countryRequiredPersonal": "Land er påkrævet for personlig brug", + "agreeToTermsRequired": "Du skal acceptere vilkårene", + "complianceConfirmationRequired": "Du skal bekræfte at du overholder Fossorial Kommerciel licens" + }, + "useCaseOptions": { + "personal": { + "title": "Personlig brug", + "description": "For enkeltpersoner, ikke-kommerciel brug som læring, personlige prosjekter eller eksperimentering." + }, + "business": { + "title": "Erhvervsbrug", + "description": "Til brug inden for organisationer eller virksomheter eller forretningsmessige indtægter eller aktiviteter." + } + }, + "steps": { + "emailLicenseType": { + "title": "E-mail og licenstype", + "description": "Indtast din e-mailadresse og vælg licenstypen din" + }, + "personalInformation": { + "title": "Personlige oplysninger", + "description": "Fortell oss om dig selv" + }, + "contactInformation": { + "title": "Kontaktoplysninger", + "description": "Dine kontaktopplysninger" + }, + "termsGenerate": { + "title": "Vilkår og generering", + "description": "Se gennem og acceptere vilkårene for at generere licensen" + } + }, + "alerts": { + "commercialUseDisclosure": { + "title": "Brug utlevering", + "description": "Vælg licensniveauet som reflekterer præcis din tiltænkte brug. Personlige licenser tillader fri brug af software for enkelte, ikke-kommercielle eller småskala kommercielle aktiviteter, med en årlig brutto indtægt på under 100 000 amerikanske dollar. All brug ud over disse grænserne – inklusive brug inden for en virksomhed, organisation eller andre indtægter miljø - kræver en gyldig Enterprise licens og betaling af nuværende licensafgift. Alle brugere, enten personlig eller Enterprise, skal overholde de kommercielle tilladelserne på Fossorial." + }, + "trialPeriodInformation": { + "title": "Information om prøveperiode", + "description": "Denne licensnøglen tillader funktioner i Enterprise for en 7-dagers evalueringsperiode. Stadig adgang til betalt funktioner uden for evalueringsperioden kræver aktivering under en gyldig Personlig eller Enterprise License. For Enterprise licensing, kontakt sales@pangolin.net." + } + }, + "form": { + "useCaseQuestion": "Bruger du Pangolin for personlig eller forretningsbruk?", + "firstName": "Fornavn", + "lastName": "Efternavn", + "jobTitle": "Jobtitel", + "primaryUseQuestion": "Hvad planlegger du først og fremst at bruge Pangolin for?", + "industryQuestion": "Hvilken branche er du i?", + "prospectiveUsersQuestion": "Hvor mange prospektive brugere forventer du at have?", + "prospectiveSitesQuestion": "Hvor mange prospektive sites (tunnels) forventer du at have?", + "companyName": "Virksomhedsnavn", + "countryOfResidence": "Bopælsland", + "stateProvinceRegion": "Stat/provins/region", + "postalZipCode": "Postnummer", + "companyWebsite": "Virksomhedens hjemmeside", + "companyPhoneNumber": "Virksomhedens telefonnummer", + "country": "Land", + "phoneNumberOptional": "Telefonnummer (valgfrit)", + "complianceConfirmation": "Jeg bekræfter, at de oplysninger, jeg har angivet, er korrekte, og at jeg overholder Fossorials kommercielle licens. Indberetning af unøjagtige oplysninger eller forkert angivelse af produktbrug bryder licensen og kan føre til, at din nøgle tilbagekaldes." + }, + "buttons": { + "close": "Luk", + "previous": "Forrige", + "next": "Næste", + "generateLicenseKey": "Generér licensnøgle" + }, + "toasts": { + "success": { + "title": "Licensnøgle blev genereret", + "description": "Din licensnøgle er blevet genereret og er klar til brug." + }, + "error": { + "title": "Kan ikke generere licensnøgle", + "description": "Det opstod en fejl ved generering af licensnøglen." + } + } + }, + "newPricingLicenseForm": { + "title": "Få en licens", + "description": "Vælg en plan, og fortæl os, hvordan du planlægger at bruge Pangolin.", + "chooseTier": "Vælg din funktionsplan", + "viewPricingLink": "Se priser, funktioner og grænser", + "tiers": { + "starter": { + "title": "Starter", + "description": "Enterprise features, 25 brugere, 25 sites og støtte fra fællesskabet." + }, + "scale": { + "title": "Skala", + "description": "Enterprise-funktioner, 50 brugere, 100 sites og prioriteret support." + } + }, + "personalUseOnly": "Kun personlig brug (gratis licens - ingen checkout)", + "buttons": { + "continueToCheckout": "Fortsæt til checkout" + }, + "toasts": { + "checkoutError": { + "title": "Fejl ved checkout", + "description": "Kan ikke starte checkout. Prøv igen." + } + } + }, + "priority": "Prioritet", + "priorityDescription": "Ruter med højere prioritet evalueres først. Prioritet = 100 betyder automatisk rækkefølge (systembeslutninger). Brug et andet tal for at gennemtvinge manuel prioritet.", + "instanceName": "Forekomst navn", + "pathMatchModalTitle": "Konfigurere matching af sti", + "pathMatchModalDescription": "Opsæt hvordan indgående forespørgsler skal matches baseret på deres sti.", + "pathMatchType": "Matchtype", + "pathMatchPrefix": "Prefiks", + "pathMatchExact": "Præcis", + "pathMatchRegex": "Regex", + "pathMatchValue": "Værdi for sti", + "clear": "Tøm", + "saveChanges": "Gem ændringer", + "pathMatchRegexPlaceholder": "^/api/.*", + "pathMatchDefaultPlaceholder": "/sti", + "pathMatchPrefixHelp": "Eksempel: /api matcher /api, /api/users, etc.", + "pathMatchExactHelp": "Eksempel: /api passer kun /api", + "pathMatchRegexHelp": "Eksempel: ^/api/.* matcher /api/alt", + "pathRewriteModalTitle": "Konfigurer ferdigskriving af sti", + "pathRewriteModalDescription": "Overfør den matchende sti, før den videresendes til målet.", + "pathRewriteType": "Omskriv type", + "pathRewritePrefixOption": "Prefiks - erstatt prefiks", + "pathRewriteExactOption": "Eksakt - Erstatt hele banen", + "pathRewriteRegexOption": "Regex - Ekkobilde", + "pathRewriteStripPrefixOption": "Tage bort prefiks - fjern prefiks", + "pathRewriteValue": "Omskriv værdi", + "pathRewriteRegexPlaceholder": "/ny/$1", + "pathRewriteDefaultPlaceholder": "/ny sti", + "pathRewritePrefixHelp": "Erstatt det matchende prefikset med denne værdien", + "pathRewriteExactHelp": "Erstatt hele banen med denne værdien når stien matcher præcis", + "pathRewriteRegexHelp": "Brug capture groups som $1, $2 til erstatning", + "pathRewriteStripPrefixHelp": "Lad feltet være tomt for at fjerne præfikset eller tilføje et nyt præfiks", + "pathRewritePrefix": "Prefiks", + "pathRewriteExact": "Præcis", + "pathRewriteRegex": "Regex", + "pathRewriteStrip": "Stripe", + "pathRewriteStripLabel": "stripe", + "sidebarEnableEnterpriseLicense": "Aktivér Enterprise licens", + "cannotbeUndone": "Dette kan ikke fortrydes.", + "toConfirm": "at bekræfte.", + "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra sitet og organisationen?", + "clientMessageRemove": "Når klienten er fjernet, kan den ikke længere oprette forbindelse til sitet.", + "sidebarLogs": "Logs", + "request": "Forespørgsel", + "requests": "Forespørgsler", + "logs": "Logs", + "logsSettingsDescription": "Overvåg logs samlet ind fra denne organisation", + "searchLogs": "Søg i logs...", + "action": "Handling", + "actor": "Aktør", + "timestamp": "Tidsstempel", + "accessLogs": "Adgangslogs", + "exportCsv": "Eksportere CSV", + "exportError": "Ukjent fejl ved eksport af CSV", + "exportCsvTooltip": "Inden for tidsramme", + "actorId": "Skuespiller ID", + "allowedByRule": "Tilladt efter regel", + "allowedNoAuth": "Tilladt Ingen Auth", + "validAccessToken": "Gyldig adgangsnøgle", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid PIN-kode", + "validPassword": "Gyldig adgangskode", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "view": "Vis", + "configManaged": "Konfiguration administrert", + "connectedClient": "Tilsluttet klient", + "resourceBlocked": "Ressource blokeret", + "droppedByRule": "Legg i reglen", + "noSessions": "Ingen økter", + "temporaryRequestToken": "Midlertidig forespørgsel Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Grund", + "requestLogs": "HTTP-forespørgselslogs", + "requestAnalytics": "Be om analyser", + "host": "Host", + "location": "Sted", + "actionLogs": "Handlingsloger", + "sidebarLogsRequest": "HTTP-forespørgselslogs", + "sidebarLogsAccess": "Adgangslogs", + "sidebarLogsAction": "Handlingsloger", + "logRetention": "Logopbevaring", + "logRetentionDescription": "Håndter hvor længe ulike typer logs beholdes for denne organisation, eller deaktivér dem", + "requestLogsDescription": "Se detaljerede forespørgselslogs for ressourcer i denne organisation", + "requestAnalyticsDescription": "Se detaljeret anmodningsanalyse for ressourcer i denne organisation", + "logRetentionRequestLabel": "Be om logbevaring", + "logRetentionRequestDescription": "Hvor længe du vil beholde forespørgselslogs", + "logRetentionAccessLabel": "Få adgang til logoverføring", + "logRetentionAccessDescription": "Hvor længe du vil beholde adgangsloger", + "logRetentionActionLabel": "Handlings log nytt", + "logRetentionActionDescription": "Hvor længe handlingen skal gemmes", + "logRetentionConnectionLabel": "Forbindelseslog", + "logRetentionConnectionDescription": "Hvor længe du vil beholde forbindelseslogs", + "logRetentionDisabled": "Deaktiveret", + "logRetention3Days": "3 dage", + "logRetention7Days": "7 dage", + "logRetention14Days": "14 dage", + "logRetention30Days": "30 dage", + "logRetention90Days": "90 dage", + "logRetentionForever": "Altid", + "logRetentionEndOfFollowingYear": "Slutt på næste år", + "actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisation", + "accessLogsDescription": "Vis autorisationsforespørgsler for ressourcer i denne organisation", + "connectionLogs": "Forbindelseslogs", + "connectionLogsDescription": "Vis forbindelseslogs for tunneler i denne organisation", + "sidebarLogsConnection": "Forbindelseslogs", + "sidebarLogsStreaming": "Streaming", + "sourceAddress": "Kildeadresse", + "destinationAddress": "Måladresse ", + "duration": "Varighed", + "licenseRequiredToUse": "En Enterprise Edition-licens eller Pangolin Cloud er påkrævet for at bruge denne funktion. Book en demo eller POC-prøveversion.", + "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for at bruge denne funktion. Denne funktion er også tilgængelig i Pangolin Cloud. Book en demo eller POC-prøve.", + "certResolver": "Certifikatløser", + "certResolverDescription": "Vælg certifikatløser som skal bruges for denne ressource.", + "selectCertResolver": "Vælg certifikatløser", + "enterCustomResolver": "Indtast brugerdefineret resolver", + "preferWildcardCert": "Foretrekk Wildcard certifikat", + "unverified": "Uverifisert", + "domainSetting": "Domæne indstillinger", + "domainSettingDescription": "Konfigurer indstillinger for domænet", + "preferWildcardCertDescription": "Forsøg at generere et wildcard-certifikat (kræver en korrekt konfigureret certifikatløser).", + "recordName": "Gem navn", + "auto": "Automatisk", + "TTL": "TTL", + "howToAddRecords": "Hvordan tilføje poster", + "dnsRecord": "DNS registre", + "required": "Påkrævet", + "domainSettingsUpdated": "Domæne indstillinger blev opdateret", + "orgOrDomainIdMissing": "ID for organisation eller domæne mangler", + "loadingDNSRecords": "Indlæser DNS-poster...", + "olmUpdateAvailableInfo": "En opdateret version af Olm er tilgængeligt. Opdater til den nyeste version for at få den bedste oplevelse.", + "client": "Klient", + "proxyProtocol": "Protokol indstillinger for Protokol", + "proxyProtocolDescription": "Konfigurer Proxy-protokol for at bevare klientens IP-adresser til TCP-tjenester.", + "enableProxyProtocol": "Aktivér Proxy-protokol", + "proxyProtocolInfo": "Bevar klientens IP-adresser for TCP backends", + "proxyProtocolVersion": "Proxy protokol version", + "version1": "Version 1 (Anbefalet)", + "version2": "Version 2", + "version1Description": "Tekstbaseret og bredt støttet. Sørg for at servertransport er tilføjet dynamisk konfiguration.", + "version2Description": "Binært og mere effektivt, men mindre kompatibel. Sørg for at servertransport er tilføjet dynamisk konfiguration.", + "warning": "Advarsel", + "proxyProtocolWarning": "Backend-applikationen skal konfigureres til at acceptere forbindelser med Proxy Protocol. Hvis backenden ikke understøtter Proxy Protocol, vil aktivering af dette ødelægge alle forbindelser. Aktivér derfor kun dette, hvis du ved, hvad du gør. Sørg for at konfigurere backenden til at stole på Proxy Protocol-headers fra Traefik.", + "restarting": "Restarter...", + "manual": "Manuel", + "messageSupport": "Støtte for meddelelse", + "supportNotAvailableTitle": "Støtte ikke tilgængelig", + "supportNotAvailableDescription": "Støtte er ikke tilgængelig akkurat nu. Du kan sende en e-mail til support@pangolin.net.", + "supportRequestSentTitle": "Supportforespørgsel sendt", + "supportRequestSentDescription": "Din meddelelse er sendt.", + "supportRequestFailedTitle": "Kunne ikke sende forespørgsel", + "supportRequestFailedDescription": "En fejl opstod under sending af din forespørgsel om støtte.", + "supportSubjectRequired": "Emne er påkrævet", + "supportSubjectMaxLength": "Emne skal være 255 tegn eller mindre", + "supportMessageRequired": "Meddelelse er påkrævet", + "supportReplyTo": "Svar til", + "supportSubject": "Emne", + "supportSubjectPlaceholder": "Angiv emne", + "supportMessage": "Meddelelse", + "supportMessagePlaceholder": "Skriv din meddelelse", + "supportSending": "Sender...", + "supportSend": "Sende", + "supportMessageSent": "Meddelelse sendt!", + "supportWillContact": "Vi kommer raskt til at tage kontakt!", + "selectLogRetention": "Vælg logopbevaring", + "terms": "Vilkår", + "privacy": "Privatliv", + "security": "Sikkerhed", + "docs": "Dokumenter", + "deviceActivation": "Aktivering af enhed", + "deviceCodeInvalidFormat": "Kode skal inneholde 9 tegn (f.eks A1AJ-N5JD)", + "deviceCodeInvalidOrExpired": "Ugyldig eller udløbet kode", + "deviceCodeVerifyFailed": "Kunne ikke bekræfte enhedskoden", + "deviceCodeValidating": "Validerer enhedskode...", + "deviceCodeVerifying": "Bekræfter enhedens godkendelse...", + "signedInAs": "Logget ind som", + "deviceCodeEnterPrompt": "Indtast koden, der vises på enheden", + "continue": "Fortsæt", + "deviceUnknownLocation": "Ukjent plassering", + "deviceAuthorizationRequested": "Denne godkendelse blev anmodet fra {location} den {date}. Sørg for, at du stoler på denne enhed, da den får adgang til kontoen.", + "deviceLabel": "Enhed: {deviceName}", + "deviceWantsAccess": "ønsker at få adgang til din konto", + "deviceExistingAccess": "Eksisterende adgang:", + "deviceFullAccess": "Full adgang til din konto", + "deviceOrganizationsAccess": "Adgang til alle organisationer din konto har adgang til", + "deviceAuthorize": "Autoriser {applicationName}", + "deviceConnected": "Enhed tilsluttet!", + "deviceAuthorizedMessage": "Enheden er autoriseret til adgang til kontoen. Gå venligst tilbage til klientapplikationen.", + "pangolinCloud": "Pangolin Cloud", + "viewDevices": "Vis enheder", + "viewDevicesDescription": "Administrer tilsluttede enheder", + "noDevices": "Ingen enheder fundet", + "dateCreated": "Oprettet dato", + "unnamedDevice": "Navnløs enhed", + "deviceQuestionRemove": "Er du sikker på, at du vil slette denne enhed?", + "deviceMessageRemove": "Denne handling kan ikke fortrydes.", + "deviceDeleteConfirm": "Slet enhed", + "deleteDevice": "Slet enhed", + "errorLoadingDevices": "Fejl ved indlæsning af enheder", + "failedToLoadDevices": "Kunne ikke indlæse enheder", + "deviceDeleted": "Enheden er slettet", + "deviceDeletedDescription": "Enheden er blevet slettet.", + "errorDeletingDevice": "Fejl ved sletning af enhed", + "failedToDeleteDevice": "Kunne ikke slette enheden", + "showColumns": "Vis kolonner", + "hideColumns": "Skjul kolonner", + "columnVisibility": "Kolonne Synlighed", + "toggleColumn": "Veksle {columnName} kolonne", + "allColumns": "Alle kolonner", + "defaultColumns": "Standard kolonner", + "customizeView": "Tilpass visning", + "viewOptions": "Vis alternativer", + "selectAll": "Vælg alle", + "selectNone": "Vælg ingen", + "selectedResources": "Valgte ressourcer", + "enableSelected": "Aktivér valgte", + "disableSelected": "Deaktivér valgte", + "checkSelectedStatus": "Kontroller status for valgte", + "clients": "Klienter", + "accessClientSelect": "Vælg maskinklienter", + "resourceClientDescription": "Maskinklienter som har adgang til denne ressource", + "regenerate": "Regenerer", + "credentials": "Legitimationsoplysninger", + "savecredentials": "Gem brugeroplysninger", + "regenerateCredentialsButton": "Regenerer brugeroplysninger", + "regenerateCredentials": "Regenerer brugeroplysninger", + "generatedcredentials": "Genererede brugeroplysninger", + "copyandsavethesecredentials": "Kopiér og gem disse oplysningerne", + "copyandsavethesecredentialsdescription": "Disse oplysninger vil ikke blive vist igen, når du forlader siden. Gem dem sikkert nu.", + "credentialsSaved": "Loginoplysninger gemt", + "credentialsSavedDescription": "Loginoplysningerne er blevet regenereret og gemt.", + "credentialsSaveError": "Fejl ved gemning af loginoplysninger", + "credentialsSaveErrorDescription": "En fejl opstod under regenerering og lagring af legitimationsoplysninger.", + "regenerateCredentialsWarning": "Regenerering af legitimationsoplysninger vil ugyldiggøre de forrige og forårsage en frakobling. Sørg for at opdatere alle konfigurationer, der bruger disse legitimationsoplysninger.", + "confirm": "Bekræft", + "regenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere legetimationerne?", + "endpoint": "Endpoint", + "Id": "Id", + "SecretKey": "Hemmelig nøgle", + "niceId": "God ID", + "niceIdUpdated": "Flott ID opdateret", + "niceIdUpdatedSuccessfully": "Id-en blev opdateret", + "niceIdUpdateError": "Fejl under opdatering af hyggelig ID", + "niceIdUpdateErrorDescription": "Det opstod en fejl under opdatering af Nice ID.", + "niceIdCannotBeEmpty": "God ID kan ikke være tom", + "enterIdentifier": "Angiv identifikator", + "identifier": "Identifier", + "deviceLoginUseDifferentAccount": "Ikke du? Brug en anden konto.", + "deviceLoginDeviceRequestingAccessToAccount": "En enhed ber om adgang til denne kontoen.", + "loginSelectAuthenticationMethod": "Vælg en autentificeringsmetode for at fortsætte.", + "noData": "Ingen data", + "machineClients": "Maskinklienter", + "install": "Installer", + "run": "Kjør", + "envFile": "Miljøfil", + "serviceFile": "Tjenestefil", + "enableAndStart": "Aktivér og start", + "clientNameDescription": "Visningsnavnet til klienten, der kan endres senere.", + "clientAddress": "Klientadresse (avanceret)", + "setupFailedToFetchSubnet": "Kunne ikke hente standard subnet", + "setupSubnetAdvanced": "Subnet (avanceret)", + "setupSubnetDescription": "Subnet for denne organisations interne netværk.", + "setupUtilitySubnet": "Utility Subnet (Avanceret)", + "setupUtilitySubnetDescription": "Subnettet for denne organisations aliasadresser og DNS-server.", + "siteRegenerateAndDisconnect": "Regenerer og frakobl", + "siteRegenerateAndDisconnectConfirmation": "Er du sikker på, at du vil regenerere legitimationsoplysninger og frakoble dette site?", + "siteRegenerateAndDisconnectWarning": "Dette vil regenerere legitimationsoplysninger og straks frakoble sitet. Sitet skal startes igen med de nye legitimationsoplysninger.", + "siteRegenerateCredentialsConfirmation": "Er du sikker på, at du vil regenerere legitimationsoplysninger for dette site?", + "siteRegenerateCredentialsWarning": "Dette vil regenerere legitimationsoplysningerne. Sitet forbliver tilsluttet, indtil du manuelt genstarter det og bruger de nye legitimationsoplysninger.", + "clientRegenerateAndDisconnect": "Regenerer og frakobl", + "clientRegenerateAndDisconnectConfirmation": "Er du sikker på, at du vil regenerere legitimationsoplysninger og frakoble denne klient?", + "clientRegenerateAndDisconnectWarning": "Dette vil regenerere legitimationsoplysninger og straks frakoble klienten. Klienten skal startes igen med de nye legitimationsoplysninger.", + "clientRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere legitimationsoplysninger for denne klienten?", + "clientRegenerateCredentialsWarning": "Dette vil regenerere legitimationsoplysninger. Klienten forbliver tilsluttet, indtil du manuelt genstarter den og bruger de nye legitimationsoplysninger.", + "remoteExitNodeRegenerateAndDisconnect": "Regenerer og frakobl", + "remoteExitNodeRegenerateAndDisconnectConfirmation": "Er du sikker på, at du vil regenerere legitimationsoplysninger og frakoble denne eksterne exitnode?", + "remoteExitNodeRegenerateAndDisconnectWarning": "Dette vil regenerere loginoplysningerne og straks frakoble den eksterne exitnode. Den eksterne exitnode skal startes igen med de nye oplysninger.", + "remoteExitNodeRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere loginoplysningerne for denne eksterne avslutningen?", + "remoteExitNodeRegenerateCredentialsWarning": "Dette vil regenerere legitimationsoplysninger. Den eksterne exitnode forbliver tilsluttet, indtil du manuelt genstarter den og bruger de nye legitimationsoplysninger.", + "agent": "Agent", + "personalUseOnly": "Kun til personlig brug", + "loginPageLicenseWatermark": "Denne instansen er licenseret kun for personlig brug.", + "instanceIsUnlicensed": "Denne instans er ulicenseret.", + "portRestrictions": "Portbegrensninger", + "allPorts": "Alle", + "custom": "Brugerdefineret", + "allPortsAllowed": "Alle porte tilladt", + "allPortsBlocked": "Alle porte blokeret", + "tcpPortsDescription": "Angiv hvilke TCP-porte, der er tilladt for denne ressource. Brug '*' for alle porte, lad feltet stå tomt for at blokere alle, eller indtast en kommasepareret liste over porte og intervaller (f.eks. 80,443,8000-9000).", + "udpPortsDescription": "Angiv hvilke UDP-porte, der er tilladt for denne ressource. Brug '*' for alle porte, lad feltet stå tomt for at blokere alle, eller indtast en kommasepareret liste over porte og intervaller (f.eks. 53,123,500-600).", + "organizationLoginPageTitle": "Organisationens loginside", + "organizationLoginPageDescription": "Tilpass loginsiden for denne organisation", + "resourceLoginPageTitle": "Ressourcens loginside", + "resourceLoginPageDescription": "Tilpass loginsiden for individuelle ressourcer", + "enterConfirmation": "Indtast bekræftelse", + "blueprintViewDetails": "Detaljer", + "defaultIdentityProvider": "Standard identitetsudbyder", + "defaultIdentityProviderDescription": "Brugeren vil automatisk blive videresendt til denne identitetsudbyder for autentificering.", + "editInternalResourceDialogNetworkSettings": "Netværksindstillinger", + "editInternalResourceDialogAccessPolicy": "Adgangsregler for adgang", + "editInternalResourceDialogAddRoles": "Tilføj roller", + "editInternalResourceDialogAddUsers": "Tilføj brugere", + "editInternalResourceDialogAddClients": "Tilføj klienter", + "editInternalResourceDialogDestinationLabel": "Destination", + "editInternalResourceDialogDestinationDescription": "Angiv destinationsadressen for den interne ressource. Dette kan være et værtsnavn, en IP-adresse eller et CIDR-område afhængigt af den valgte tilstand. Du kan også konfigurere et internt DNS-alias for nemmere identifikation.", + "internalResourceFormMultiSiteRoutingHelp": "Valg af flere sites muliggør robust routing og failover for høj tilgængelighed.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Læs mere", + "editInternalResourceDialogPortRestrictionsDescription": "Begrens adgang til specifikke TCP/UDP-porte eller tillade/blokere alle porte.", + "createInternalResourceDialogHttpConfiguration": "HTTP-konfiguration", + "createInternalResourceDialogHttpConfigurationDescription": "Vælg domænet klienter vil bruge for at nu denne ressource via HTTP eller HTTPS.", + "editInternalResourceDialogHttpConfiguration": "HTTP-konfiguration", + "editInternalResourceDialogHttpConfigurationDescription": "Vælg domænet klienter vil bruge for at nu denne ressource via HTTP eller HTTPS.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Adgangskontrol", + "editInternalResourceDialogAccessControlDescription": "Kontroller hvilke roller, brugere og maskinklienter som har adgang til denne ressource når den er forbundet til. Administratorer har altid adgang.", + "editInternalResourceDialogPortRangeValidationError": "Portintervallet skal være \"*\" for alle porte eller en kommasepareret liste med porte og intervaller (f.eks. \"80,443,8000-9000\"). Porte skal være mellem 1 og 65535.", + "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Sted", + "internalResourceAuthDaemonStrategyDescription": "Vælg, hvor SSH-autentificeringsdaemonen kører: på sitet (Newt) eller på en ekstern host.", + "internalResourceAuthDaemonDescription": "SSH-godkendelsesdaemonen håndterer SSH-nøglesignering og PAM-autentificering for denne ressource. Vælg, om den kører på sitet (Newt) eller på en separat ekstern host. Se dokumentationen for mere.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Vælg strategi", + "internalResourceAuthDaemonStrategyLabel": "Sted", + "internalResourceAuthDaemonSite": "På site", + "internalResourceAuthDaemonSiteDescription": "Autentificeringsdaemonen kører på sitet (Newt).", + "internalResourceAuthDaemonRemote": "Ekstern host", + "internalResourceAuthDaemonRemoteDescription": "Autentificeringsdaemonen kører på en host, som ikke er sitet.", + "internalResourceAuthDaemonPort": "Daemon Port (valgfrit)", + "orgAuthWhatsThis": "Hvor kan jeg finne min organisations-ID?", + "learnMore": "Læs mere", + "backToHome": "Gå tilbage til start", + "needToSignInToOrg": "Behøver du at bruge organisationens identitetsudbyder?", + "maintenanceMode": "Vedligeholdelsesside", + "maintenanceModeDescription": "Vis en vedligeholdelsesside til besøgende", + "maintenanceModeType": "Vedligeholdelsestilstandstype", + "showMaintenancePage": "Vis en vedligeholdelsesside til besøgende", + "enableMaintenanceMode": "Aktivér vedligeholdelsestilstand", + "enableMaintenanceModeDescription": "Når dette er aktiveret, vil besøgende se en vedligeholdelsesside i stedet for din ressource.", + "automatic": "Automatisk", + "automaticModeDescription": "Vis kun vedligeholdelsessiden, når alle serverens mål er nede eller usunde. Din ressource fortsætter med at fungere normalt, så længe mindst ét mål er sundt.", + "forced": "Tvunget", + "forcedModeDescription": "Vis altid vedligeholdelsessiden uafhængigt af serverens sundhed. Brug dette ved planlagt vedligeholdelse, når du vil forhindre al adgang.", + "warning:": "Advarsel:", + "forcedeModeWarning": "Al trafik vil blive dirigeret til vedligeholdelsessiden. Serverens ressourcer vil ikke modtage nogen forespørgsler.", + "pageTitle": "Sidetittel", + "maintenancePageContentSubsection": "Sideindhold", + "maintenancePageContentSubsectionDescription": "Tilpas indholdet, der vises på vedligeholdelsessiden", + "pageTitleDescription": "Hovedoverskriften, der vises på vedligeholdelsessiden", + "maintenancePageMessage": "Vedligeholdelsesbesked", + "maintenancePageMessagePlaceholder": "Vi kommer snart tilbage! Vores site gennemgår i øjeblikket planlagt vedligeholdelse.", + "maintenancePageMessageDescription": "Detaljeret besked, der forklarer vedligeholdelsen", + "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfrit)", + "privateMaintenanceScreenTitle": "Privat plassholder skærm", + "privateMaintenanceScreenMessage": "Dette domænet bruges på en privat ressource. Opret forbindelse til ved at bruge Pangolin-klienten for at få adgang til denne ressource.", + "privateMaintenanceScreenSteps": "Hvis du stadig ser denne meddelelse, når du er forbundet, peger browserens DNS-cache muligvis stadig på den gamle adresse. For at rette det: Luk og åbn denne fane eller browseren igen, og gå derefter tilbage til denne side.", + "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", + "maintenanceEstimatedTimeDescription": "Hvornår du forventer, at vedligeholdelsen er færdig", + "editDomain": "Rediger domæne", + "editDomainDescription": "Vælg et domæne for din ressource", + "maintenanceModeDisabledTooltip": "Denne funktion kræver en gyldig licens for at aktiveres.", + "maintenanceScreenTitle": "Tjenesten er midlertidigt utilgængelig", + "maintenanceScreenMessage": "Vi oplever i øjeblikket tekniske problemer. Tjek venligst igen snart.", + "maintenanceScreenEstimatedCompletion": "Estimert ferdigstillelse:", + "createInternalResourceDialogDestinationRequired": "Destinationen er nødvendig", + "available": "Tilgængelig", + "disabledResourceDescription": "Når ressourcen er deaktiveret, vil den være utilgængelig for alle.", + "archived": "Arkiveret", + "noArchivedDevices": "Ingen arkiverede enheder fundet", + "deviceArchived": "Enhed arkiveret", + "deviceArchivedDescription": "Enheden er blevet arkiveret.", + "errorArchivingDevice": "Fejl ved arkivering af enhed", + "failedToArchiveDevice": "Kunne ikke arkivere enhed", + "deviceQuestionArchive": "Er du sikker på, at du vil arkivere denne enhed?", + "deviceMessageArchive": "Enheden bliver arkiveret og fjernet fra listen over aktive enheder.", + "deviceArchiveConfirm": "Arkiver enhed", + "archiveDevice": "Arkiver enhed", + "archive": "Arkiv", + "deviceUnarchived": "Enheden er fjernet fra arkivet", + "deviceUnarchivedDescription": "Enheden er blevet fjernet fra arkivet.", + "errorUnarchivingDevice": "Fejl ved arkivering af enhed", + "failedToUnarchiveDevice": "Kunne ikke fjerne enheden fra arkivet", + "unarchive": "Avarkiver", + "archiveClient": "Arkiver klient", + "archiveClientQuestion": "Er du sikker på at du vil arkivere denne klienten?", + "archiveClientMessage": "Klienten arkiveres og fjernes fra listen over aktive klienter.", + "archiveClientConfirm": "Arkiver klient", + "blockClient": "Bloker kunde", + "blockClientQuestion": "Er du sikker på at du vil blokere denne klienten?", + "blockClientMessage": "Enheden bliver tvunget til at frakoble, hvis den er forbundet. Du kan fjerne blokeringen af enheden senere.", + "blockClientConfirm": "Bloker kunde", + "active": "Aktiv", + "usernameOrEmail": "Brugernavn eller e-mail", + "selectYourOrganization": "Vælg din organisation", + "signInTo": "Log ind på", + "signInWithPassword": "Fortsæt med adgangskode", + "noAuthMethodsAvailable": "Ingen autentificeringsmetoder er tilgængelige for denne organisation.", + "enterPassword": "Angiv dit adgangskode", + "enterMfaCode": "Angiv koden fra din godkendelsesapp", + "securityKeyRequired": "Venligst brug sikkerhedsnøglen til at loge på.", + "needToUseAnotherAccount": "Behøver du at bruge en anden konto?", + "loginLegalDisclaimer": "Ved at klike på knapperne nedenfor, anerkender du at du har læst, forstår, og accepterer Vilkår for brug og for Privatlivserklæring.", + "termsOfService": "Vilkår for brug", + "privacyPolicy": "Privatlivspolitik", + "userNotFoundWithUsername": "Ingen bruger med det brugernavnet fundet.", + "verify": "Bekræft", + "signIn": "Log ind", + "forgotPassword": "Glemt adgangskode?", + "orgSignInTip": "Hvis du har loget ind før, kan du skrive ind brugernavnet eller e-mailadressen ovenfor for at autentificere med organisationens identitetstjeneste i stedet. Det er nemmere!", + "continueAnyway": "Fortsæt likevel", + "dontShowAgain": "Ikke vis tilbage", + "orgSignInNotice": "Visste du?", + "signupOrgNotice": "Prøver du at logge ind?", + "signupOrgTip": "Prøver du at logge ind via din organisations identitetsudbyder?", + "signupOrgLink": "Log ind eller tilmeld dig med organisationen din i stedet", + "verifyEmailLogInWithDifferentAccount": "Brug en anden konto", + "logIn": "Log ind", + "deviceInformation": "Enhedsoplysninger", + "deviceInformationDescription": "Oplysninger om enheden og agenten", + "deviceSecurity": "Enhedssikkerhed", + "deviceSecurityDescription": "Sikkerhedstilstandsinformation om udstyr", + "platform": "Platform", + "macosVersion": "macOS version", + "windowsVersion": "Windows version", + "iosVersion": "iOS Version", + "androidVersion": "Android version", + "osVersion": "OS version", + "kernelVersion": "Kjerne version", + "deviceModel": "Enhedsmodel", + "serialNumber": "Serienummer", + "hostname": "Hostname", + "firstSeen": "Først sett", + "lastSeen": "Sist sett", + "biometricsEnabled": "Biometri aktiveret", + "diskEncrypted": "Disk krypteret", + "firewallEnabled": "Brannmur aktiveret", + "autoUpdatesEnabled": "Automatiske opdateringer aktiveret", + "tpmAvailable": "TPM tilgængelig", + "windowsAntivirusEnabled": "Antivirus aktiveret", + "macosSipEnabled": "System Integritetsbeskyttelse (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Brannmur Usynlig Tilstand", + "linuxAppArmorEnabled": "Rustning", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Vis enhedsoplysninger og indstillinger", + "devicePendingApprovalDescription": "Denne enhed venter på godkendelse", + "deviceBlockedDescription": "Denne enhed er blokeret. Den kan ikke oprette forbindelse til nogen ressourcer, medmindre blokeringen fjernes.", + "unblockClient": "Fjern blokering af klient", + "unblockClientDescription": "Enheden er blevet blokeret", + "unarchiveClient": "Fjern arkivering klient", + "unarchiveClientDescription": "Enheden er arkiveret", + "block": "Bloker", + "unblock": "Fjern blokering af", + "deviceActions": "Enhedshandlinger", + "deviceActionsDescription": "Administrer enhedsstatus og adgang", + "devicePendingApprovalBannerDescription": "Denne enhed venter på godkendelse. Den kan ikke oprette forbindelse til ressourcer, før den er godkendt.", + "connected": "Tilsluttet", + "disconnected": "Offline", + "approvalsEmptyStateTitle": "Enhedsgodkendelser er ikke aktiveret", + "approvalsEmptyStateDescription": "Aktivere godkendelser af enheder for at roller skal godkendes af admin før brugere kan opret forbindelse til nye enheder.", + "approvalsEmptyStateStep1Title": "Gå til roller", + "approvalsEmptyStateStep1Description": "Naviger til organisationens roller indstillinger for at konfigurere enhedsgodkendelser.", + "approvalsEmptyStateStep2Title": "Aktivér enhedsgodkendelser", + "approvalsEmptyStateStep2Description": "Rediger en rolle og aktivér muligheden 'Kræve enhedsgodkendelser'. Brugere med denne rolle vil have brug for administratorgodkendelse for nye enheder.", + "approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når dette er aktiveret, vises ventende enhedsanmodninger her til gennemgang", + "approvalsEmptyStateButtonText": "Administrer Roller", + "domainErrorTitle": "Vi har problemer med at verificere dit domæne", + "idpAdminAutoProvisionPoliciesTabHint": "Konfigurer rollegartlegging og organisationspolitikker på Autoprovisioneringsindstillinger fanen.", + "streamingTitle": "Hændelsesstreaming", + "streamingDescription": "Stream hændelser fra din organisation til eksterne destinationer i sanntid.", + "streamingUnnamedDestination": "Plassering uden navn", + "streamingNoUrlConfigured": "Ingen URL konfigureret", + "streamingAddDestination": "Tilføj mål", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Send hændelser til alle HTTP-endpoints med fleksibel autentificering og maling.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Strøm hændelser til en S3-kompatibel objektlager. Kommer snart.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Videresend arrangementer direkte til din Datadog-konto. Kommer snart.", + "streamingTypePickerDescription": "Vælg en måltype for at komme i gang.", + "streamingLastSyncError": "Det opstod en fejl under seneste synkronisering", + "streamingUnexpectedError": "En uventet fejl opstod.", + "streamingFailedToUpdate": "Kunne ikke opdatere destination", + "streamingDeletedSuccess": "Målet blev slettet", + "streamingFailedToDelete": "Kunne ikke slette destination", + "streamingDeleteTitle": "Slet mål", + "streamingDeleteButtonText": "Slet mål", + "streamingDeleteDialogAreYouSure": "Er du sikker på at du vil slette", + "streamingDeleteDialogThisDestination": "denne destinationen", + "streamingDeleteDialogPermanentlyRemoved": "? Alle konfigurationer vil blive slettet permanent.", + "httpDestEditTitle": "Rediger mål", + "httpDestAddTitle": "Tilføj HTTP-destination", + "httpDestEditDescription": "Opdater konfigurationen for denne HTTP-hændelsesstreamingdestination.", + "httpDestAddDescription": "Konfigurer et nyt HTTP-endpoint til at modtage organisationens hændelser.", + "S3DestEditTitle": "Rediger destination", + "S3DestAddTitle": "Tilføj S3 destination", + "S3DestEditDescription": "Opdater konfigurationen for denne S3-hændelsesstreamingdestination.", + "S3DestAddDescription": "Konfigurer en ny Amazon S3-bucket (eller S3-kompatibel bucket) til at modtage din organisations hændelser.", + "s3DestTabSettings": "Indstillinger", + "s3DestTabFormat": "Format", + "s3DestNameLabel": "Navn", + "s3DestNamePlaceholder": "Min S3-destination", + "s3DestAccessKeyIdLabel": "AWS adgangsnøgle-ID", + "s3DestSecretAccessKeyLabel": "AWS hemmelig adgangsnøgle", + "s3DestSecretAccessKeyPlaceholder": "Din AWS hemmelighed access key", + "s3DestRegionLabel": "AWS-region", + "s3DestBucketLabel": "Bucket-navn", + "s3DestPrefixLabel": "Nøglepræfiks (valgfrit)", + "s3DestPrefixDescription": "Valgfrit sti-prefiks tilføjet hver objektnøgle. Objekter er gemt på {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.", + "s3DestEndpointLabel": "Brugerdefineret endpoint (valgfrit)", + "s3DestEndpointDescription": "Overstyr S3-endepunktet for S3-kompatibel lagring som MinIO eller Cloudflare R2. Lad stå tomt for standard AWS S3.", + "s3DestGzipLabel": "Gzip-komprimering", + "s3DestGzipDescription": "Komprimer hvert uploadede objekt med gzip. Reducerer lageromkostninger og uploadstørrelse.", + "s3DestFormatTitle": "Filformat", + "s3DestFormatDescription": "Hvordan hændelser er serialisert i hvert uploadede objekt.", + "s3DestFormatJsonArrayDescription": "Hvert objekt er et JSON-array af hændelsesposter. Kompatibel med de fleste analyseværktøjer.", + "s3DestFormatNdjsonDescription": "Hvert objekt indeholder en JSON-post per linje (nylinje-delt JSON). Kompatibel med Athena, BigQuery, og Spark.", + "s3DestFormatCsvTitle": "CSV", + "s3DestFormatCsvDescription": "Hvert objekt er en RFC-4180 CSV-fil med en overskriftsrække. Kolonnenavne er afledt af hændelsesdatafelterne.", + "s3DestSaveChanges": "Gem ændringer", + "s3DestCreateDestination": "Opret destination", + "s3DestUpdatedSuccess": "Destination opdateret med succes", + "s3DestCreatedSuccess": "Destination oprettet med succes", + "s3DestUpdateFailed": "Kunne ikke opdatere destination", + "s3DestCreateFailed": "Kunne ikke oprette destination", + "datadogDestEditTitle": "Rediger destination", + "datadogDestAddTitle": "Tilføj Datadog destination", + "datadogDestEditDescription": "Opdater konfigurationen for denne Datadog-hændelsesstreamingdestination.", + "datadogDestAddDescription": "Konfigurer et nyt Datadog-endpoint til at modtage organisationens hændelser.", + "httpDestTabSettings": "Indstillinger", + "httpDestTabHeaders": "Headers", + "httpDestTabBody": "Indhold", + "httpDestTabLogs": "Logs", + "httpDestNamePlaceholder": "Min HTTP destination", + "httpDestUrlLabel": "Destinasjons URL", + "httpDestUrlErrorHttpRequired": "URL-adressen skal bruge httpp eller HTTPS", + "httpDestUrlErrorHttpsRequired": "HTTPS er nødvendig for distribution af cloud", + "httpDestUrlErrorInvalid": "Indtast en gyldig webadresse (f.eks. https://eksempel.com/webhook)", + "httpDestAuthTitle": "Autentificering", + "httpDestAuthDescription": "Vælg, hvordan anmodninger til dit endpoint autentificeres.", + "httpDestAuthNoneTitle": "Ingen godkendelse", + "httpDestAuthNoneDescription": "Sender forespørgsler uden autorisasjonsoverskrift.", + "httpDestAuthBearerTitle": "Bærer Symbol", + "httpDestAuthBearerDescription": "Legger til en Autorisation: Bearer '' header til hver forespørgsel.", + "httpDestAuthBearerPlaceholder": "Din API-nøgle eller token", + "httpDestAuthBasicTitle": "Standard Auth", + "httpDestAuthBasicDescription": "Legger til en Autorisation: Basic '' header. Give legitimationsoplysninger som brugernavn:adgangskode.", + "httpDestAuthBasicPlaceholder": "brugernavn:adgangskode", + "httpDestAuthCustomTitle": "Brugerdefineret header", + "httpDestAuthCustomDescription": "Angiv et brugerdefineret HTTP headers navn og værdi for autentificering (f.eks X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Headernavn (f.eks. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Header værdi", + "httpDestCustomHeadersTitle": "Brugerdefinerede HTTP-headers", + "httpDestCustomHeadersDescription": "Tilføj brugerdefinerede headers til hver udgående forespørgsel. Nyttig for statisk tokens eller en brugerdefineret indholdstype. Som standard bliver indholdstype: application/json sendt.", + "httpDestNoHeadersConfigured": "Ingen brugerdefinerede headers konfigureret. Klik på \"Tilføj header\" for at tilføje en.", + "httpDestHeaderNamePlaceholder": "Headernavn", + "httpDestHeaderValuePlaceholder": "Værdi", + "httpDestAddHeader": "Tilføj header", + "httpDestBodyTemplateTitle": "Brugerdefineret hovedmal", + "httpDestBodyTemplateDescription": "Kontroller JSON payloadstrukturen sendt til dit endpoint. Hvis deaktiveret, sendes et standard JSON-objekt for hver hændelse.", + "httpDestEnableBodyTemplate": "Aktivér brugerdefineret meldingsmal", + "httpDestBodyTemplateLabel": "Body-skabelon (JSON)", + "httpDestBodyTemplateHint": "Brug designmal variabler for at referere til eventfelt i din betaling.", + "httpDestPayloadFormatTitle": "Mål format", + "httpDestPayloadFormatDescription": "Hvordan hændelser serialiseres i hver request body.", + "httpDestFormatJsonArrayTitle": "JSON-liste", + "httpDestFormatJsonArrayDescription": "Én forespørgsel pr. batch; indholdet er en JSON-liste. Kompatibel med de fleste generiske webhooks og Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Én forespørgsel pr. batch; indholdet er newline-delimited JSON — ét objekt pr. linje, uden ydre array. Kræves af Splunk HEC, Elastic/OpenSearch og Grafana Loki.", + "httpDestFormatSingleTitle": "En hændelse per forespørgsel", + "httpDestFormatSingleDescription": "Sender en separat HTTP POST for hver enkelt hændelse. Brug kun for endpoints der ikke kan håndtere batcher.", + "httpDestLogTypesTitle": "Logtyper", + "httpDestLogTypesDescription": "Vælg, hvilke logtyper der videresendes til dette mål. Kun aktiverede logtyper bliver streamet.", + "httpDestAccessLogsTitle": "Adgangslogs", + "httpDestAccessLogsDescription": "Adgangsforsøk for ressourcer, inklusive godkendte og afvist forespørgsler.", + "httpDestActionLogsTitle": "Handlingsloger", + "httpDestActionLogsDescription": "Administrative tiltak som utføres af brugere inden for organisationen.", + "httpDestConnectionLogsTitle": "Forbindelseslogs", + "httpDestConnectionLogsDescription": "Udstyrs- og tunnelforbindelseshændelser, inklusive forbindelser og frakobling.", + "httpDestRequestLogsTitle": "HTTP-forespørgselslogs", + "httpDestRequestLogsDescription": "HTTP-forespørgsel logs for bekræftede ressourcer, inklusive metode, sti og responskode.", + "httpDestSaveChanges": "Gem ændringer", + "httpDestCreateDestination": "Opret mål", + "httpDestUpdatedSuccess": "Målet er opdateret", + "httpDestCreatedSuccess": "Målet er oprettet", + "httpDestUpdateFailed": "Kunne ikke opdatere destination", + "httpDestCreateFailed": "Kan ikke oprette mål", + "followRedirects": "Følg videresendinger", + "followRedirectsDescription": "Følg automatisk HTTP-videresendinger for forespørgsler.", + "alertingErrorWebhookUrl": "Indtast venligst en gyldig URL for webhooken.", + "healthCheckStrategyHttp": "Validerer forbindelse og kontrollerer HTTP-responsstatus.", + "healthCheckStrategyTcp": "Bekræfter kun TCP-forbindelse, uden at inspisere responsen.", + "healthCheckStrategySnmp": "Udfører en SNMP get-forespørgsel for at tjekke sundheden til netværksenheder og infrastruktur.", + "healthCheckStrategyIcmp": "Bruger ICMP ekko forespørgsler (ping) for at tjekke om en ressource er tilgængelig og responsiv.", + "healthCheckTabStrategy": "Strategi", + "healthCheckTabConnection": "Forbindelse", + "healthCheckTabAdvanced": "Avanceret", + "healthCheckStrategyNotAvailable": "Denne strategien er ikke tilgængeligt. Venligst kontakt salgsafdelingen for at aktivere denne funktion.", + "uptime30d": "Oppetid (30 d)", + "idpAddActionCreateNew": "Opret ny identitetsudbyder", + "idpAddActionImportFromOrg": "Importer fra en anden organisation", + "idpImportDialogTitle": "Importer identitetsudbyder", + "idpImportDialogDescription": "Vælg en identitetsudbyder fra en organisation der du er admin. Den vil blive knyttet til denne organisation.", + "idpImportSearchPlaceholder": "Søg efter organisations- eller leverandørnavn...", + "idpImportEmpty": "Ingen identitetsudbydere fundet.", + "idpImportedDescription": "Identitetsudbyderen blev importert med succes.", + "idpDeleteGlobalQuestion": "Er du sikker på, at du vil slette denne identitetsudbyder permanent?", + "idpDeleteGlobalDescription": "Dette vil slette identitetsudbyder permanent fra alle organisationer, den er tilknyttet.", + "idpUnassociateTitle": "Frakobl identitetsudbyder", + "idpUnassociateQuestion": "Er du sikker på, at du vil frakoble denne identitetsudbyder fra denne organisation?", + "idpUnassociateDescription": "Alle brugere knyttet til denne identitetsudbyder vil blive fjernet fra denne organisation, men identitetsudbyderen vil stadig eksistere for andre tilknyttede organisationer.", + "idpUnassociateConfirm": "Bekræft frakobling af identitetsudbyder", + "idpConfirmDeleteAndRemoveMeFromOrg": "SLET OG FJERN MIG FRA ORGANISATIONEN", + "idpUnassociateAndRemoveMeFromOrg": "FRAKOBL OG FJERN MIG FRA ORGANISATIONEN", + "idpUnassociateWarning": "Dette kan ikke fortrydes for denne organisation.", + "idpUnassociatedDescription": "Identitetsudbyderen er frakoblet fra denne organisation", + "idpUnassociateMenu": "Frakobl", + "idpDeleteAllOrgsMenu": "Slet", + "publicIpEndpoint": "Endpoint", + "lastTriggeredAt": "Senest udløst", + "reject": "Afvis", + "uptimeDaysAgo": "{count} dage siden", + "uptimeToday": "I dag", + "uptimeNoDataAvailable": "Ingen data tilgængelig", + "uptimeSuffix": "oppetid", + "uptimeDowntimeSuffix": "nedetid", + "uptimeTooltipUptimeLabel": "Oppetid", + "uptimeTooltipDowntimeLabel": "Nedetid", + "uptimeOngoing": "pågående", + "uptimeNoMonitoringData": "Ingen overvåkingsdata", + "uptimeNoData": "Ingen data", + "uptimeMiniBarDown": "Nede", + "uptimeSectionTitle": "Oppetid", + "uptimeSectionDescription": "Tilgængelighed de seneste {days} dage", + "uptimeAddAlert": "Tilføj varsling", + "uptimeViewAlerts": "Vis varsler", + "uptimeCreateEmailAlert": "Opret e-mailnotifikation", + "uptimeAlertDescriptionSite": "Få besked via e-mail, når dette site går offline eller kommer tilbage online.", + "uptimeAlertDescriptionResource": "Få besked via e-mail, når denne ressource går offline eller kommer tilbage online.", + "uptimeAlertNamePlaceholder": "Varslingsnavn", + "uptimeAdditionalEmails": "Flere e-mails", + "uptimeCreateAlert": "Opret varsling", + "uptimeAlertNoRecipients": "Ingen modtagere", + "uptimeAlertNoRecipientsDescription": "Venligst tilføj mindst én bruger, rolle, eller e-mail for at varsle.", + "uptimeAlertCreated": "Notifikation oprettet", + "uptimeAlertCreatedDescription": "Du vil blive varslet når dette ændrer status.", + "uptimeAlertCreateFailed": "Kunne ikke oprette notifikation", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Nøgle", + "webhookHeaderValuePlaceholder": "Værdi", + "alertLabel": "Notifikation", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard-underdomæner er ikke tilladt.", + "domainPickerWildcardCertWarning": "Wildcard-ressourcer kan kræve ekstra konfiguration for at fungere korrekt.", + "domainPickerWildcardCertWarningLink": "Læs mere", + "health": "Sundhed", + "domainPendingErrorTitle": "Verificeringsproblem", + "memberPortalTitle": "Ressourcer", + "memberPortalDescription": "Ressourcer du har adgang til i denne organisation", + "memberPortalSortBy": "Sorter efter...", + "memberPortalSortNameAsc": "Navn A-AT", + "memberPortalSortNameDesc": "Navn AT-A", + "memberPortalSortDomainAsc": "Domæne A-AT", + "memberPortalSortDomainDesc": "Domæne AT-A", + "memberPortalSortEnabledFirst": "Aktiveret først", + "memberPortalSortDisabledFirst": "Deaktiveret først", + "memberPortalRefresh": "Opdater", + "memberPortalRefreshResources": "Opdater ressourcer", + "memberPortalFailedToLoad": "Kunne ikke indlæse ind ressourcer", + "memberPortalFailedToLoadDescription": "Kunne ikke indlæse ressourcer. Tjek venligst din forbindelse, og prøv igen.", + "memberPortalUnableToLoad": "Kan ikke indlæse ind ressourcer", + "memberPortalTryAgain": "Prøv igen", + "memberPortalNoResourcesFound": "Ingen ressourcer fundet", + "memberPortalNoResourcesAvailable": "Ingen ressourcer tilgængelig", + "memberPortalNoResourcesMatchSearch": "Ingen ressourcer matcher med \"{query}\". Prøv at justere søgeordene dine eller fjern søgningen for at se alle ressourcer.", + "memberPortalNoResourcesAccess": "Du har endnu ikke adgang til nogen ressourcer. Kontakt din administrator for at få adgang til de ressourcer, du har brug for.", + "memberPortalClearSearch": "Fjern søg", + "memberPortalPublicResources": "Offentlige ressourcer", + "memberPortalPublicResourcesDescription": "Webapplikationer og -tjenester tilgængelige via browser", + "memberPortalCopiedToClipboard": "Kopieret til udklipsholderen", + "memberPortalCopiedUrlDescription": "Ressource-URL er kopieret til din udklipsholder.", + "memberPortalOpenResource": "Åbn ressource", + "memberPortalPrivateResources": "Private ressourcer", + "memberPortalPrivateResourcesDescription": "Interne netværksressourcer tilgængelige via klient", + "memberPortalResourceDetails": "Ressourcedetaljer", + "memberPortalMode": "Tilstand", + "memberPortalDestination": "Destination", + "memberPortalAlias": "Navn", + "memberPortalCopiedAliasDescription": "Ressourcealias er kopieret til din udklipsholder.", + "memberPortalCopiedDestinationDescription": "Ressourcedestination er kopieret til din udklipsholder.", + "memberPortalRequiresClientConnection": "Kræver klientforbindelse", + "memberPortalAuthMethods": "Autentificeringsmetoder", + "memberPortalSso": "Enkeltpåloging (SSO)", + "memberPortalPasswordProtected": "Adgangskodebeskyttet", + "memberPortalPinCode": "PIN-kode", + "memberPortalEmailWhitelist": "E-mailwhitelist", + "memberPortalResourceDisabled": "Ressource deaktiveret", + "memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer", + "memberPortalPrevious": "Forrige", + "memberPortalNext": "Næste", + "httpSettings": "HTTP Indstillinger", + "tcpSettings": "TCP Indstillinger", + "udpSettings": "UDP Indstillinger", + "sshTitle": "SSH", + "sshConnectingDescription": "Oprette en sikker forbindelse…", + "sshConnecting": "Opretter forbindelse til…", + "sshInitializing": "Initialiserer…", + "sshSignInTitle": "Log ind på SSH", + "sshSignInDescription": "Indtast dine SSH-legitimationsoplysninger for at oprette forbindelse til", + "sshPasswordTab": "Adgangskode", + "sshPrivateKeyTab": "Privat Nøgle", + "sshPrivateKeyField": "Privat Nøgle", + "sshPrivateKeyDisclaimer": "Din private nøgle gemmes ikke og er ikke synlig for Pangolin. Alternativt kan du bruge kortlivede certifikater til sømløs autentificering med din eksisterende Pangolin-identitet.", + "sshLearnMore": "Læs mere", + "sshPrivateKeyFile": "Privat nøglefil", + "sshAuthenticate": "Opret forbindelse til", + "sshTerminate": "Avslutt", + "sshPoweredBy": "Drevet af", + "sshErrorNoTarget": "Ingen mål spesifisert", + "sshErrorWebSocket": "WebSocket-forbindelse mislykkedes", + "sshErrorAuthFailed": "Autentificering mislykkedes", + "sshErrorConnectionClosed": "Forbindelse afsluttet før autentificering blev fuldført", + "sitePangolinSshDescription": "Tillad SSH-adgang til ressourcer på dette site. Dette kan ændres senere.", + "browserGatewayNoResourceForDomain": "Ingen ressourcer fundet for dette domænet", + "browserGatewayNoTarget": "Ingen mål", + "browserGatewayConnect": "Opret forbindelse til", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Kunne ikke signere SSH-nøgle for PAM-loginautentificering. Er du logget ind som bruger?", + "sshTerminalError": "Fejl: {error}", + "sshConnectionClosedCode": "Forbindelsen blev lukket (kode {code})", + "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", + "vncPasswordOptional": "Adgangskode (valgfrit)", + "vncNoResourceTarget": "Intet ressourcemål tilgængeligt", + "vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Indsæt udklipsholder", + "rdpTitle": "RDP", + "rdpSignInTitle": "Log ind på Fjernskrivebord", + "rdpSignInDescription": "Indtast Windows-legitimationsoplysninger for at oprette forbindelse til", + "rdpLoadingModule": "Indlæser modul...", + "rdpFailedToLoadModule": "Kunne ikke indlæse RDP-modul", + "rdpNotReady": "Ikke klar", + "rdpModuleInitializing": "RDP-modulen er stadig under initialisering", + "rdpDownloadingFiles": "Downloader {count} fil(er) fra fjern…", + "rdpDownloadFailed": "Download mislykkedes: {fileName}", + "rdpUploaded": "Uploadet: {fileName}", + "rdpNoConnectionTarget": "Intet forbindelsesmål tilgængeligt", + "rdpConnectionFailed": "Forbindelsen mislykkedes", + "rdpFit": "Tilpass", + "rdpFull": "Full", + "rdpReal": "Ekte", + "rdpMeta": "Meta", + "rdpUploadFiles": "Upload filer", + "rdpFilesReadyToPaste": "Filer klare til at limes ind", + "rdpFilesReadyToPasteDescription": "{count} fil(er) kopieret til fjernudklipsholderen — trykk Ctrl+V på fjernskrivebordet for at indsætte.", + "rdpUploadFailed": "Uploaden mislykkedes", + "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", + "sessionToolbarShow": "Vis værktøjslinje", + "sessionToolbarHide": "Skjul værktøjslinje" +} \ No newline at end of file From 4eba51de72e55a63fd7605430c449a848745bc7d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 17:45:44 -0400 Subject: [PATCH 114/264] support delete resources associated with site --- messages/en-US.json | 10 +- server/lib/deleteResource.ts | 144 ++++++++++++++++++ server/lib/deleteSiteAssociatedResources.ts | 126 +++++++++++++++ server/lib/deleteSiteResource.ts | 53 +++++++ server/middlewares/verifyOrgAccess.ts | 3 - server/routers/resource/deleteResource.ts | 93 ++--------- server/routers/site/deleteSite.ts | 98 +++++++++++- .../siteResource/deleteSiteResource.ts | 45 +++--- src/components/SitesTable.tsx | 56 ++++++- 9 files changed, 507 insertions(+), 121 deletions(-) create mode 100644 server/lib/deleteResource.ts create mode 100644 server/lib/deleteSiteAssociatedResources.ts create mode 100644 server/lib/deleteSiteResource.ts diff --git a/messages/en-US.json b/messages/en-US.json index 30173d651..2a3379516 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Edit", "siteConfirmDelete": "Confirm Delete Site", + "siteConfirmDeleteAndResources": "Confirm Delete Site and Resources", "siteDelete": "Delete Site", - "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", + "siteDeleteAndResources": "Delete Site and Resources", + "siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.", + "siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", + "siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?", + "sitesTableDeleteSite": "Delete Site", + "sitesTableDeleteSiteAndResources": "Delete Site and Resources", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", @@ -204,7 +210,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "publicResourcesBannerTitle": "Web-based Public Access", - "publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts new file mode 100644 index 000000000..b2ffa0f0f --- /dev/null +++ b/server/lib/deleteResource.ts @@ -0,0 +1,144 @@ +import { eq, inArray } from "drizzle-orm"; +import { + db, + newts, + resourcePolicies, + resources, + sites, + targetHealthCheck, + targets, + type Resource, + type Target, + type TargetHealthCheck, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { removeTargets } from "@server/routers/newt/targets"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export type DeleteResourceResult = { + deletedResource: Resource; + targetsToBeRemoved: Target[]; + healthChecksToBeRemoved: TargetHealthCheck[]; +}; + +export async function performDeleteResources( + resourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const targetsToBeRemoved = await trx + .select() + .from(targets) + .where(inArray(targets.resourceId, resourceIds)); + + const targetIds = targetsToBeRemoved.map((t) => t.targetId); + const healthChecksToBeRemoved = + targetIds.length > 0 + ? await trx + .select() + .from(targetHealthCheck) + .where(inArray(targetHealthCheck.targetId, targetIds)) + : []; + + const deletedResources = await trx + .delete(resources) + .where(inArray(resources.resourceId, resourceIds)) + .returning(); + + const policyIds = deletedResources + .map((resource) => resource.defaultResourcePolicyId) + .filter((id): id is number => id != null); + + if (policyIds.length > 0) { + await trx + .delete(resourcePolicies) + .where(inArray(resourcePolicies.resourcePolicyId, policyIds)); + } + + if (deletedResources.length > 0) { + logger.debug(`Deleted ${deletedResources.length} resources`); + } + + const targetsByResourceId = new Map(); + for (const target of targetsToBeRemoved) { + const existing = targetsByResourceId.get(target.resourceId) ?? []; + existing.push(target); + targetsByResourceId.set(target.resourceId, existing); + } + + const targetIdToResourceId = new Map( + targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) + ); + + const healthChecksByResourceId = new Map(); + for (const healthCheck of healthChecksToBeRemoved) { + const resourceId = targetIdToResourceId.get(healthCheck.targetId!); + if (resourceId == null) { + continue; + } + const existing = healthChecksByResourceId.get(resourceId) ?? []; + existing.push(healthCheck); + healthChecksByResourceId.set(resourceId, existing); + } + + return deletedResources.map((deletedResource) => ({ + deletedResource, + targetsToBeRemoved: + targetsByResourceId.get(deletedResource.resourceId) ?? [], + healthChecksToBeRemoved: + healthChecksByResourceId.get(deletedResource.resourceId) ?? [] + })); +} + +export async function performDeleteResource( + resourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [result] = await performDeleteResources([resourceId], trx); + return result ?? null; +} + +export async function runResourceDeleteSideEffects( + result: DeleteResourceResult +): Promise { + const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } = + result; + + for (const target of targetsToBeRemoved) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, target.siteId)) + .limit(1); + + if (!site) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${target.siteId} not found` + ); + } + + if (site.pubKey && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await removeTargets( + newt.newtId, + [], + healthChecksToBeRemoved, + deletedResource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } + } + } +} diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts new file mode 100644 index 000000000..61da82f58 --- /dev/null +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -0,0 +1,126 @@ +import { and, eq, sql } from "drizzle-orm"; +import { + db, + siteNetworks, + siteResources, + targets, + type SiteResource, + type Transaction +} from "@server/db"; +import { + performDeleteResources, + runResourceDeleteSideEffects, + type DeleteResourceResult +} from "@server/lib/deleteResource"; +import { + performDeleteSiteResources, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; +import logger from "@server/logger"; + +export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; + +export type DeleteSiteAssociatedResourcesSideEffects = { + resources: DeleteResourceResult[]; + siteResources: SiteResource[]; +}; + +export async function getResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ resourceId: targets.resourceId }) + .from(targets) + .where(eq(targets.siteId, siteId)); + + return rows.map((row) => row.resourceId); +} + +export async function getSiteResourceIdsForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ siteResourceId: siteResources.siteResourceId }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId)) + ); + + return rows.map((row) => row.siteResourceId); +} + +export async function getAssociatedResourceCountForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const [publicCountResult, privateCountResult] = await Promise.all([ + trx + .select({ + count: sql`count(distinct ${targets.resourceId})` + }) + .from(targets) + .where(eq(targets.siteId, siteId)), + trx + .select({ + count: sql`count(distinct ${siteResources.siteResourceId})` + }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + eq(siteNetworks.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + ]); + + return ( + Number(publicCountResult[0]?.count ?? 0) + + Number(privateCountResult[0]?.count ?? 0) + ); +} + +export function exceedsSiteAssociatedResourceDeleteLimit( + resourceCount: number +): boolean { + return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; +} + +export async function deleteAssociatedResourcesForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getResourceIdsForSite(siteId, trx); + const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); + + const [resources, siteResourcesDeleted] = await Promise.all([ + performDeleteResources(resourceIds, trx), + performDeleteSiteResources(siteResourceIds, trx) + ]); + + return { resources, siteResources: siteResourcesDeleted }; +} + +export async function runDeleteSiteAssociatedResourcesSideEffects( + sideEffects: DeleteSiteAssociatedResourcesSideEffects +): Promise { + for (const result of sideEffects.resources) { + await runResourceDeleteSideEffects(result); + } + + for (const removed of sideEffects.siteResources) { + runSiteResourceDeleteSideEffects(removed); + } +} diff --git a/server/lib/deleteSiteResource.ts b/server/lib/deleteSiteResource.ts new file mode 100644 index 000000000..9db5bd902 --- /dev/null +++ b/server/lib/deleteSiteResource.ts @@ -0,0 +1,53 @@ +import { inArray } from "drizzle-orm"; +import { + db, + siteResources, + type SiteResource, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +export async function performDeleteSiteResources( + siteResourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const removedSiteResources = await trx + .delete(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)) + .returning(); + + if (removedSiteResources.length > 0) { + logger.debug(`Deleted ${removedSiteResources.length} site resources`); + } + + return removedSiteResources; +} + +export async function performDeleteSiteResource( + siteResourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [removedSiteResource] = await performDeleteSiteResources( + [siteResourceId], + trx + ); + return removedSiteResource ?? null; +} + +export function runSiteResourceDeleteSideEffects( + removedSiteResource: SiteResource +): void { + rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`, + err + ); + } + ); +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 030b5f702..be6242f6d 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -55,9 +55,6 @@ export async function verifyOrgAccess( userId, session: req.session }); - logger.debug("failed policy check", { - policyCheck - }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index a959611ec..766b25b04 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,13 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; -import { - db, - newts, - resourcePolicies, - resources, - sites, - targetHealthCheck, - targets -} from "@server/db"; +import { db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { removeTargets } from "../newt/targets"; +import { + performDeleteResource, + runResourceDeleteSideEffects +} from "@server/lib/deleteResource"; -// Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); @@ -67,27 +60,13 @@ export async function deleteResource( const { resourceId } = parsedParams.data; - const targetsToBeRemoved = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); + let deleteResult = null; - const healthChecksToBeRemoved = await db - .select() - .from(targetHealthCheck) - .where( - inArray( - targetHealthCheck.targetId, - targetsToBeRemoved.map((t) => t.targetId) - ) - ); + await db.transaction(async (trx) => { + deleteResult = await performDeleteResource(resourceId, trx); + }); - const [deletedResource] = await db - .delete(resources) - .where(eq(resources.resourceId, resourceId)) - .returning(); - - if (!deletedResource) { + if (!deleteResult) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -96,54 +75,7 @@ export async function deleteResource( ); } - for (const target of targetsToBeRemoved) { - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, target.siteId)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - await removeTargets( - newt.newtId, - // [target], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - healthChecksToBeRemoved, - deletedResource.mode === "udp" ? "udp" : "tcp", - newt.version - ); - } - } - } - - // Also delete default resource policy - if (deletedResource.defaultResourcePolicyId) { - await db - .delete(resourcePolicies) - .where( - eq( - resourcePolicies.resourcePolicyId, - deletedResource.defaultResourcePolicyId - ) - ); - } + await runResourceDeleteSideEffects(deleteResult); return response(res, { data: null, @@ -154,6 +86,9 @@ export async function deleteResource( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 077376211..300c570d8 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -14,18 +14,41 @@ 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 { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; const deleteSiteSchema = z.strictObject({ siteId: z.coerce.number().int().positive() }); +const deleteSiteQuerySchema = z.strictObject({ + deleteResources: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(false) + .openapi({ + type: "boolean", + description: + "When true, also deletes all public and private resources associated with this site" + }) +}); + registry.registerPath({ method: "delete", path: "/site/{siteId}", description: "Delete a site and all its associated data.", tags: [OpenAPITags.Site], request: { - params: deleteSiteSchema + params: deleteSiteSchema, + query: deleteSiteQuerySchema }, responses: { 200: { @@ -61,7 +84,18 @@ export async function deleteSite( ); } + const parsedQuery = deleteSiteQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { siteId } = parsedParams.data; + const { deleteResources } = parsedQuery.data; const [site] = await db .select() @@ -78,20 +112,67 @@ export async function deleteSite( ); } + if (deleteResources) { + const canDeletePublic = await checkUserActionPermission( + ActionsEnum.deleteResource, + req + ); + const canDeletePrivate = await checkUserActionPermission( + ActionsEnum.deleteSiteResource, + req + ); + + if (!canDeletePublic || !canDeletePrivate) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to delete associated resources" + ) + ); + } + + const associatedResourceCount = + await getAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + associatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} resources` + ) + ); + } + } + const [deletedNewt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); + let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = { + resources: [], + siteResources: [] + }; + await db.transaction(async (trx) => { + if (deleteResources) { + resourceSideEffects = await deleteAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + } + if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // Clean up all client associations and send peer/proxy removal - // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); } @@ -99,13 +180,17 @@ export async function deleteSite( await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); - // Send termination message outside of transaction to prevent blocking + if (deleteResources) { + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + if (deletedNewt) { const payload = { type: `newt/wg/terminate`, data: {} }; - // Don't await this to prevent blocking the response sendToClient(deletedNewt.newtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", @@ -123,6 +208,9 @@ export async function deleteSite( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index b9efc5ba8..cddeb490b 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -1,15 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, primaryDb, sites } from "@server/db"; -import { siteResources } from "@server/db"; +import { db, siteResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + performDeleteSiteResource, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -65,11 +67,10 @@ export async function deleteSiteResource( const { siteResourceId } = parsedParams.data; - // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!existingSiteResource) { @@ -78,26 +79,22 @@ export async function deleteSiteResource( ); } - // Delete the site resource - const [removedSiteResource] = await db - .delete(siteResources) - .where(eq(siteResources.siteResourceId, siteResourceId)) - .returning(); + let removedSiteResource = null; - // Run in the background after the response is sent. Wrapped in its - // own transaction so it always executes on the primary — avoiding any - // replica-lag issues while still allowing the HTTP response to return - // early. - rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( - (err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err - ); - } - ); + await db.transaction(async (trx) => { + removedSiteResource = await performDeleteSiteResource( + siteResourceId, + trx + ); + }); - logger.info(`Deleted site resource ${siteResourceId}`); + if (!removedSiteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + runSiteResourceDeleteSideEffects(removedSiteResource); return response(res, { data: { message: "Site resource deleted successfully" }, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5b5ac1db1..3dc7a56da 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; @@ -104,6 +105,7 @@ export default function SitesTable({ } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); @@ -157,10 +159,12 @@ export default function SitesTable({ }); } - function deleteSite(siteId: number) { + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api - .delete(`/site/${siteId}`) + .delete(`/site/${siteId}`, { + params: { deleteResources: withResources } + }) .catch((e) => { console.error(t("siteErrorDelete"), e); toast({ @@ -521,16 +525,33 @@ export default function SitesTable({ )} + { setSelectedSite(siteRow); + setDeleteWithResources(false); setIsDeleteModalOpen(true); }} > - {t("delete")} + {t("sitesTableDeleteSite")} + {siteRow.resourceCount <= 250 && ( + { + setSelectedSite(siteRow); + setDeleteWithResources(true); + setIsDeleteModalOpen(true); + }} + > + + {t( + "sitesTableDeleteSiteAndResources" + )} + + + )} { setIsDeleteModalOpen(val); setSelectedSite(null); + setDeleteWithResources(false); }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

+ {deleteWithResources + ? t("siteQuestionRemoveAndResources") + : t("siteQuestionRemove")} +

+

+ {deleteWithResources + ? t("siteMessageRemoveAndResources") + : t("siteMessageRemove")} +

} - buttonText={t("siteConfirmDelete")} + buttonText={ + deleteWithResources + ? t("siteConfirmDeleteAndResources") + : t("siteConfirmDelete") + } onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) + startTransition(() => + deleteSite(selectedSite!.id, deleteWithResources) + ) } string={selectedSite.name} - title={t("siteDelete")} + title={ + deleteWithResources + ? t("siteDeleteAndResources") + : t("siteDelete") + } /> )} From d0defa380a10ed9e150bee99a8659b237b97ab57 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 17:52:21 -0400 Subject: [PATCH 115/264] remove split by command and space in role form --- messages/en-US.json | 4 ++-- src/components/RoleForm.tsx | 18 +++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2a3379516..83e8a488d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2177,10 +2177,10 @@ "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudo": "Allow sudo", "sshSudoCommands": "Sudo Commands", - "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.", + "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.", "sshCreateHomeDir": "Create Home Directory", "sshUnixGroups": "Unix Groups", - "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.", + "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.", "roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file", "roleTextImportTitle": "Import from File", "roleTextImportDescription": "Importing {fileName} into {fieldLabel}.", diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index ea45817ae..102aa8e3a 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] { if (!value?.trim()) return []; return value - .split(/[,\s\n]+/) + .split(/\r?\n/) .map((group) => group.trim()) .filter(Boolean); } @@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] { export function parseSudoCommands(value: string | undefined): string[] { if (!value?.trim()) return []; - const commands: string[] = []; - for (const segment of value.split(/[,\n]+/)) { - const trimmed = segment.trim(); - if (!trimmed) continue; - - for (const part of trimmed.split(/ (?=\/)/)) { - const command = part.trim(); - if (command) commands.push(command); - } - } - - return commands; + return value + .split(/\r?\n/) + .map((command) => command.trim()) + .filter(Boolean); } function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { From 79de64dc07358238e793382ea69401975f28745e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 16:40:58 -0400 Subject: [PATCH 116/264] Fix removing site not removing peer --- server/lib/rebuildClientAssociations.ts | 93 ++++++++++++++++--------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 72a16efcf..1bc01238c 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -312,14 +312,62 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-siteResource associations /////////// + const existingClientSiteResources = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResource.siteResourceId + ) + ); + + const existingClientSiteResourceIds = existingClientSiteResources.map( + (row) => row.clientId + ); + // get all of the clients associated with other site resources that share // any of the same sites as this site resource (via siteNetworks). We can't // simply filter by networkId since each site resource has its own network; // two site resources serving the same site typically belong to different // networks that both happen to include the site through siteNetworks. const sitesListSiteIds = sitesList.map((s) => s.siteId); + + // We must also consider sites where these clients are currently cached, + // otherwise removing a site from this resource can leave stale + // client-site cache entries behind for the removed site. + const cachedSiteRowsForResourceClients = + existingClientSiteResourceIds.length > 0 + ? await trx + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where( + inArray( + clientSitesAssociationsCache.clientId, + existingClientSiteResourceIds + ) + ) + : []; + + const allCandidateSiteIds = Array.from( + new Set([ + ...sitesListSiteIds, + ...cachedSiteRowsForResourceClients.map((r) => r.siteId) + ]) + ); + + const sitesToProcess = + allCandidateSiteIds.length > 0 + ? await trx + .select() + .from(sites) + .where(inArray(sites.siteId, allCandidateSiteIds)) + : []; + const currentSiteIdSet = new Set(sitesListSiteIds); const allUpdatedClientsFromOtherResourcesOnThisSite = - sitesListSiteIds.length > 0 + allCandidateSiteIds.length > 0 ? await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -339,7 +387,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ) .where( and( - inArray(siteNetworks.siteId, sitesListSiteIds), + inArray(siteNetworks.siteId, allCandidateSiteIds), ne( siteResources.siteResourceId, siteResource.siteResourceId @@ -358,22 +406,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); } - const existingClientSiteResources = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResource.siteResourceId - ) - ); - - const existingClientSiteResourceIds = existingClientSiteResources.map( - (row) => row.clientId - ); - logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` ); @@ -456,10 +488,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-site associations /////////// logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesToProcess.length} site(s) (current=${sitesList.length})` ); - for (const site of sitesList) { + for (const site of sitesToProcess) { const siteId = site.siteId; logger.debug( @@ -501,7 +533,13 @@ async function rebuildClientAssociationsFromSiteResourceImpl( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` ); - const clientSitesToAdd = mergedAllClientIds.filter( + // Expected clients from this resource are site-scoped: if this site is + // no longer attached to the resource, the expected set is empty. + const expectedClientIdsForSite = currentSiteIdSet.has(siteId) + ? mergedAllClientIds + : []; + + const clientSitesToAdd = expectedClientIdsForSite.filter( (clientId) => !existingClientSiteIds.includes(clientId) && !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource @@ -536,7 +574,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( // Now remove any client-site associations that should no longer exist const clientSitesToRemove = existingClientSiteIds.filter( (clientId) => - !mergedAllClientIds.includes(clientId) && + !expectedClientIdsForSite.includes(clientId) && !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource ); @@ -1211,7 +1249,6 @@ export async function handleMessagingForUpdatedSiteResource( ); const oldDestinationStillInUseClientSitePairs = new Set(); - const oldAliasStillInUseClientSitePairs = new Set(); if ( existingSiteResource?.destination && allSiteIds.length > 0 && @@ -1316,11 +1353,6 @@ export async function handleMessagingForUpdatedSiteResource( `${client.clientId}:${siteId}` ); - if (oldDestinationStillInUseByASite && allSiteIds.length > 0) { - // nothing in the message anyway lets just continue - continue; - } - peerDataRemoves.push({ // this might happen twice after the rebuild function but that is okay clientId: client.clientId, @@ -1328,10 +1360,7 @@ export async function handleMessagingForUpdatedSiteResource( remoteSubnets: !oldDestinationStillInUseByASite ? generateRemoteSubnets([updatedSiteResource]) : [], - aliases: - allSiteIds.length == 0 - ? generateAliasConfig([updatedSiteResource]) - : [] + aliases: generateAliasConfig([updatedSiteResource]) }); } } From be3877a3ce309100f26d495c481a725eea5d56cd Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 18:35:42 -0400 Subject: [PATCH 117/264] Rename for clarity --- server/lib/rebuildClientAssociations.ts | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 1bc01238c..1f675e81e 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -35,6 +35,7 @@ import { parseEndpoint } from "@server/lib/ip"; import { + addPeerData, addPeerDataBatch, addTargetsBatch as addSubnetProxyTargetsBatch, removePeerDataBatch, @@ -1258,6 +1259,7 @@ export async function handleMessagingForUpdatedSiteResource( `handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}` ); + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource const oldDestinationStillInUseRows = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -1348,20 +1350,23 @@ export async function handleMessagingForUpdatedSiteResource( version: newt.version }); for (const client of mergedAllClients) { - const oldDestinationStillInUseByASite = + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); - peerDataRemoves.push({ - // this might happen twice after the rebuild function but that is okay - clientId: client.clientId, - siteId, - remoteSubnets: !oldDestinationStillInUseByASite - ? generateRemoteSubnets([updatedSiteResource]) - : [], - aliases: generateAliasConfig([updatedSiteResource]) - }); + if (existingSiteResource) { + peerDataRemoves.push({ + // this might happen twice after the rebuild function but that is okay + clientId: client.clientId, + siteId, + remoteSubnets: !oldDestinationStillInUseBySite + ? generateRemoteSubnets([existingSiteResource]) + : [], + aliases: generateAliasConfig([existingSiteResource]) + }); + } } } } else { @@ -1577,7 +1582,8 @@ export async function handleMessagingForUpdatedSiteResource( continue; } - const oldDestinationStillInUseByASite = + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); @@ -1588,7 +1594,7 @@ export async function handleMessagingForUpdatedSiteResource( siteId, remoteSubnets: destinationChanged ? { - oldRemoteSubnets: !oldDestinationStillInUseByASite + oldRemoteSubnets: !oldDestinationStillInUseBySite ? generateRemoteSubnets([ existingSiteResource ]) @@ -2363,7 +2369,7 @@ async function handleMessagesForClientResources( ) ); - // Only remove remote subnet if no other resource uses the same destination + // Only remove remote subnet if no other resource uses the same destination on the same site const remoteSubnetsToRemove = destinationStillInUse.length > 0 ? [] From 877985deb3605674e8852c21b39733f252193226 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 18:35:52 -0400 Subject: [PATCH 118/264] Spellcheck --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5092cb6c1..a7da2a1cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,8 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "cSpell.words": [ + "nessicary" + ] } \ No newline at end of file From f8591f27c58ddc1916113eb80dae17e8975834c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 25 Jun 2026 10:08:37 -0400 Subject: [PATCH 119/264] Fix no data when last data was over 90 days ago --- server/lib/statusHistory.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 8bb7e6a0c..7c5b5c370 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -42,7 +42,29 @@ export async function getCachedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); - const { buckets, totalDowntime } = computeBuckets(events, days); + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -110,7 +132,8 @@ export function computeBuckets( timestamp: number; id: number; }[], - days: number + days: number, + priorStatus: string | null = null ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); @@ -136,7 +159,10 @@ export function computeBuckets( .filter((e) => e.timestamp < dayStartSec) .at(-1); - const currentStatus = lastBeforeDay?.status ?? null; + // Fall back to the last known state before the entire query window + // so that entities that haven't generated events recently still show + // as their actual status rather than "no_data". + const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null; const windows: { start: number; end: number | null; status: string }[] = []; From 0f02d1bc0269cba43167abc0fbaedf34e807dc81 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 25 Jun 2026 18:01:25 +0200 Subject: [PATCH 120/264] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20deprecate?= =?UTF-8?q?d=20ISO=20`CS`=20country=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/countries.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/db/countries.ts b/server/db/countries.ts index 749f1183f..c668ca2ae 100644 --- a/server/db/countries.ts +++ b/server/db/countries.ts @@ -795,10 +795,13 @@ export const COUNTRIES = [ name: "Serbia", code: "RS" }, - { - name: "Serbia and Montenegro", - code: "CS" - }, + // Removed as this is a deprecated ISO country code, not supported anymore + // Also the individual flags for Serbia & Montenegro are already included in the list + // more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS + // { + // name: "Serbia and Montenegro", + // code: "CS" + // }, { name: "Seychelles", code: "SC" From 4b1b3d3d5bc71f29cee114d18b73fc9746df6424 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:17 -0700 Subject: [PATCH 121/264] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 78ed0faa8..14a4a5365 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifier", "siteConfirmDelete": "Confirmer la suppression du nœud", + "siteConfirmDeleteAndResources": "Confirmer la suppression du site et des ressources", "siteDelete": "Supprimer le nœud", + "siteDeleteAndResources": "Supprimer le site et les ressources", "siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.", + "siteMessageRemoveAndResources": "Cela supprimera définitivement toutes les ressources publiques et privées liées à ce site, même si une ressource est également associée à d'autres sites.", "siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?", + "siteQuestionRemoveAndResources": "Êtes-vous sûr de vouloir supprimer ce site et toutes les ressources associées?", + "sitesTableDeleteSite": "Supprimer le site", + "sitesTableDeleteSiteAndResources": "Supprimer le site et les ressources", "siteManageSites": "Gérer les nœuds", "siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés", "sitesBannerTitle": "Se connecter à n'importe quel réseau", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", + "internalResourceAliasLocalWarning": "Les alias se terminant par .local peuvent causer des problèmes de résolution dus au mDNS sur certains réseaux.", "internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP", "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "siteConfiguration": "Configuration", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", "loadingDNSRecords": "Chargement des enregistrements DNS...", "olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Une version mise à jour est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "client": "Client", "proxyProtocol": "Paramètres du protocole proxy", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", From 60339706bb171cc807daa8f7b4732c3e1ee27178 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:19 -0700 Subject: [PATCH 122/264] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index ec22f9b0e..7895b15e5 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Editar", "siteConfirmDelete": "Confirmar Borrar Sitio", + "siteConfirmDeleteAndResources": "Confirmar eliminación del sitio y recursos", "siteDelete": "Eliminar sitio", + "siteDeleteAndResources": "Eliminar sitio y recursos", "siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.", + "siteMessageRemoveAndResources": "Esto eliminará permanentemente todos los recursos públicos y privados vinculados a este sitio, incluso si un recurso también está asociado con otros sitios.", "siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?", + "siteQuestionRemoveAndResources": "¿Está seguro de que desea eliminar este sitio y todos los recursos asociados?", + "sitesTableDeleteSite": "Eliminar sitio", + "sitesTableDeleteSiteAndResources": "Eliminar sitio y recursos", "siteManageSites": "Administrar Sitios", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", "sitesBannerTitle": "Conectar cualquier red", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", + "internalResourceAliasLocalWarning": "Los alias que terminan en .local pueden causar problemas de resolución debido a mDNS en algunas redes.", "internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP", "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "siteConfiguration": "Configuración", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Falta el ID de organización o dominio", "loadingDNSRecords": "Cargando registros DNS...", "olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Hay una versión actualizada disponible. Actualice a la última versión para obtener la mejor experiencia.", "client": "Cliente", "proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", From dd26518d6f1453cfd284f3d3c85dd50c52c897e8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:21 -0700 Subject: [PATCH 123/264] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 2e0aea8ca..6cefe4a4d 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -66,9 +66,15 @@ "local": "Локална", "edit": "Редактиране", "siteConfirmDelete": "Потвърждение на изтриване на сайта", + "siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите", "siteDelete": "Изтриване на сайта", + "siteDeleteAndResources": "Изтриване на сайта и ресурсите", "siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.", + "siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.", "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", + "siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?", + "sitesTableDeleteSite": "Изтриване на сайта", + "sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите", "siteManageSites": "Управление на сайтове", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", "sitesBannerTitle": "Свържете се с мрежа.", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", + "internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.", "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "siteConfiguration": "Конфигурация", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", From 88a9b92dc3332edcf0e316e5298fbf9e251c6674 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:23 -0700 Subject: [PATCH 124/264] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index d81927977..9a175093e 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -66,9 +66,15 @@ "local": "Místní", "edit": "Upravit", "siteConfirmDelete": "Potvrdit odstranění lokality", + "siteConfirmDeleteAndResources": "Potvrdit odstranění lokality a zdrojů", "siteDelete": "Odstranění lokality", + "siteDeleteAndResources": "Odstranit lokalitu a zdroje", "siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.", + "siteMessageRemoveAndResources": "Toto trvale odstraní všechny veřejné a soukromé zdroje spojené s touto lokalitou, i když je zdroj také přiřazen k jiným lokalitám.", "siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?", + "siteQuestionRemoveAndResources": "Opravdu chcete odstranit tuto lokalitu a všechny přidružené zdroje?", + "sitesTableDeleteSite": "Odstranění lokality", + "sitesTableDeleteSiteAndResources": "Odstranit lokalitu a zdroje", "siteManageSites": "Správa lokalit", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", "sitesBannerTitle": "Připojit jakoukoli síť", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", + "internalResourceAliasLocalWarning": "Aliasy končící na .local mohou způsobit problémy s vyřešením díky mDNS v některých sítích.", "internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje", "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "siteConfiguration": "Konfigurace", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Chybí ID organizace nebo domény", "loadingDNSRecords": "Načítání DNS záznamů...", "olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Je k dispozici aktualizovaná verze. Aktualizujte prosím na nejnovější verzi pro nejlepší zážitek.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", From 36460d4cc05d463425495f3d35930296b9e15bec Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:25 -0700 Subject: [PATCH 125/264] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 102 ++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index cdf209ed0..b9db7f323 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -58,7 +58,7 @@ "name": "Navn", "online": "Online", "offline": "Offline", - "site": "Site", + "site": "Websted", "dataIn": "Data ind", "dataOut": "Data ud", "connectionType": "Forbindelsestype", @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekræft Sletning af Site", + "siteConfirmDeleteAndResources": "Bekræft sletning af sted og ressourcer", "siteDelete": "Slet Site", + "siteDeleteAndResources": "Slet Sted og Ressourcer", "siteMessageRemove": "Når sitet er fjernet, vil det ikke længere være tilgængeligt. Alle mål for sitet vil også blive fjernet.", + "siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressourcer knyttet til dette sted, selv hvis en ressource også er knyttet til andre steder.", "siteQuestionRemove": "Er du sikker på at du vil fjerne sitet fra organisationen?", + "siteQuestionRemoveAndResources": "Er du sikker på, at du vil slette dette sted og alle tilknyttede ressourcer?", + "sitesTableDeleteSite": "Slet sted", + "sitesTableDeleteSiteAndResources": "Slet sted og ressourcer", "siteManageSites": "Administrer Sites", "siteDescription": "Oprette og administrer sites for at aktivere forbindelse til private netværk", "sitesBannerTitle": "Opret forbindelse til alle netværk", @@ -711,7 +717,7 @@ "proxyUpdatedDescription": "Proxy indstillinger er blevet opdateret", "proxyErrorUpdate": "En fejl opstod under opdatering af proxyindstillinger", "proxyErrorUpdateDescription": "En fejl opstod under opdatering af proxyindstillinger", - "targetAddr": "Host", + "targetAddr": "Vært", "targetPort": "Port", "targetProtocol": "Protokol", "targetTlsSettings": "Sikker forbindelseskonfiguration", @@ -902,7 +908,7 @@ "newtSecretKey": "Sikkerhedsnøgle", "newtVersion": "Version", "architecture": "Arkitektur", - "sites": "Sites", + "sites": "Websteder", "siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet", @@ -1059,7 +1065,7 @@ "network": "Netværk", "manage": "Administrer", "sitesNotFound": "Ingen sites fundet.", - "pangolinServerAdmin": "Server Admin - Pangolin", + "pangolinServerAdmin": "Serveradmin - Pangolin", "licenseTierProfessional": "Professionel licens", "licenseTierEnterprise": "Enterprise-licens", "licenseTierPersonal": "Personlig licens", @@ -1186,7 +1192,7 @@ "emailVerifyResend": "Har du ikke modtaget en kode? Klik her for at sende igen", "passwordNotMatch": "Adgangskoderne matcher ikke", "signupError": "Det opstod en fejl ved registrering", - "pangolinLogoAlt": "Pangolin Logo", + "pangolinLogoAlt": "Pangolin-logo", "inviteAlready": "Ser ud til at du er blevet inviteret!", "inviteAlreadyDescription": "For at acceptere invitationen skal du logge ind eller oprette en konto.", "signupQuestion": "Har du allerede en konto?", @@ -1533,7 +1539,7 @@ "apiKeysErrorNoUpdate": "Ingen API-nøgle at opdatere", "sidebarOverview": "Oversigt", "sidebarHome": "Hjem", - "sidebarSites": "Sites", + "sidebarSites": "Websteder", "sidebarApprovals": "Godkendelsesanmodninger", "sidebarResources": "Ressourcer", "sidebarProxyResources": "Offentlig", @@ -1573,7 +1579,7 @@ "alertingSearchRules": "Søg i regler…", "alertingAddRule": "Opret regel", "alertingColumnSource": "Kilde", - "alertingColumnTrigger": "Trigger", + "alertingColumnTrigger": "Udløser", "alertingColumnActions": "Handlinger", "alertingColumnEnabled": "Aktiveret", "alertingDeleteQuestion": "Bekræft venligst, at du vil slette denne varslingsregel.", @@ -1589,9 +1595,9 @@ "alertingRuleEnabled": "Regel aktiveret", "alertingSectionSource": "Kilde", "alertingSourceType": "Kildetype", - "alertingSourceSite": "Site", + "alertingSourceSite": "Sted", "alertingSourceHealthCheck": "Sundhedstjek", - "alertingPickSites": "Sites", + "alertingPickSites": "Steder", "alertingPickHealthChecks": "Sundhedstjek", "alertingPickResources": "Ressourcer", "alertingAllSites": "Alle sites", @@ -1609,7 +1615,7 @@ "alertingSelectResources": "Vælg ressourcer…", "alertingResourcesSelected": "{count} ressourcer valgt", "alertingResourcesEmpty": "Ingen ressourcer med mål i de første 10 resultaterne.", - "alertingSectionTrigger": "Trigger", + "alertingSectionTrigger": "Udløser", "alertingTrigger": "Når skal det varsles", "alertingTriggerSiteOnline": "Site er online", "alertingTriggerSiteOffline": "Site er offline", @@ -1643,7 +1649,7 @@ "alertingWebhookMethod": "HTTP-metode", "alertingWebhookSecret": "Signeringshemmelighed (valgfrit)", "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", - "alertingWebhookHeaders": "Headers", + "alertingWebhookHeaders": "Overskrifter", "alertingAddHeader": "Tilføj header", "alertingSelectSites": "Vælg sites…", "alertingSitesSelected": "{count} sites valgt", @@ -1655,7 +1661,7 @@ "alertingUsersSelected": "{count} brugere valgt", "alertingSelectRoles": "Vælg roller…", "alertingRolesSelected": "{count} roller valgt", - "alertingSummarySites": "Sites ({count})", + "alertingSummarySites": "Websteder ({count})", "alertingSummaryAllSites": "Alle sites", "alertingSummaryHealthChecks": "Sundhedstjek ({count})", "alertingSummaryAllHealthChecks": "Alle sundhedstjek", @@ -1683,7 +1689,7 @@ "alertingNodeNotConfigured": "Ikke konfigureret endnu", "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", "alertingNodeRoleSource": "Kilde", - "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleTrigger": "Udløser", "alertingNodeRoleAction": "Handling", "alertingTabRules": "Varslingsregler", "alertingTabHealthChecks": "Sundhedstjek", @@ -1720,7 +1726,7 @@ "standaloneHcFilterEnabled": "Aktiveret", "standaloneHcFilterEnabledOn": "Aktiveret", "standaloneHcFilterEnabledOff": "Deaktiveret", - "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterSiteIdFallback": "Sted {id}", "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blueprints", "blueprintsLog": "Blueprint-log", @@ -1893,7 +1899,7 @@ "billingUsageLimitsOverview": "Oversikt over forbrugsgrænser", "billingMonitorUsage": "Overvåg forbruget din i forhold til konfigurerte grænse. Hvis du behøver økte grænse, venligst kontakt support@pangolin.net.", "billingDataUsage": "Dataforbrug", - "billingSites": "Sites", + "billingSites": "Websteder", "billingUsers": "Brugere", "billingDomains": "Domæner", "billingOrganizations": "Orger", @@ -2112,7 +2118,7 @@ "healthSelectScheme": "Vælg metode", "healthCheckPortInvalid": "Porten skal være mellem 1 og 65535", "healthCheckPath": "Sti", - "healthHostname": "IP / Host", + "healthHostname": "IP / Vært", "healthPort": "Port", "healthCheckPathDescription": "Stien for at tjekke helsestatus.", "healthyIntervalSeconds": "Sund intervall (sek)", @@ -2165,7 +2171,7 @@ "sshSudoMode": "Sudo adgang", "sshSudoModeNone": "Ingen", "sshSudoModeNoneDescription": "Brugeren kan ikke køre kommandoer med sudo.", - "sshSudoModeFull": "Full Sudo", + "sshSudoModeFull": "Fuld Sudo", "sshSudoModeFullDescription": "Brugeren kan køre hvilken som helst kommando med sudo.", "sshSudoModeCommands": "Kommandoer", "sshSudoModeCommandsDescription": "Brugeren kan kun køre de angitte kommandoene med sudo.", @@ -2185,7 +2191,7 @@ "roleTextImportPreview": "Forhåndsvisning", "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer at importere} one {ét element at importere} other {# elementer at importere}}", "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", - "roleTextImportConfirm": "Import", + "roleTextImportConfirm": "Importer", "roleTextImportInvalidFile": "Ustøttet filtype", "roleTextImportInvalidFileDescription": "Kun.txt og.csv filer er støttet.", "roleTextImportEmpty": "Ingen elementer fundet i filen", @@ -2218,7 +2224,7 @@ "httpMethod": "HTTP-metode", "selectHttpMethod": "Vælg HTTP-metode", "domainPickerSubdomainLabel": "Underdomæne", - "domainPickerWildcard": "Wildcard", + "domainPickerWildcard": "Jokertegn", "domainPickerWildcardPaidOnly": "Wildcard-underdomæner er en betalt funktion. Opgrader venligst for at få adgang til denne funktion.", "domainPickerBaseDomainLabel": "Basisdomæne", "domainPickerSearchDomains": "Søg i domæner...", @@ -2274,7 +2280,7 @@ "editInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "editInternalResourceDialogMode": "Tilstand", "editInternalResourceDialogModePort": "Port", - "editInternalResourceDialogModeHost": "Host", + "editInternalResourceDialogModeHost": "Vært", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", @@ -2295,7 +2301,7 @@ "createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen", "createInternalResourceDialogResourceProperties": "Ressourceegenskaber", "createInternalResourceDialogName": "Navn", - "createInternalResourceDialogSite": "Site", + "createInternalResourceDialogSite": "Websted", "selectSite": "Vælg site...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "noSitesFound": "Ingen sites fundet.", @@ -2324,7 +2330,7 @@ "createInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "createInternalResourceDialogMode": "Tilstand", "createInternalResourceDialogModePort": "Port", - "createInternalResourceDialogModeHost": "Host", + "createInternalResourceDialogModeHost": "Vært", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-området til ressourcen på sitets netværk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", + "internalResourceAliasLocalWarning": "Aliasser, der ender på .local, kan forårsage opløsningsproblemer på grund af mDNS på nogle netværk.", "internalResourceDownstreamSchemeRequired": "Skema er påkrævet for HTTP-ressourcer", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressourcer", "siteConfiguration": "Konfiguration", @@ -2435,7 +2442,7 @@ "regionAfrica": "Afrika", "regionNorthernAfrica": "[country name] Nord-Afrika", "regionEasternAfrica": "Øst-Afrika", - "regionMiddleAfrica": "Middle Africa", + "regionMiddleAfrica": "Mellemafrika", "regionSouthernAfrica": "Sør-Afrika", "regionWesternAfrica": "[country name] Vest-Afrika", "regionAmericas": "Amerika", @@ -2443,7 +2450,7 @@ "regionCentralAmerica": "Sentral-Amerika", "regionSouthAmerica": "Sør-Amerika", "regionNorthernAmerica": "Nord-Amerika", - "regionAsia": "Asia", + "regionAsia": "Asien", "regionCentralAsia": "Sentral-Asia", "regionEasternAsia": "Øst-Asia", "regionSouthEasternAsia": "Sørøst-Asia", @@ -2454,11 +2461,11 @@ "regionNorthernEurope": "Nord-Europa", "regionSouthernEurope": "Sydeuropa", "regionWesternEurope": "Vest-Europa", - "regionOceania": "Oceania", + "regionOceania": "Oceanien", "regionAustraliaAndNewZealand": "Australia og New Zealand", - "regionMelanesia": "Melanesia", - "regionMicronesia": "Micronesia", - "regionPolynesia": "Polynesia", + "regionMelanesia": "Melanesien", + "regionMicronesia": "Mikronesien", + "regionPolynesia": "Polynesien", "managedSelfHosted": { "title": "Administreret selvhostet", "description": "Sikre, selvhostede Pangolin-servere med lav vedligeholdelse og ekstra bells and whistles", @@ -2528,12 +2535,12 @@ "roleMappingRemoveRule": "Fjern", "idpGoogleConfiguration": "Google Konfiguration", "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimationsoplysningerne", - "idpGoogleClientIdDescription": "Google OAuth2 Client ID", + "idpGoogleClientIdDescription": "Google OAuth2-klient-ID", "idpGoogleClientSecretDescription": "Google OAuth2-klienten hemmelighed", "idpAzureConfiguration": "Azure Entra ID konfiguration", "idpAzureConfigurationDescription": "Konfigurer Azure Entra ID OAuth2 legitimationsoplysninger", "idpTenantId": "Leietaker-ID", - "idpTenantIdPlaceholder": "tenant-id", + "idpTenantIdPlaceholder": "lejer-id", "idpAzureTenantIdDescription": "Azure leant ID (fundet i Azure Active Directory-oversikten)", "idpAzureClientIdDescription": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription": "Azure App Registrering Klient Hemmelig", @@ -2547,7 +2554,7 @@ "idpAzureClientIdDescription2": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", "idpGoogleDescription": "Google OAuth2/OIDC udbyder", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "subnet": "Subnet", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "customDomain": "Brugerdefineret domæne", @@ -2698,7 +2705,7 @@ "resourceHeaderAuthSetupTitleDescription": "Angiv grunnleggende auth legitimationsoplysninger (brugernavn og adgangskode) for at beskytte denne ressource med HTTP Header autentificering. Adgang til det ved hjælp af formatet HTTPS://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Angiv header-godkendelse", "actionSetResourceHeaderAuth": "Angiv header-godkendelse", - "enterpriseEdition": "Enterprise Edition", + "enterpriseEdition": "Enterprise-udgave", "unlicensed": "Ikke licenseret", "beta": "beta", "manageUserDevices": "Bruger Enheder", @@ -2877,10 +2884,10 @@ "toConfirm": "at bekræfte.", "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra sitet og organisationen?", "clientMessageRemove": "Når klienten er fjernet, kan den ikke længere oprette forbindelse til sitet.", - "sidebarLogs": "Logs", + "sidebarLogs": "Logfiler", "request": "Forespørgsel", "requests": "Forespørgsler", - "logs": "Logs", + "logs": "Logfiler", "logsSettingsDescription": "Overvåg logs samlet ind fra denne organisation", "searchLogs": "Søg i logs...", "action": "Handling", @@ -2894,11 +2901,11 @@ "allowedByRule": "Tilladt efter regel", "allowedNoAuth": "Tilladt Ingen Auth", "validAccessToken": "Gyldig adgangsnøgle", - "validHeaderAuth": "Valid header auth", + "validHeaderAuth": "Gyldig header-auth", "validPincode": "Valid PIN-kode", "validPassword": "Gyldig adgangskode", - "validEmail": "Valid email", - "validSSO": "Valid SSO", + "validEmail": "Gyldig e-mail", + "validSSO": "Gyldig SSO", "view": "Vis", "configManaged": "Konfiguration administrert", "connectedClient": "Tilsluttet klient", @@ -2906,12 +2913,12 @@ "droppedByRule": "Legg i reglen", "noSessions": "Ingen økter", "temporaryRequestToken": "Midlertidig forespørgsel Token", - "noMoreAuthMethods": "No Valid Auth", + "noMoreAuthMethods": "Ingen gyldig auth", "ip": "IP", "reason": "Grund", "requestLogs": "HTTP-forespørgselslogs", "requestAnalytics": "Be om analyser", - "host": "Host", + "host": "Vært", "location": "Sted", "actionLogs": "Handlingsloger", "sidebarLogsRequest": "HTTP-forespørgselslogs", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "ID for organisation eller domæne mangler", "loadingDNSRecords": "Indlæser DNS-poster...", "olmUpdateAvailableInfo": "En opdateret version af Olm er tilgængeligt. Opdater til den nyeste version for at få den bedste oplevelse.", + "updateAvailableInfo": "En opdateret version er tilgængelig. Opdater til den nyeste version for at få den bedste oplevelse.", "client": "Klient", "proxyProtocol": "Protokol indstillinger for Protokol", "proxyProtocolDescription": "Konfigurer Proxy-protokol for at bevare klientens IP-adresser til TCP-tjenester.", @@ -3082,7 +3090,7 @@ "niceIdUpdateErrorDescription": "Det opstod en fejl under opdatering af Nice ID.", "niceIdCannotBeEmpty": "God ID kan ikke være tom", "enterIdentifier": "Angiv identifikator", - "identifier": "Identifier", + "identifier": "Identifikator", "deviceLoginUseDifferentAccount": "Ikke du? Brug en anden konto.", "deviceLoginDeviceRequestingAccessToAccount": "En enhed ber om adgang til denne kontoen.", "loginSelectAuthenticationMethod": "Vælg en autentificeringsmetode for at fortsætte.", @@ -3260,13 +3268,13 @@ "platform": "Platform", "macosVersion": "macOS version", "windowsVersion": "Windows version", - "iosVersion": "iOS Version", + "iosVersion": "iOS-version", "androidVersion": "Android version", "osVersion": "OS version", "kernelVersion": "Kjerne version", "deviceModel": "Enhedsmodel", "serialNumber": "Serienummer", - "hostname": "Hostname", + "hostname": "Hostnavn", "firstSeen": "Først sett", "lastSeen": "Sist sett", "biometricsEnabled": "Biometri aktiveret", @@ -3366,9 +3374,9 @@ "datadogDestEditDescription": "Opdater konfigurationen for denne Datadog-hændelsesstreamingdestination.", "datadogDestAddDescription": "Konfigurer et nyt Datadog-endpoint til at modtage organisationens hændelser.", "httpDestTabSettings": "Indstillinger", - "httpDestTabHeaders": "Headers", + "httpDestTabHeaders": "Overskrifter", "httpDestTabBody": "Indhold", - "httpDestTabLogs": "Logs", + "httpDestTabLogs": "Logfiler", "httpDestNamePlaceholder": "Min HTTP destination", "httpDestUrlLabel": "Destinasjons URL", "httpDestUrlErrorHttpRequired": "URL-adressen skal bruge httpp eller HTTPS", @@ -3454,7 +3462,7 @@ "idpUnassociatedDescription": "Identitetsudbyderen er frakoblet fra denne organisation", "idpUnassociateMenu": "Frakobl", "idpDeleteAllOrgsMenu": "Slet", - "publicIpEndpoint": "Endpoint", + "publicIpEndpoint": "Slutpunkt", "lastTriggeredAt": "Senest udløst", "reject": "Afvis", "uptimeDaysAgo": "{count} dage siden", @@ -3587,7 +3595,7 @@ "rdpNoConnectionTarget": "Intet forbindelsesmål tilgængeligt", "rdpConnectionFailed": "Forbindelsen mislykkedes", "rdpFit": "Tilpass", - "rdpFull": "Full", + "rdpFull": "Fuldt", "rdpReal": "Ekte", "rdpMeta": "Meta", "rdpUploadFiles": "Upload filer", @@ -3597,4 +3605,4 @@ "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "sessionToolbarShow": "Vis værktøjslinje", "sessionToolbarHide": "Skjul værktøjslinje" -} \ No newline at end of file +} From 56187d61d531cfe706bc9155c1bc4351b8e89464 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:27 -0700 Subject: [PATCH 126/264] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 4e4ac9161..38e8fafc4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Bearbeiten", "siteConfirmDelete": "Löschen des Standorts bestätigen", + "siteConfirmDeleteAndResources": "Löschen von Standort und Ressourcen bestätigen", "siteDelete": "Standort löschen", + "siteDeleteAndResources": "Standort und Ressourcen löschen", "siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", + "siteMessageRemoveAndResources": "Dies wird dauerhaft alle öffentlichen und privaten Ressourcen, die mit diesem Standort verknüpft sind, löschen, selbst wenn eine Ressource auch mit anderen Standorten verbunden ist.", "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", + "siteQuestionRemoveAndResources": "Sind Sie sicher, dass Sie diesen Standort und alle zugehörigen Ressourcen löschen möchten?", + "sitesTableDeleteSite": "Standort löschen", + "sitesTableDeleteSiteAndResources": "Standort und Ressourcen löschen", "siteManageSites": "Standorte verwalten", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", + "internalResourceAliasLocalWarning": "Aliasse, die auf .local enden, können aufgrund von mDNS in einigen Netzwerken zu Auflösungsproblemen führen.", "internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich", "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "siteConfiguration": "Konfiguration", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "loadingDNSRecords": "Lade DNS-Einträge...", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Eine aktualisierte Version ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", From 7e4dea918aaa28e361e1609447263282f41ce4ec Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:29 -0700 Subject: [PATCH 127/264] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 2a6a65ac3..74066721b 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifica", "siteConfirmDelete": "Conferma Eliminazione Sito", + "siteConfirmDeleteAndResources": "Conferma Eliminazione Sito e Risorse", "siteDelete": "Elimina Sito", + "siteDeleteAndResources": "Elimina Sito e Risorse", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.", + "siteMessageRemoveAndResources": "Questo eliminerà permanentemente tutte le risorse pubbliche e private collegate a questo sito, anche se una risorsa è anche associata ad altri siti.", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", + "siteQuestionRemoveAndResources": "Sei sicuro di voler eliminare questo sito e tutte le risorse associate?", + "sitesTableDeleteSite": "Elimina Sito", + "sitesTableDeleteSiteAndResources": "Elimina Sito e Risorse", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "sitesBannerTitle": "Connetti Qualsiasi Rete", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", + "internalResourceAliasLocalWarning": "Gli alias che terminano in .local possono causare problemi di risoluzione a causa di mDNS su alcune reti.", "internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP", "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "siteConfiguration": "Configurazione", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", "loadingDNSRecords": "Caricamento record DNS...", "olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "È disponibile una versione aggiornata. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "client": "Client", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", From fee635b8611ad05c1d4a231a6a3d9c319eac1a98 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:31 -0700 Subject: [PATCH 128/264] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d57050693..a53e76d18 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -66,9 +66,15 @@ "local": "로컬", "edit": "편집", "siteConfirmDelete": "사이트 삭제 확인", + "siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인", "siteDelete": "사이트 삭제", + "siteDeleteAndResources": "사이트 및 리소스 삭제", "siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.", + "siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.", "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", + "siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?", + "sitesTableDeleteSite": "사이트 삭제", + "sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제", "siteManageSites": "사이트 관리", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", "sitesBannerTitle": "모든 네트워크 연결", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", + "internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.", "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "siteConfiguration": "설정", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", From 3c13b1ea1550936151305b67504af06d76f070c2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:33 -0700 Subject: [PATCH 129/264] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index bdb22ef52..a483c147e 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -66,9 +66,15 @@ "local": "Lokaal", "edit": "Bewerken", "siteConfirmDelete": "Verwijderen van site bevestigen", + "siteConfirmDeleteAndResources": "Bevestig Verwijderen van Site en Bronnen", "siteDelete": "Site verwijderen", + "siteDeleteAndResources": "Site en Bronnen verwijderen", "siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.", + "siteMessageRemoveAndResources": "Dit zal permanent alle publieke en private resources gekoppeld aan deze site verwijderen, zelfs als een resource ook aan andere sites is gekoppeld.", "siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?", + "siteQuestionRemoveAndResources": "Weet u zeker dat u deze site en alle gekoppelde resources wilt verwijderen?", + "sitesTableDeleteSite": "Site verwijderen", + "sitesTableDeleteSiteAndResources": "Site en Bronnen verwijderen", "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", + "internalResourceAliasLocalWarning": "Aliassen die eindigen op .local kunnen resolutieproblemen veroorzaken vanwege mDNS op sommige netwerken.", "internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen", "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "siteConfiguration": "Configuratie", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", "loadingDNSRecords": "DNS-records laden...", "olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Er is een bijgewerkte versie beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "client": "Klant", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", From 822a07d48e6b1003884b87ab6c65ae93f97a1f18 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:35 -0700 Subject: [PATCH 130/264] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1d553fa3b..bfe29df7d 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -66,9 +66,15 @@ "local": "Lokalny", "edit": "Edytuj", "siteConfirmDelete": "Potwierdź usunięcie witryny", + "siteConfirmDeleteAndResources": "Potwierdź usunięcie witryny i zasobów", "siteDelete": "Usuń witrynę", + "siteDeleteAndResources": "Usuń witrynę i zasoby", "siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.", + "siteMessageRemoveAndResources": "To spowoduje trwałe usunięcie wszystkich zasobów publicznych i prywatnych powiązanych z tą witryną, nawet jeśli zasób jest także powiązany z innymi witrynami.", "siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?", + "siteQuestionRemoveAndResources": "Czy na pewno chcesz usunąć tę witrynę i wszystkie powiązane zasoby?", + "sitesTableDeleteSite": "Usuń witrynę", + "sitesTableDeleteSiteAndResources": "Usuń witrynę i zasoby", "siteManageSites": "Zarządzaj stronami", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", "sitesBannerTitle": "Połącz dowolną sieć", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", + "internalResourceAliasLocalWarning": "Alias kończący się na .local może powodować problemy z rozpoznawaniem z powodu mDNS w niektórych sieciach.", "internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP", "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "siteConfiguration": "Konfiguracja", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", "loadingDNSRecords": "Ładowanie rekordów DNS...", "olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Dostępna jest zaktualizowana wersja. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze wrażenia z użytkowania.", "client": "Klient", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", From 66b1b385a342ff05d82db7e85efa22645d7aa0cd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:37 -0700 Subject: [PATCH 131/264] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 345a21b24..ff0d543fe 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -66,9 +66,15 @@ "local": "Localização", "edit": "Alterar", "siteConfirmDelete": "Confirmar que pretende apagar o site", + "siteConfirmDeleteAndResources": "Confirmar Exclusão do Site e Recursos", "siteDelete": "Excluir site", + "siteDeleteAndResources": "Excluir Site e Recursos", "siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.", + "siteMessageRemoveAndResources": "Isso excluirá permanentemente todos os recursos públicos e privados vinculados a este site, mesmo que um recurso também esteja associado a outros sites.", "siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?", + "siteQuestionRemoveAndResources": "Tem certeza de que deseja excluir este site e todos os recursos associados?", + "sitesTableDeleteSite": "Excluir Site", + "sitesTableDeleteSiteAndResources": "Excluir Site e Recursos", "siteManageSites": "Gerir sites", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", "sitesBannerTitle": "Conectar a Qualquer Rede", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", + "internalResourceAliasLocalWarning": "Os aliases terminando em .local podem causar problemas de resolução devido ao mDNS em algumas redes.", "internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP", "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "siteConfiguration": "Configuração", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "ID da organização ou domínio está faltando", "loadingDNSRecords": "Carregando registros DNS...", "olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Uma versão atualizada está disponível. Por favor, atualize para a versão mais recente para uma melhor experiência.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", From f3fe11c136bc98bc9082f6b9d52a8007e531045e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:39 -0700 Subject: [PATCH 132/264] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 851f8153c..523f3c2ef 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -66,9 +66,15 @@ "local": "Локальный", "edit": "Редактировать", "siteConfirmDelete": "Подтвердить удаление сайта", + "siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов", "siteDelete": "Удалить сайт", + "siteDeleteAndResources": "Удалить сайт и ресурсы", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", + "siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", + "siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?", + "sitesTableDeleteSite": "Удалить сайт", + "sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы", "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", + "internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.", "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "siteConfiguration": "Конфигурация", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", From 39d61a35eb04ea6a49b92b988df282b2c516e9dd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:41 -0700 Subject: [PATCH 133/264] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index f04b80159..db5f8158a 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -66,9 +66,15 @@ "local": "Yerel", "edit": "Düzenle", "siteConfirmDelete": "Site Silmeyi Onayla", + "siteConfirmDeleteAndResources": "Site ve Kaynakları Silmeyi Onayla", "siteDelete": "Siteyi Sil", + "siteDeleteAndResources": "Site ve Kaynakları Sil", "siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.", + "siteMessageRemoveAndResources": "Bu işlem, diğer sitelerle de ilişkilendirilmiş olsa bile, bu siteye bağlı tüm genel ve özel kaynakları kalıcı olarak silecektir.", "siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?", + "siteQuestionRemoveAndResources": "Bu siteyi ve tüm ilişkili kaynakları silmek istediğinizden emin misiniz?", + "sitesTableDeleteSite": "Siteyi Sil", + "sitesTableDeleteSiteAndResources": "Site ve Kaynakları Sil", "siteManageSites": "Siteleri Yönet", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", "sitesBannerTitle": "Herhangi Bir Ağa Bağlan", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", + "internalResourceAliasLocalWarning": "Bazı ağlarda mDNS nedeniyle .local ile biten takma adlar çözümleme sorunlarına neden olabilir.", "internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir", "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "siteConfiguration": "Yapılandırma", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", "loadingDNSRecords": "DNS kayıtları yükleniyor...", "olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Güncellenmiş bir sürüm mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", From 7425daad3ff5b39286e097f876ad1e2b7b1c819b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:43 -0700 Subject: [PATCH 134/264] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 906cfc96e..d4f124f96 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -66,9 +66,15 @@ "local": "本地的", "edit": "编辑", "siteConfirmDelete": "确认删除节点", + "siteConfirmDeleteAndResources": "确认删除站点及资源", "siteDelete": "删除节点", + "siteDeleteAndResources": "删除站点及资源", "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", + "siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。", "siteQuestionRemove": "您确定要从组织中删除该节点吗?", + "siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?", + "sitesTableDeleteSite": "删除站点", + "sitesTableDeleteSiteAndResources": "删除站点及资源", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", + "internalResourceAliasLocalWarning": "以 .local 结尾的别名可能会因某些网络上的 mDNS 而导致解析问题。", "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", "internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "siteConfiguration": "配置", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", From 8183d19400505feee84f2f09ae33fbfe96d8ceea Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:44 -0700 Subject: [PATCH 135/264] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 1660e12a4..f26499819 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekreft Sletting av Område", + "siteConfirmDeleteAndResources": "Bekreft sletting av nettsted og ressurser", "siteDelete": "Slett Område", + "siteDeleteAndResources": "Slett nettsted og ressurser", "siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.", + "siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressurser tilknyttet dette nettstedet, selv om en ressurs også er tilknyttet andre nettsteder.", "siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?", + "siteQuestionRemoveAndResources": "Er du sikker på at du vil slette dette nettstedet og alle tilknyttede ressurser?", + "sitesTableDeleteSite": "Slett nettsted", + "sitesTableDeleteSiteAndResources": "Slett nettsted og ressurser", "siteManageSites": "Administrer Områder", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", "sitesBannerTitle": "Koble til alle nettverk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", + "internalResourceAliasLocalWarning": "Alias som slutter på .local kan forårsake oppløsningsproblemer på grunn av mDNS på enkelte nettverk.", "internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "siteConfiguration": "Konfigurasjon", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", "loadingDNSRecords": "Laster DNS-poster...", "olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "En oppdatert versjon er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "client": "Klient", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", From 053ff1e7997746bc77405cb2cfeb9012f84a5899 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 25 Jun 2026 15:39:04 -0400 Subject: [PATCH 136/264] Add utility subnet --- messages/en-US.json | 1 + src/components/OrgInfoCard.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 83e8a488d..c7964f8be 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2556,6 +2556,7 @@ "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", + "utilitySubnet": "Utility Subnet", "subnetDescription": "The subnet for this organization's network configuration.", "customDomain": "Custom Domain", "authPage": "Authentication Pages", diff --git a/src/components/OrgInfoCard.tsx b/src/components/OrgInfoCard.tsx index f21a42c29..796855609 100644 --- a/src/components/OrgInfoCard.tsx +++ b/src/components/OrgInfoCard.tsx @@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) { return ( - + {t("name")} {org.org.name} @@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) { {org.org.subnet || t("none")} + + + {t("utilitySubnet")} + + + {org.org.utilitySubnet || t("none")} + + From 2e628fe0e41e3556154e2023d7da81666a954919 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 26 Jun 2026 09:26:43 -0400 Subject: [PATCH 137/264] Make sure the rebuild actually executes --- server/lib/calculateUserClientsForOrgs.ts | 764 +++++++++++----------- 1 file changed, 371 insertions(+), 393 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 9c6903ebc..39585500a 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -29,7 +29,7 @@ type ClientRow = typeof clients.$inferSelect; function runQueuedClientAssociationRebuilds( userId: string, queuedClients: ClientRow[] -): void { +) { if (queuedClients.length === 0) { return; } @@ -39,425 +39,403 @@ function runQueuedClientAssociationRebuilds( uniqueClientsById.set(client.clientId, client); } - void (async () => { - for (const client of uniqueClientsById.values()) { - try { - await rebuildClientAssociationsFromClient(client); - } catch (error) { - logger.error( - `Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` - ); - } - } + for (const client of uniqueClientsById.values()) { + rebuildClientAssociationsFromClient(client).catch((error) => { + logger.error( + `Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String( + error + )}` + ); + }); + } - logger.debug( - `Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})` - ); - })(); + logger.debug( + `Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})` + ); } export async function calculateUserClientsForOrgs( userId: string ): Promise { const trx = primaryDb; + const queuedAssociationRebuilds: ClientRow[] = []; + const orgCache = new Map(); + const adminRoleCache = new Map(); + const exitNodesCache = new Map< + string, + Awaited> + >(); + const isOrgLicensedCache = new Map(); + const existingClientCache = new Map< + string, + typeof clients.$inferSelect | null + >(); + const roleClientAccessCache = new Map(); + const userClientAccessCache = new Map(); - const execute = async (transaction: Transaction | typeof db) => { - const orgCache = new Map(); - const adminRoleCache = new Map< - string, - typeof roles.$inferSelect | null - >(); - const exitNodesCache = new Map< - string, - Awaited> - >(); - const isOrgLicensedCache = new Map(); - const existingClientCache = new Map< - string, - typeof clients.$inferSelect | null - >(); - const roleClientAccessCache = new Map(); - const userClientAccessCache = new Map(); + const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`; + const getRoleClientKey = (roleId: number, clientId: number) => + `${roleId}:${clientId}`; + const getUserClientKey = (cachedUserId: string, clientId: number) => + `${cachedUserId}:${clientId}`; - const getOrgOlmKey = (orgId: string, olmId: string) => - `${orgId}:${olmId}`; - const getRoleClientKey = (roleId: number, clientId: number) => - `${roleId}:${clientId}`; - const getUserClientKey = (cachedUserId: string, clientId: number) => - `${cachedUserId}:${clientId}`; - - const getOrg = async (orgId: string) => { - if (orgCache.has(orgId)) { - return orgCache.get(orgId) ?? null; - } - - const [org] = await transaction - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); - orgCache.set(orgId, org ?? null); - - return org ?? null; - }; - - const getAdminRole = async (orgId: string) => { - if (adminRoleCache.has(orgId)) { - return adminRoleCache.get(orgId) ?? null; - } - - const [adminRole] = await transaction - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - adminRoleCache.set(orgId, adminRole ?? null); - - return adminRole ?? null; - }; - - const getExitNodes = async (orgId: string) => { - if (exitNodesCache.has(orgId)) { - return exitNodesCache.get(orgId)!; - } - - const exitNodes = await listExitNodes(orgId); - exitNodesCache.set(orgId, exitNodes); - - return exitNodes; - }; - - const getIsOrgLicensed = async (orgId: string) => { - if (isOrgLicensedCache.has(orgId)) { - return isOrgLicensedCache.get(orgId)!; - } - - const isOrgLicensed = await isLicensedOrSubscribed( - orgId, - tierMatrix.deviceApprovals - ); - isOrgLicensedCache.set(orgId, isOrgLicensed); - - return isOrgLicensed; - }; - - const getExistingClient = async (orgId: string, olmId: string) => { - const key = getOrgOlmKey(orgId, olmId); - if (existingClientCache.has(key)) { - return existingClientCache.get(key) ?? null; - } - - const [existingClient] = await transaction - .select() - .from(clients) - .where( - and( - eq(clients.userId, userId), - eq(clients.orgId, orgId), - eq(clients.olmId, olmId) - ) - ) - .limit(1); - - existingClientCache.set(key, existingClient ?? null); - - return existingClient ?? null; - }; - - const hasRoleClientAccess = async ( - roleId: number, - clientId: number - ) => { - const key = getRoleClientKey(roleId, clientId); - if (roleClientAccessCache.has(key)) { - return roleClientAccessCache.get(key)!; - } - - const [existingRoleClient] = await transaction - .select() - .from(roleClients) - .where( - and( - eq(roleClients.roleId, roleId), - eq(roleClients.clientId, clientId) - ) - ) - .limit(1); - - const hasAccess = Boolean(existingRoleClient); - roleClientAccessCache.set(key, hasAccess); - - return hasAccess; - }; - - const hasUserClientAccess = async ( - cachedUserId: string, - clientId: number - ) => { - const key = getUserClientKey(cachedUserId, clientId); - if (userClientAccessCache.has(key)) { - return userClientAccessCache.get(key)!; - } - - const [existingUserClient] = await transaction - .select() - .from(userClients) - .where( - and( - eq(userClients.userId, cachedUserId), - eq(userClients.clientId, clientId) - ) - ) - .limit(1); - - const hasAccess = Boolean(existingUserClient); - userClientAccessCache.set(key, hasAccess); - - return hasAccess; - }; - - // Get all OLMs for this user - const userOlms = await transaction - .select() - .from(olms) - .where(eq(olms.userId, userId)); - - if (userOlms.length === 0) { - // No OLMs for this user, but we should still clean up any orphaned clients - await cleanupOrphanedClients( - userId, - transaction, - [], - queuedAssociationRebuilds - ); - return; + const getOrg = async (orgId: string) => { + if (orgCache.has(orgId)) { + return orgCache.get(orgId) ?? null; } - // Get all user orgs with all roles (for org list and role-based logic) - const userOrgRoleRows = await transaction + const [org] = await trx .select() - .from(userOrgs) - .innerJoin( - userOrgRoles, + .from(orgs) + .where(eq(orgs.orgId, orgId)); + orgCache.set(orgId, org ?? null); + + return org ?? null; + }; + + const getAdminRole = async (orgId: string) => { + if (adminRoleCache.has(orgId)) { + return adminRoleCache.get(orgId) ?? null; + } + + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + adminRoleCache.set(orgId, adminRole ?? null); + + return adminRole ?? null; + }; + + const getExitNodes = async (orgId: string) => { + if (exitNodesCache.has(orgId)) { + return exitNodesCache.get(orgId)!; + } + + const exitNodes = await listExitNodes(orgId); + exitNodesCache.set(orgId, exitNodes); + + return exitNodes; + }; + + const getIsOrgLicensed = async (orgId: string) => { + if (isOrgLicensedCache.has(orgId)) { + return isOrgLicensedCache.get(orgId)!; + } + + const isOrgLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.deviceApprovals + ); + isOrgLicensedCache.set(orgId, isOrgLicensed); + + return isOrgLicensed; + }; + + const getExistingClient = async (orgId: string, olmId: string) => { + const key = getOrgOlmKey(orgId, olmId); + if (existingClientCache.has(key)) { + return existingClientCache.get(key) ?? null; + } + + const [existingClient] = await trx + .select() + .from(clients) + .where( and( - eq(userOrgs.userId, userOrgRoles.userId), - eq(userOrgs.orgId, userOrgRoles.orgId) + eq(clients.userId, userId), + eq(clients.orgId, orgId), + eq(clients.olmId, olmId) ) ) - .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) - .where(eq(userOrgs.userId, userId)); + .limit(1); - const userOrgIds = [ - ...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId)) - ]; - const orgIdToRoleRows = new Map< - string, - (typeof userOrgRoleRows)[0][] - >(); - for (const r of userOrgRoleRows) { - const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; - list.push(r); - orgIdToRoleRows.set(r.userOrgs.orgId, list); - } - const orgRequiresDeviceApprovalRole = new Map(); - for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) { - orgRequiresDeviceApprovalRole.set( - orgId, - roleRowsForOrg.some((r) => r.roles.requireDeviceApproval) - ); + existingClientCache.set(key, existingClient ?? null); + + return existingClient ?? null; + }; + + const hasRoleClientAccess = async (roleId: number, clientId: number) => { + const key = getRoleClientKey(roleId, clientId); + if (roleClientAccessCache.has(key)) { + return roleClientAccessCache.get(key)!; } - // For each OLM, ensure there's a client in each org the user is in - for (const olm of userOlms) { - for (const orgId of orgIdToRoleRows.keys()) { - const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; - const userOrg = roleRowsForOrg[0].userOrgs; + const [existingRoleClient] = await trx + .select() + .from(roleClients) + .where( + and( + eq(roleClients.roleId, roleId), + eq(roleClients.clientId, clientId) + ) + ) + .limit(1); - const org = await getOrg(orgId); + const hasAccess = Boolean(existingRoleClient); + roleClientAccessCache.set(key, hasAccess); - if (!org) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found` - ); - continue; - } + return hasAccess; + }; - if (!org.subnet) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured` - ); - continue; - } - - // Get admin role for this org (needed for access grants) - const adminRole = await getAdminRole(orgId); - - if (!adminRole) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found` - ); - continue; - } - - // Check if a client already exists for this OLM+user+org combination - const existingClient = await getExistingClient( - orgId, - olm.olmId - ); - - if (existingClient) { - // Ensure admin role has access to the client - const hasRoleAccess = await hasRoleClientAccess( - adminRole.roleId, - existingClient.clientId - ); - - if (!hasRoleAccess) { - await transaction.insert(roleClients).values({ - roleId: adminRole.roleId, - clientId: existingClient.clientId - }); - roleClientAccessCache.set( - getRoleClientKey( - adminRole.roleId, - existingClient.clientId - ), - true - ); - logger.debug( - `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` - ); - } - - // Ensure user has access to the client - const hasUserAccess = await hasUserClientAccess( - userId, - existingClient.clientId - ); - - if (!hasUserAccess) { - await transaction.insert(userClients).values({ - userId, - clientId: existingClient.clientId - }); - userClientAccessCache.set( - getUserClientKey(userId, existingClient.clientId), - true - ); - logger.debug( - `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` - ); - } - - logger.debug( - `Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation` - ); - continue; - } - - // Get exit nodes for this org - const exitNodesList = await getExitNodes(orgId); - - if (exitNodesList.length === 0) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found` - ); - continue; - } - - const randomExitNode = - exitNodesList[ - Math.floor(Math.random() * exitNodesList.length) - ]; - - // Get next available subnet - const { value: newSubnet, release: releaseSubnetLock } = - await getNextAvailableClientSubnet(orgId, transaction); - - const subnet = newSubnet.split("/")[0]; - const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; - - const niceId = await getUniqueClientName(orgId); - - const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId); - const requireApproval = - build !== "oss" && - isOrgLicensed && - orgRequiresDeviceApprovalRole.get(orgId) === true; - - const newClientData: InferInsertModel = { - userId, - orgId: userOrg.orgId, - exitNodeId: randomExitNode.exitNodeId, - name: olm.name || "User Client", - subnet: updatedSubnet, - olmId: olm.olmId, - type: "olm", - niceId, - approvalState: requireApproval ? "pending" : null - }; - - // Create the client - const [newClient] = await transaction - .insert(clients) - .values(newClientData) - .returning(); - await releaseSubnetLock(); - existingClientCache.set( - getOrgOlmKey(orgId, olm.olmId), - newClient - ); - - // create approval request - if (requireApproval) { - await transaction - .insert(approvals) - .values({ - timestamp: Math.floor(new Date().getTime() / 1000), - orgId: userOrg.orgId, - clientId: newClient.clientId, - userId, - type: "user_device" - }) - .returning(); - } - - queuedAssociationRebuilds.push(newClient); - - // Grant admin role access to the client - await transaction.insert(roleClients).values({ - roleId: adminRole.roleId, - clientId: newClient.clientId - }); - roleClientAccessCache.set( - getRoleClientKey(adminRole.roleId, newClient.clientId), - true - ); - - // Grant user access to the client - await transaction.insert(userClients).values({ - userId, - clientId: newClient.clientId - }); - userClientAccessCache.set( - getUserClientKey(userId, newClient.clientId), - true - ); - - logger.debug( - `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` - ); - } + const hasUserClientAccess = async ( + cachedUserId: string, + clientId: number + ) => { + const key = getUserClientKey(cachedUserId, clientId); + if (userClientAccessCache.has(key)) { + return userClientAccessCache.get(key)!; } - // Clean up clients in orgs the user is no longer in + const [existingUserClient] = await trx + .select() + .from(userClients) + .where( + and( + eq(userClients.userId, cachedUserId), + eq(userClients.clientId, clientId) + ) + ) + .limit(1); + + const hasAccess = Boolean(existingUserClient); + userClientAccessCache.set(key, hasAccess); + + return hasAccess; + }; + + // Get all OLMs for this user + const userOlms = await trx + .select() + .from(olms) + .where(eq(olms.userId, userId)); + + if (userOlms.length === 0) { + // No OLMs for this user, but we should still clean up any orphaned clients await cleanupOrphanedClients( userId, - transaction, - userOrgIds, + trx, + [], queuedAssociationRebuilds ); - }; + return; + } + + // Get all user orgs with all roles (for org list and role-based logic) + const userOrgRoleRows = await trx + .select() + .from(userOrgs) + .innerJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(eq(userOrgs.userId, userId)); + + const userOrgIds = [ + ...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId)) + ]; + const orgIdToRoleRows = new Map(); + for (const r of userOrgRoleRows) { + const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; + list.push(r); + orgIdToRoleRows.set(r.userOrgs.orgId, list); + } + const orgRequiresDeviceApprovalRole = new Map(); + for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) { + orgRequiresDeviceApprovalRole.set( + orgId, + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval) + ); + } + + // For each OLM, ensure there's a client in each org the user is in + for (const olm of userOlms) { + for (const orgId of orgIdToRoleRows.keys()) { + const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; + const userOrg = roleRowsForOrg[0].userOrgs; + + const org = await getOrg(orgId); + + if (!org) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found` + ); + continue; + } + + if (!org.subnet) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured` + ); + continue; + } + + // Get admin role for this org (needed for access grants) + const adminRole = await getAdminRole(orgId); + + if (!adminRole) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found` + ); + continue; + } + + // Check if a client already exists for this OLM+user+org combination + const existingClient = await getExistingClient(orgId, olm.olmId); + + if (existingClient) { + // Ensure admin role has access to the client + const hasRoleAccess = await hasRoleClientAccess( + adminRole.roleId, + existingClient.clientId + ); + + if (!hasRoleAccess) { + await trx.insert(roleClients).values({ + roleId: adminRole.roleId, + clientId: existingClient.clientId + }); + roleClientAccessCache.set( + getRoleClientKey( + adminRole.roleId, + existingClient.clientId + ), + true + ); + logger.debug( + `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` + ); + } + + // Ensure user has access to the client + const hasUserAccess = await hasUserClientAccess( + userId, + existingClient.clientId + ); + + if (!hasUserAccess) { + await trx.insert(userClients).values({ + userId, + clientId: existingClient.clientId + }); + userClientAccessCache.set( + getUserClientKey(userId, existingClient.clientId), + true + ); + logger.debug( + `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` + ); + } + + logger.debug( + `Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation` + ); + continue; + } + + // Get exit nodes for this org + const exitNodesList = await getExitNodes(orgId); + + if (exitNodesList.length === 0) { + logger.warn( + `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found` + ); + continue; + } + + const randomExitNode = + exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + + // Get next available subnet + const { value: newSubnet, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId, trx); + + const subnet = newSubnet.split("/")[0]; + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; + + const niceId = await getUniqueClientName(orgId); + + const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId); + const requireApproval = + build !== "oss" && + isOrgLicensed && + orgRequiresDeviceApprovalRole.get(orgId) === true; + + const newClientData: InferInsertModel = { + userId, + orgId: userOrg.orgId, + exitNodeId: randomExitNode.exitNodeId, + name: olm.name || "User Client", + subnet: updatedSubnet, + olmId: olm.olmId, + type: "olm", + niceId, + approvalState: requireApproval ? "pending" : null + }; + + // Create the client + const [newClient] = await trx + .insert(clients) + .values(newClientData) + .returning(); + await releaseSubnetLock(); + existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient); + + // create approval request + if (requireApproval) { + await trx + .insert(approvals) + .values({ + timestamp: Math.floor(new Date().getTime() / 1000), + orgId: userOrg.orgId, + clientId: newClient.clientId, + userId, + type: "user_device" + }) + .returning(); + } + + queuedAssociationRebuilds.push(newClient); + + // Grant admin role access to the client + await trx.insert(roleClients).values({ + roleId: adminRole.roleId, + clientId: newClient.clientId + }); + roleClientAccessCache.set( + getRoleClientKey(adminRole.roleId, newClient.clientId), + true + ); + + // Grant user access to the client + await trx.insert(userClients).values({ + userId, + clientId: newClient.clientId + }); + userClientAccessCache.set( + getUserClientKey(userId, newClient.clientId), + true + ); + + logger.debug( + `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` + ); + } + } + + // Clean up clients in orgs the user is no longer in + await cleanupOrphanedClients( + userId, + trx, + userOrgIds, + queuedAssociationRebuilds + ); runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds); } @@ -496,7 +474,7 @@ async function cleanupOrphanedClients( ) .returning(); - // Queue deleted clients for post-transaction association cleanup. + // Queue deleted clients for post-trx association cleanup. for (const deletedClient of deletedClients) { queuedAssociationRebuilds.push(deletedClient); 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 138/264] 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 139/264] 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 140/264] 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 141/264] 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 142/264] 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 143/264] 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 144/264] 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 145/264] 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 146/264] 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 147/264] 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 148/264] 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 149/264] 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 150/264] 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 151/264] 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 152/264] 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 153/264] 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 154/264] 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 155/264] 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 156/264] 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 157/264] 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 158/264] 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 159/264] 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 160/264] 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 161/264] 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 162/264] 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 163/264] 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 164/264] 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 165/264] 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 166/264] 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 167/264] 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 168/264] 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 169/264] 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 170/264] 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 171/264] 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 172/264] 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 173/264] 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 174/264] 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 175/264] 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 176/264] 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 177/264] 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 178/264] 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 179/264] 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 180/264] 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 181/264] 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 182/264] 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 183/264] 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 184/264] 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 185/264] 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 186/264] 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 187/264] 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 188/264] 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 189/264] 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 190/264] 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 191/264] 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 192/264] 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 193/264] 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 194/264] 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 195/264] 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 196/264] 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 197/264] 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 198/264] 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 199/264] 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 200/264] 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 201/264] 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 202/264] 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 203/264] 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 204/264] 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 205/264] 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 206/264] 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 207/264] 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 208/264] 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 209/264] 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 210/264] 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 211/264] 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 212/264] 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 213/264] 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 214/264] 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 215/264] 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 216/264] 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"; From 8f377a4fb2037f956fc8abced2d55de4632be50b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Jul 2026 22:06:47 -0400 Subject: [PATCH 217/264] Dont run on cloud --- server/setup/ensureActions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/setup/ensureActions.ts b/server/setup/ensureActions.ts index 587da4793..2dc5aa1bc 100644 --- a/server/setup/ensureActions.ts +++ b/server/setup/ensureActions.ts @@ -3,8 +3,12 @@ import { db, orgs } from "@server/db"; import { actions, roles, roleActions } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; +import { build } from "@server/build"; export async function ensureActions() { + if (build == "saas") { + return; + } await db.transaction(async (trx) => { const actionIds = Object.values(ActionsEnum); const existingActions = await trx.select().from(actions).execute(); From e40f3257034f802096be1177f6800a4ce7267b63 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 10:55:46 -0400 Subject: [PATCH 218/264] Add new limits to the billing page --- messages/en-US.json | 6 + server/lib/billing/features.ts | 6 + server/private/routers/billing/getOrgUsage.ts | 21 ++ .../settings/(private)/billing/page.tsx | 273 +++++++++++++++++- 4 files changed, 300 insertions(+), 6 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0129c1159..4701d4da1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1915,6 +1915,9 @@ "billingDomains": "Domains", "billingOrganizations": "Orgs", "billingRemoteExitNodes": "Remote Nodes", + "billingPublicResources": "Public Resources", + "billingPrivateResources": "Private Resources", + "billingMachineClients": "Machine Clients", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", "billingIncludedUsage": "Included Usage", @@ -1943,6 +1946,9 @@ "billingUsersInfo": "How many users you can use", "billingDomainInfo": "How many domains you can use", "billingRemoteExitNodesInfo": "How many remote nodes you can use", + "billingPublicResourcesInfo": "How many public resources you can use", + "billingPrivateResourcesInfo": "How many private resources you can use", + "billingMachineClientsInfo": "How many machine clients you can use", "billingLicenseKeys": "License Keys", "billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseSubscription": "License Subscription", diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index eff042ebf..ffe24a4a3 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -27,6 +27,12 @@ export async function getFeatureDisplayName( return "Remote Exit Nodes"; case LimitId.ORGANIZATIONS: return "Organizations"; + case LimitId.PUBLIC_RESOURCES: + return "Public Resources"; + case LimitId.PRIVATE_RESOURCES: + return "Private Resources"; + case LimitId.MACHINE_CLIENTS: + return "Machine Clients"; case LimitId.TIER1: return "Home Lab"; default: diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 6b61b84de..1910a6a33 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -104,6 +104,18 @@ export async function getOrgUsage( orgId, LimitId.ORGANIZATIONS ); + const publicResources = await usageService.getUsage( + orgId, + LimitId.PUBLIC_RESOURCES + ); + const privateResources = await usageService.getUsage( + orgId, + LimitId.PRIVATE_RESOURCES + ); + const machineClients = await usageService.getUsage( + orgId, + LimitId.MACHINE_CLIENTS + ); // const egressData = await usageService.getUsage( // orgId, // FeatureId.EGRESS_DATA_MB @@ -127,6 +139,15 @@ export async function getOrgUsage( if (organizations) { usageData.push(organizations); } + if (publicResources) { + usageData.push(publicResources); + } + if (privateResources) { + usageData.push(privateResources); + } + if (machineClients) { + usageData.push(machineClients); + } const orgLimits = await db .select() diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index d526a17f4..b3d1bb351 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -158,6 +158,9 @@ const tierLimits: Record< domains: number; remoteNodes: number; organizations: number; + publicResources: number; + privateResources: number; + machineClients: number; } > = { basic: { @@ -165,35 +168,50 @@ 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.ORGANIZATIONS]?.value ?? 0 + organizations: freeLimitSet[LimitId.ORGANIZATIONS]?.value ?? 0, + publicResources: freeLimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0, + privateResources: freeLimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0, + machineClients: freeLimitSet[LimitId.MACHINE_CLIENTS]?.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.ORGANIZATIONS]?.value ?? 0 + organizations: tier1LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0, + publicResources: tier1LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0, + privateResources: tier1LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0, + machineClients: tier1LimitSet[LimitId.MACHINE_CLIENTS]?.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.ORGANIZATIONS]?.value ?? 0 + organizations: tier2LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0, + publicResources: tier2LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0, + privateResources: tier2LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0, + machineClients: tier2LimitSet[LimitId.MACHINE_CLIENTS]?.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.ORGANIZATIONS]?.value ?? 0 + organizations: tier3LimitSet[LimitId.ORGANIZATIONS]?.value ?? 0, + publicResources: tier3LimitSet[LimitId.PUBLIC_RESOURCES]?.value ?? 0, + privateResources: tier3LimitSet[LimitId.PRIVATE_RESOURCES]?.value ?? 0, + machineClients: tier3LimitSet[LimitId.MACHINE_CLIENTS]?.value ?? 0 }, enterprise: { users: 0, // Custom for enterprise sites: 0, // Custom for enterprise domains: 0, // Custom for enterprise remoteNodes: 0, // Custom for enterprise - organizations: 0 // Custom for enterprise + organizations: 0, // Custom for enterprise + publicResources: 0, // Custom for enterprise + privateResources: 0, // Custom for enterprise + machineClients: 0 // Custom for enterprise } }; @@ -234,6 +252,9 @@ export default function BillingPage() { const DOMAINS = "domains"; const REMOTE_EXIT_NODES = "remoteExitNodes"; const ORGINIZATIONS = "organizations"; + const PUBLIC_RESOURCES = "publicResources"; + const PRIVATE_RESOURCES = "privateResources"; + const MACHINE_CLIENTS = "machineClients"; // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); @@ -797,6 +818,45 @@ export default function BillingPage() { }); } + // Check public resources + const publicResourcesUsage = getUsageValue(PUBLIC_RESOURCES); + if ( + limits.publicResources > 0 && + publicResourcesUsage > limits.publicResources + ) { + violations.push({ + feature: "Public Resources", + currentUsage: publicResourcesUsage, + newLimit: limits.publicResources + }); + } + + // Check private resources + const privateResourcesUsage = getUsageValue(PRIVATE_RESOURCES); + if ( + limits.privateResources > 0 && + privateResourcesUsage > limits.privateResources + ) { + violations.push({ + feature: "Private Resources", + currentUsage: privateResourcesUsage, + newLimit: limits.privateResources + }); + } + + // Check machine clients + const machineClientsUsage = getUsageValue(MACHINE_CLIENTS); + if ( + limits.machineClients > 0 && + machineClientsUsage > limits.machineClients + ) { + violations.push({ + feature: "Machine Clients", + currentUsage: machineClientsUsage, + newLimit: limits.machineClients + }); + } + return violations; }; @@ -1025,7 +1085,7 @@ export default function BillingPage() {
{t("billingMaximumLimits") || "Maximum Limits"}
- + {t("billingUsers") || "Users"} @@ -1308,6 +1368,168 @@ export default function BillingPage() { )} + + + {t("billingPublicResources") || + "Public Resources"} + + + {isOverLimit(PUBLIC_RESOURCES) ? ( + + + + + {getLimitValue( + PUBLIC_RESOURCES + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + PUBLIC_RESOURCES + ), + limit: + getLimitValue( + PUBLIC_RESOURCES + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(PUBLIC_RESOURCES)}) exceeds limit (${getLimitValue(PUBLIC_RESOURCES)})`} +

+
+
+ ) : ( + <> + {getLimitValue( + PUBLIC_RESOURCES + ) ?? + t("billingUnlimited") ?? + "∞"} + + )} +
+
+ + + {t("billingPrivateResources") || + "Private Resources"} + + + {isOverLimit(PRIVATE_RESOURCES) ? ( + + + + + {getLimitValue( + PRIVATE_RESOURCES + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + PRIVATE_RESOURCES + ), + limit: + getLimitValue( + PRIVATE_RESOURCES + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(PRIVATE_RESOURCES)}) exceeds limit (${getLimitValue(PRIVATE_RESOURCES)})`} +

+
+
+ ) : ( + <> + {getLimitValue( + PRIVATE_RESOURCES + ) ?? + t("billingUnlimited") ?? + "∞"} + + )} +
+
+ + + {t("billingMachineClients") || + "Machine Clients"} + + + {isOverLimit(MACHINE_CLIENTS) ? ( + + + + + {getLimitValue( + MACHINE_CLIENTS + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + MACHINE_CLIENTS + ), + limit: + getLimitValue( + MACHINE_CLIENTS + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(MACHINE_CLIENTS)}) exceeds limit (${getLimitValue(MACHINE_CLIENTS)})`} +

+
+
+ ) : ( + <> + {getLimitValue( + MACHINE_CLIENTS + ) ?? + t("billingUnlimited") ?? + "∞"} + + )} +
+
@@ -1507,6 +1729,45 @@ export default function BillingPage() { "Remote Nodes"}
+
+ + + { + tierLimits[ + pendingTier.tier + ].publicResources + }{" "} + {t( + "billingPublicResources" + ) || "Public Resources"} + +
+
+ + + { + tierLimits[ + pendingTier.tier + ].privateResources + }{" "} + {t( + "billingPrivateResources" + ) || "Private Resources"} + +
+
+ + + { + tierLimits[ + pendingTier.tier + ].machineClients + }{" "} + {t( + "billingMachineClients" + ) || "Machine Clients"} + +
)} From 5fc5a3ebca45f64648abf784238d7e256f94f491 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 11:49:49 -0400 Subject: [PATCH 219/264] Adjust spacing --- .../settings/(private)/billing/page.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index b3d1bb351..aa121838f 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -1057,21 +1057,23 @@ export default function BillingPage() { -
+
{/* Current Usage */} -
+
{t("billingCurrentUsage") || "Current Usage"}
-
- - {getUserCount()} - - - {t("billingUsers") || "Users"} - +
+
+ + {getUserCount()} + + + {t("billingUsers") || "Users"} + +
{hasSubscription && getPricePerUser() > 0 && ( -
+
x ${getPricePerUser()} / month = $ {getUserCount() * getPricePerUser()} / month @@ -1081,7 +1083,7 @@ export default function BillingPage() {
{/* Maximum Limits */} -
+
{t("billingMaximumLimits") || "Maximum Limits"}
From 1bf3d2cdd67e219e976514d1da1f801bd16d4b57 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 12:10:20 -0400 Subject: [PATCH 220/264] Add back the sync with semver --- server/routers/newt/handleNewtPingMessage.ts | 32 +++++--- server/routers/newt/sync.ts | 82 ++++++++++---------- 2 files changed, 63 insertions(+), 51 deletions(-) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 86489bd67..13052dcc1 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -5,8 +5,21 @@ import { Newt } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; +import semver from "semver"; import { recordSitePing } from "./pingAccumulator"; +const NEWT_SUPPORTS_SYNC_VERSION = ">=1.14.0"; +const PONG = { + message: { + type: "pong", + data: { + timestamp: new Date().toISOString() + } + }, + broadcast: false, + excludeSender: false +}; + /** * Handles ping messages from newt clients. * @@ -37,6 +50,14 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { // cross-region latency to the database. recordSitePing(newt.siteId); + if ( + newt.version && + !semver.satisfies(newt.version, NEWT_SUPPORTS_SYNC_VERSION) + ) { + // Newt does not support the sync message so not checking - stop here - + return PONG; + } + // Check config version and sync if stale. const configVersion = await getClientConfigVersion(newt.newtId); @@ -65,14 +86,5 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { await sendNewtSyncMessage(newt, site); } - return { - message: { - type: "pong", - data: { - timestamp: new Date().toISOString() - } - }, - broadcast: false, - excludeSender: false - }; + return PONG; }; diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index 6c3ab0a0f..30e2d59f8 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -9,45 +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 f87e136f6b2e8be0d0e152e31e1ac78a39fcaa14 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 20:54:59 -0400 Subject: [PATCH 221/264] Unique subnets for exit nodes --- server/lib/exitNodes/subnet.ts | 73 ++++--- .../private/routers/gerbil/createExitNode.ts | 64 ++++--- .../remoteExitNode/createRemoteExitNode.ts | 181 ++++++++++-------- server/routers/gerbil/createExitNode.ts | 64 ++++--- 4 files changed, 215 insertions(+), 167 deletions(-) diff --git a/server/lib/exitNodes/subnet.ts b/server/lib/exitNodes/subnet.ts index 49e28bd57..8c4f3e99e 100644 --- a/server/lib/exitNodes/subnet.ts +++ b/server/lib/exitNodes/subnet.ts @@ -1,30 +1,55 @@ -import { db, exitNodes } from "@server/db"; +import { db, exitNodes, Transaction } from "@server/db"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; +import { lockManager } from "#dynamic/lib/lock"; -export async function getNextAvailableSubnet(): Promise { - // Get all existing subnets from routes table - const existingAddresses = await db - .select({ - address: exitNodes.address - }) - .from(exitNodes); - - const addresses = existingAddresses.map((a) => a.address); - let subnet = findNextAvailableCidr( - addresses, - config.getRawConfig().gerbil.block_size, - config.getRawConfig().gerbil.subnet_group - ); - if (!subnet) { - throw new Error("No available subnets remaining in space"); +/** + * Reserves the next available exit node subnet. + * + * Exit node subnets must never overlap with one another - regardless of + * which org(s) they belong to - since HA exit nodes can end up routing for + * the same org. This acquires a lock that the caller MUST release (via the + * returned `release`) only after the chosen address has been durably + * persisted (e.g. after the enclosing transaction commits), otherwise + * concurrent callers can race and pick the same subnet. + */ +export async function getNextAvailableSubnet( + trx: Transaction | typeof db = db +): Promise<{ value: string; release: () => Promise }> { + const lockKey = "exit-node-subnet-allocation"; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); } + const release = () => lockManager.releaseLock(lockKey, acquired); - // replace the last octet with 1 - subnet = - subnet.split(".").slice(0, 3).join(".") + - ".1" + - "/" + - subnet.split("/")[1]; - return subnet; + try { + // Get all existing subnets from routes table + const existingAddresses = await trx + .select({ + address: exitNodes.address + }) + .from(exitNodes); + + const addresses = existingAddresses.map((a) => a.address); + let subnet = findNextAvailableCidr( + addresses, + config.getRawConfig().gerbil.block_size, + config.getRawConfig().gerbil.subnet_group + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + // replace the last octet with 1 + subnet = + subnet.split(".").slice(0, 3).join(".") + + ".1" + + "/" + + subnet.split("/")[1]; + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } diff --git a/server/private/routers/gerbil/createExitNode.ts b/server/private/routers/gerbil/createExitNode.ts index 818c5f0e1..cfa7c42eb 100644 --- a/server/private/routers/gerbil/createExitNode.ts +++ b/server/private/routers/gerbil/createExitNode.ts @@ -29,37 +29,41 @@ export async function createExitNode( .where(eq(exitNodes.publicKey, publicKey)); let exitNode: ExitNode; if (!exitNodeQuery) { - const address = await getNextAvailableSubnet(); - // TODO: eventually we will want to get the next available port so that we can multiple exit nodes - // const listenPort = await getNextAvailablePort(); - const listenPort = config.getRawConfig().gerbil.start_port; - let subEndpoint = ""; - if (config.getRawConfig().gerbil.use_subdomain) { - subEndpoint = await getUniqueExitNodeEndpointName(); + const { value: address, release } = await getNextAvailableSubnet(); + try { + // TODO: eventually we will want to get the next available port so that we can multiple exit nodes + // const listenPort = await getNextAvailablePort(); + const listenPort = config.getRawConfig().gerbil.start_port; + let subEndpoint = ""; + if (config.getRawConfig().gerbil.use_subdomain) { + subEndpoint = await getUniqueExitNodeEndpointName(); + } + + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name || + `Exit Node ${publicKey.slice(0, 8)}`; + + // create a new exit node + [exitNode] = await db + .insert(exitNodes) + .values({ + publicKey, + endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, + address, + listenPort, + online: true, + reachableAt, + name: exitNodeName + }) + .returning() + .execute(); + + logger.info( + `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` + ); + } finally { + await release(); } - - const exitNodeName = - config.getRawConfig().gerbil.exit_node_name || - `Exit Node ${publicKey.slice(0, 8)}`; - - // create a new exit node - [exitNode] = await db - .insert(exitNodes) - .values({ - publicKey, - endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, - address, - listenPort, - online: true, - reachableAt, - name: exitNodeName - }) - .returning() - .execute(); - - logger.info( - `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` - ); } else { // update the reachable at [exitNode] = await db diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index bb16f228d..bf86ed107 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -114,8 +114,6 @@ export async function createRemoteExitNode( } const secretHash = await hashPassword(secret); - // const address = await getNextAvailableSubnet(); - const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES const [existingRemoteExitNode] = await db .select() @@ -191,89 +189,106 @@ export async function createRemoteExitNode( ); } - await db.transaction(async (trx) => { - if (!existingExitNode) { - const [res] = await trx - .insert(exitNodes) - .values({ - name: remoteExitNodeId, - address, - endpoint: "", - publicKey: "", - listenPort: 0, - online: false, - type: "remoteExitNode" - }) - .returning(); - existingExitNode = res; - } + // If this remote exit node isn't already backing an exit node in + // another org, we're about to create a brand new one. Reserve a + // subnet for it up front so the allocation lock is held across the + // whole insert - this guarantees exit node subnets never overlap, + // even under concurrent creation, which matters for HA setups. + let releaseSubnetLock: (() => Promise) | null = null; + let newExitNodeAddress: string | null = null; + if (!existingExitNode) { + const { value, release } = await getNextAvailableSubnet(); + newExitNodeAddress = value; + releaseSubnetLock = release; + } - if (!existingRemoteExitNode) { - await trx.insert(remoteExitNodes).values({ - remoteExitNodeId: remoteExitNodeId, - secretHash, - dateCreated: moment().toISOString(), - exitNodeId: existingExitNode.exitNodeId - }); - } else { - // update the existing remote exit node - await trx - .update(remoteExitNodes) - .set({ - exitNodeId: existingExitNode.exitNodeId - }) - .where( - eq( - remoteExitNodes.remoteExitNodeId, - existingRemoteExitNode.remoteExitNodeId - ) - ); - } - - if (!existingExitNodeOrg) { - await trx.insert(exitNodeOrgs).values({ - exitNodeId: existingExitNode.exitNodeId, - orgId: orgId - }); - } - - // calculate if the node is in any other of the orgs before we count it as an add to the billing org - if (org.billingOrgId) { - const otherBillingOrgs = await trx - .select() - .from(orgs) - .where( - and( - eq(orgs.billingOrgId, org.billingOrgId), - ne(orgs.orgId, orgId) - ) - ); - - const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); - - const orgsInBillingDomainThatTheNodeIsStillIn = await trx - .select() - .from(exitNodeOrgs) - .where( - and( - eq( - exitNodeOrgs.exitNodeId, - existingExitNode.exitNodeId - ), - inArray(exitNodeOrgs.orgId, billingOrgIds) - ) - ); - - if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { - await usageService.add( - orgId, - LimitId.REMOTE_EXIT_NODES, - 1, - trx - ); + try { + await db.transaction(async (trx) => { + if (!existingExitNode) { + const [res] = await trx + .insert(exitNodes) + .values({ + name: remoteExitNodeId, + address: newExitNodeAddress!, + endpoint: "", + publicKey: "", + listenPort: 0, + online: false, + type: "remoteExitNode" + }) + .returning(); + existingExitNode = res; } - } - }); + + if (!existingRemoteExitNode) { + await trx.insert(remoteExitNodes).values({ + remoteExitNodeId: remoteExitNodeId, + secretHash, + dateCreated: moment().toISOString(), + exitNodeId: existingExitNode.exitNodeId + }); + } else { + // update the existing remote exit node + await trx + .update(remoteExitNodes) + .set({ + exitNodeId: existingExitNode.exitNodeId + }) + .where( + eq( + remoteExitNodes.remoteExitNodeId, + existingRemoteExitNode.remoteExitNodeId + ) + ); + } + + if (!existingExitNodeOrg) { + await trx.insert(exitNodeOrgs).values({ + exitNodeId: existingExitNode.exitNodeId, + orgId: orgId + }); + } + + // calculate if the node is in any other of the orgs before we count it as an add to the billing org + if (org.billingOrgId) { + const otherBillingOrgs = await trx + .select() + .from(orgs) + .where( + and( + eq(orgs.billingOrgId, org.billingOrgId), + ne(orgs.orgId, orgId) + ) + ); + + const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); + + const orgsInBillingDomainThatTheNodeIsStillIn = await trx + .select() + .from(exitNodeOrgs) + .where( + and( + eq( + exitNodeOrgs.exitNodeId, + existingExitNode.exitNodeId + ), + inArray(exitNodeOrgs.orgId, billingOrgIds) + ) + ); + + if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { + await usageService.add( + orgId, + LimitId.REMOTE_EXIT_NODES, + 1, + trx + ); + } + } + }); + } finally { + await releaseSubnetLock?.(); + } const token = generateSessionToken(); await createRemoteExitNodeSession(token, remoteExitNodeId); diff --git a/server/routers/gerbil/createExitNode.ts b/server/routers/gerbil/createExitNode.ts index bc9650367..9e93cf575 100644 --- a/server/routers/gerbil/createExitNode.ts +++ b/server/routers/gerbil/createExitNode.ts @@ -13,37 +13,41 @@ export async function createExitNode( const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); let exitNode: ExitNode; if (!exitNodeQuery) { - const address = await getNextAvailableSubnet(); - // TODO: eventually we will want to get the next available port so that we can multiple exit nodes - // const listenPort = await getNextAvailablePort(); - const listenPort = config.getRawConfig().gerbil.start_port; - let subEndpoint = ""; - if (config.getRawConfig().gerbil.use_subdomain) { - subEndpoint = await getUniqueExitNodeEndpointName(); + const { value: address, release } = await getNextAvailableSubnet(); + try { + // TODO: eventually we will want to get the next available port so that we can multiple exit nodes + // const listenPort = await getNextAvailablePort(); + const listenPort = config.getRawConfig().gerbil.start_port; + let subEndpoint = ""; + if (config.getRawConfig().gerbil.use_subdomain) { + subEndpoint = await getUniqueExitNodeEndpointName(); + } + + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name || + `Exit Node ${publicKey.slice(0, 8)}`; + + // create a new exit node + [exitNode] = await db + .insert(exitNodes) + .values({ + publicKey, + endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, + address, + online: true, + listenPort, + reachableAt, + name: exitNodeName + }) + .returning() + .execute(); + + logger.info( + `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` + ); + } finally { + await release(); } - - const exitNodeName = - config.getRawConfig().gerbil.exit_node_name || - `Exit Node ${publicKey.slice(0, 8)}`; - - // create a new exit node - [exitNode] = await db - .insert(exitNodes) - .values({ - publicKey, - endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, - address, - online: true, - listenPort, - reachableAt, - name: exitNodeName - }) - .returning() - .execute(); - - logger.info( - `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` - ); } else { // update the existing exit node [exitNode] = await db From fc54ad49b5fe9af880afec11aebb4a0b49de7aae Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 21:46:44 -0400 Subject: [PATCH 222/264] Fix trial showing --- src/app/[orgId]/settings/(private)/billing/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index aa121838f..0529622e2 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -290,7 +290,13 @@ export default function BillingPage() { setHasSubscription( tierSub.subscription.status === "active" ); - setIsTrial(tierSub.subscription.expiresAt != null); + // expiresAt is only meaningful while the trial hasn't + // actually run out yet; a stale row with a past + // expiresAt should no longer be treated as a live trial + const expiresAt = tierSub.subscription.expiresAt; + setIsTrial( + expiresAt != null && expiresAt * 1000 > Date.now() + ); } // Find license subscription From b93d26f09f691827044b90d0a71c5398e73a06ba Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Jul 2026 21:46:53 -0400 Subject: [PATCH 223/264] Fix reading from replicas --- server/private/lib/redis.ts | 59 ++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index bda2e6204..7ff24ba93 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -894,6 +894,19 @@ class RegionalRedisManager { return opts; } + // The regional Redis StatefulSet's "redis" service pins to pod redis-0 + // (primary). The replica (redis-1) is only reachable through the + // per-pod headless service: ..svc.cluster.local -> + // redis-1.redis-headless..svc.cluster.local. Returns null + // if the configured host doesn't match that pattern (e.g. local dev), + // in which case callers should fall back to the primary for reads. + private getReplicaHost(primaryHost: string): string | null { + const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/); + if (!match) return null; + const namespace = match[1]; + return `redis-1.redis-headless.${namespace}.svc.cluster.local`; + } + private initializeClients(): void { const cfg = this.getConfig(); const baseOpts = { @@ -907,35 +920,42 @@ class RegionalRedisManager { try { this.writeClient = new Redis(baseOpts); - // redis-1 (replica) handles reads; fall back to primary if not resolvable - this.readClient = new Redis({ - ...baseOpts, - host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => { - // Derive replica hostname from the headless service pattern: - // redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local - // If it doesn't look like a k8s service, just use the same host - return h + rest; - }) - }); - // For simplicity use same host for both; callers can always read from primary - // The real replica routing is handled by the StatefulSet headless service - this.readClient = this.writeClient; + const replicaHost = this.getReplicaHost(cfg.host!); + this.readClient = replicaHost + ? new Redis({ ...baseOpts, host: replicaHost }) + : this.writeClient; this.writeClient.on("ready", () => { - logger.info("Regional Redis client ready"); + logger.info("Regional Redis write client ready"); this.isHealthy = true; }); this.writeClient.on("error", (err) => { - logger.error("Regional Redis client error:", err); + logger.error("Regional Redis write client error:", err); this.isHealthy = false; }); this.writeClient.on("reconnecting", () => { - logger.info("Regional Redis client reconnecting..."); + logger.info("Regional Redis write client reconnecting..."); this.isHealthy = false; }); - logger.info("Regional Redis client initialized"); + if (this.readClient !== this.writeClient) { + this.readClient.on("ready", () => { + logger.info("Regional Redis read client ready"); + }); + this.readClient.on("error", (err) => { + logger.error("Regional Redis read client error:", err); + }); + this.readClient.on("reconnecting", () => { + logger.info("Regional Redis read client reconnecting..."); + }); + } + + logger.info( + replicaHost + ? `Regional Redis client initialized (reads routed to replica ${replicaHost})` + : "Regional Redis client initialized (no replica resolvable, reads routed to primary)" + ); } catch (error) { logger.error("Failed to initialize regional Redis client:", error); this.isEnabled = false; @@ -1041,11 +1061,14 @@ class RegionalRedisManager { public async disconnect(): Promise { try { + if (this.readClient && this.readClient !== this.writeClient) { + await this.readClient.quit(); + } + this.readClient = null; if (this.writeClient) { await this.writeClient.quit(); this.writeClient = null; } - this.readClient = null; logger.info("Regional Redis client disconnected"); } catch (error) { logger.error("Error disconnecting regional Redis client:", error); From 1717a99cee573a60f02a5ebf1eb4145c5024855a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 21:53:52 -0400 Subject: [PATCH 224/264] add compact mode for large orgs --- messages/en-US.json | 7 + server/routers/external.ts | 6 + server/routers/launcher/index.ts | 1 + .../launcher/launcherResourceAccess.ts | 11 +- server/routers/launcher/launcherScale.ts | 124 +++++++++++++++++ server/routers/launcher/listLauncherScale.ts | 60 +++++++++ server/routers/launcher/types.ts | 34 ++++- src/app/[orgId]/page.tsx | 1 + src/components/multi-site-selector.tsx | 14 +- .../LauncherFilterPopover.tsx | 4 + .../LauncherFlatResourceList.tsx | 125 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 24 ++-- .../LauncherSearchFirstGate.tsx | 86 ++++++++++++ .../LauncherSettingsMenu.tsx | 47 ++++++- .../resource-launcher/ResourceLauncher.tsx | 82 ++++++++++-- src/lib/launcherLocalStorage.ts | 8 +- src/lib/launcherScale.ts | 95 +++++++++++++ src/lib/launcherServerData.ts | 54 ++++++-- src/lib/launcherUrlState.ts | 2 +- src/lib/queries.ts | 15 +++ 20 files changed, 757 insertions(+), 43 deletions(-) create mode 100644 server/routers/launcher/launcherScale.ts create mode 100644 server/routers/launcher/listLauncherScale.ts create mode 100644 src/components/resource-launcher/LauncherFlatResourceList.tsx create mode 100644 src/components/resource-launcher/LauncherSearchFirstGate.tsx create mode 100644 src/lib/launcherScale.ts diff --git a/messages/en-US.json b/messages/en-US.json index 0129c1159..e699aba9f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3590,6 +3590,7 @@ "resourceLauncherGroupBy": "Group By", "resourceLauncherGroupBySite": "Site", "resourceLauncherGroupByLabel": "Label", + "resourceLauncherGroupByNone": "None", "resourceLauncherLayout": "Layout", "resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutList": "List", @@ -3607,6 +3608,12 @@ "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.", + "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", + "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", + "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", + "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", + "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/routers/external.ts b/server/routers/external.ts index 5d770c064..43cf88a43 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -470,6 +470,12 @@ authenticated.get( launcher.listLauncherGroups ); +authenticated.get( + "/org/:orgId/launcher/scale", + verifyOrgAccess, + launcher.listLauncherScale +); + authenticated.get( "/org/:orgId/launcher/resources", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index 939bf74bc..e941bbe10 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -1,5 +1,6 @@ export * from "./types"; export { listLauncherGroups } from "./listLauncherGroups"; +export { listLauncherScale } from "./listLauncherScale"; export { listLauncherResources } from "./listLauncherResources"; export { listLauncherSites } from "./listLauncherSites"; export { listLauncherLabels } from "./listLauncherLabels"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 8fe5e8ef8..2ce6d87d4 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -38,6 +38,7 @@ import { formatSiteResourceAccess } from "./formatLauncherAccess"; import { + LAUNCHER_FLAT_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, type LauncherFilterListQuery, @@ -1161,10 +1162,12 @@ export async function listLauncherResourcesForUser( let items = [...publicItems, ...siteItems]; items = filterResourcesBySearch(items, query.query); - if (query.groupBy === "label") { - items = filterResourcesByLabel(items, query.groupKey); - } else if (query.groupBy === "site") { - items = filterResourcesBySite(items, query.groupKey); + if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { + 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/launcherScale.ts b/server/routers/launcher/launcherScale.ts new file mode 100644 index 000000000..ec7750cfb --- /dev/null +++ b/server/routers/launcher/launcherScale.ts @@ -0,0 +1,124 @@ +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { + listLauncherGroupsForUser, + resolveAccessibleIds +} from "./launcherResourceAccess"; +import { + parseIdListParam, + type LauncherScaleInfo, + type LauncherScaleQuery +} from "./types"; + +export const LAUNCHER_FULL_MAX_RESOURCES = 2000; +export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200; +export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100; +export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20; + +const LAUNCHER_SCALE_TTL_SEC = 60; + +function launcherScaleCacheKey( + orgId: string, + userId: string, + roleIds: number[], + query: LauncherScaleQuery +) { + const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); + const filterKey = [ + query.query, + query.groupBy, + query.siteIds ?? "", + query.labelIds ?? "", + query.sort_by, + query.order + ].join("|"); + return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`; +} + +async function getLauncherScaleForUserUncached( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherScaleQuery +): Promise { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const resourceCount = + accessible.resourceIds.length + accessible.siteResourceIds.length; + + const baselineQuery = { + query: "", + groupBy: "site" as const, + siteIds: undefined, + labelIds: undefined, + sort_by: "name" as const, + order: "asc" as const, + page: 1, + pageSize: 1 + }; + + const [{ total: siteGroupCount }, { total: labelGroupCount }] = + await Promise.all([ + listLauncherGroupsForUser( + orgId, + userId, + userRoleIds, + baselineQuery + ), + listLauncherGroupsForUser(orgId, userId, userRoleIds, { + ...baselineQuery, + groupBy: "label" + }) + ]); + + const mode = + resourceCount <= LAUNCHER_FULL_MAX_RESOURCES && + siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS + ? "full" + : "compact"; + + const siteFilterIds = parseIdListParam(query.siteIds); + + const allowSiteGrouping = + siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || + (siteFilterIds.length > 0 && + siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); + + const allowLabelGrouping = + labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS; + + const requireSearchOrFilter = + mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; + + return { + mode, + resourceCount, + siteGroupCount, + labelGroupCount, + capabilities: { + allowSiteGrouping, + allowLabelGrouping, + requireSearchOrFilter + } + }; +} + +export async function getLauncherScaleForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherScaleQuery +): Promise { + const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + + const result = await getLauncherScaleForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC); + return result; +} diff --git a/server/routers/launcher/listLauncherScale.ts b/server/routers/launcher/listLauncherScale.ts new file mode 100644 index 000000000..2fc907b17 --- /dev/null +++ b/server/routers/launcher/listLauncherScale.ts @@ -0,0 +1,60 @@ +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 { getLauncherScaleForUser } from "./launcherScale"; +import { launcherScaleQuerySchema } from "./types"; + +export async function listLauncherScale( + 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 = launcherScaleQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const scale = await getLauncherScaleForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { scale }, + success: true, + error: false, + message: "Launcher scale retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher scale:", 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 0a252d74d..2b7ee7f6e 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -2,9 +2,10 @@ import { z } from "zod"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; +export const LAUNCHER_FLAT_GROUP_KEY = "__all__"; export const launcherViewConfigSchema = z.object({ - groupBy: z.enum(["site", "label"]).default("site"), + groupBy: z.enum(["site", "label", "none"]).default("site"), layout: z.enum(["grid", "list"]).default("grid"), sortBy: z.literal("name").default("name"), order: z.enum(["asc", "desc"]).default("asc"), @@ -138,7 +139,7 @@ export const launcherListQuerySchema = z.strictObject({ .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"), + groupBy: z.enum(["site", "label", "none"]).optional().default("site"), groupKey: z.string().optional(), siteIds: z.string().optional(), labelIds: z.string().optional(), @@ -163,3 +164,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; + +export type LauncherScaleCapabilities = { + allowSiteGrouping: boolean; + allowLabelGrouping: boolean; + requireSearchOrFilter: boolean; +}; + +export type LauncherScaleInfo = { + mode: "full" | "compact"; + resourceCount: number; + siteGroupCount: number; + labelGroupCount: number; + capabilities: LauncherScaleCapabilities; +}; + +export type ListLauncherScaleResponse = { + scale: LauncherScaleInfo; +}; + +export const launcherScaleQuerySchema = z.strictObject({ + query: z.string().optional().default(""), + groupBy: z.enum(["site", "label", "none"]).optional().default("site"), + 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 LauncherScaleQuery = z.infer; diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 7abb1b317..0197a5502 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -85,6 +85,7 @@ export default async function OrgPage(props: OrgPageProps) { activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} + scale={launcherData.scale} groups={launcherData.groups} groupsPagination={launcherData.groupsPagination} /> diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index fe81dc697..15db6ceea 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = { onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; scope?: "org" | "launcher"; + onClear?: () => void; + showClear?: boolean; }; export function formatMultiSitesSelectorLabel( @@ -42,7 +44,9 @@ export function MultiSitesSelector({ selectedSites, onSelectionChange, filterTypes, - scope = "org" + scope = "org", + onClear, + showClear = false }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); @@ -107,6 +111,14 @@ export function MultiSitesSelector({ {t("siteNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {sitesShown.map((site) => ( 0} + onClear={() => { + onSitesChange([]); + }} /> diff --git a/src/components/resource-launcher/LauncherFlatResourceList.tsx b/src/components/resource-launcher/LauncherFlatResourceList.tsx new file mode 100644 index 000000000..7bbc01ccb --- /dev/null +++ b/src/components/resource-launcher/LauncherFlatResourceList.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { hasActiveLauncherFilters } from "@app/lib/launcherScale"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherResource, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; +import { LauncherResourceGrid } from "./LauncherResourceGrid"; +import { LauncherResourceList } from "./LauncherResourceList"; + +type LauncherFlatResourceListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + onClearFilters?: () => void; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherFlatResourceList({ + orgId, + config, + onClearFilters, + onResourceSelect +}: LauncherFlatResourceListProps) { + const loadMoreRef = useRef(null); + + const resourceFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + groupKey: LAUNCHER_FLAT_GROUP_KEY, + 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, isFetching } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, resourceFilters) + }); + + 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]); + + if (resources.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + + return ( +
+ {config.layout === "grid" ? ( + + ) : ( + + )} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index c7fe6b905..ecf60a773 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -69,16 +69,20 @@ export function LauncherGroupList({ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery({ ...launcherQueries.groups(orgId, groupFilters), - initialData: { - pages: [ - { - groups: initialGroups, - pagination: groupsPagination - } - ], - pageParams: [1] - }, - refetchOnMount: false + ...(initialGroups.length > 0 + ? { + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false as const + } + : {}) }); const groups = data?.pages.flatMap((page) => page.groups) ?? []; diff --git a/src/components/resource-launcher/LauncherSearchFirstGate.tsx b/src/components/resource-launcher/LauncherSearchFirstGate.tsx new file mode 100644 index 000000000..ab9ea1226 --- /dev/null +++ b/src/components/resource-launcher/LauncherSearchFirstGate.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type LauncherSearchFirstGateProps = { + layout: "grid" | "list"; +}; + +function GhostResourceGrid() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function GhostResourceList() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function LauncherSearchFirstGate({ + layout +}: LauncherSearchFirstGateProps) { + const t = useTranslations(); + + return ( +
+
+ {layout === "grid" ? ( + + ) : ( + + )} +
+
+
+ +
+
+

+ {t("resourceLauncherSearchFirstTitle")} +

+

+ {t("resourceLauncherSearchFirstDescription")} +

+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 41143cd10..abce2e5e6 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -15,13 +15,19 @@ import { SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; -import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import type { + LauncherScaleCapabilities, + LauncherViewConfig +} from "@server/routers/launcher/types"; import { useTranslations } from "next-intl"; import { Settings } from "lucide-react"; type LauncherSettingsMenuProps = { config: LauncherViewConfig; isDefaultView: boolean; + capabilities: LauncherScaleCapabilities; + isCompactMode: boolean; + selectedGroupBy: LauncherViewConfig["groupBy"]; onConfigChange: (patch: Partial) => void; onDeleteView: () => void; }; @@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = { export function LauncherSettingsMenu({ config, isDefaultView, + capabilities, + isCompactMode, + selectedGroupBy, onConfigChange, onDeleteView }: LauncherSettingsMenuProps) { @@ -51,7 +60,7 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupBy")}

+ {isCompactMode ? ( +

+ {t("resourceLauncherCompactGroupingLocked")} +

+ ) : null} + {!isCompactMode && !capabilities.allowSiteGrouping ? ( +

+ {t("resourceLauncherSiteGroupingDisabled")} +

+ ) : null} + {!isCompactMode && !capabilities.allowLabelGrouping ? ( +

+ {t("resourceLauncherLabelGroupingDisabled")} +

+ ) : null}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index be5620d25..2cc9d50a2 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -29,12 +29,20 @@ import { } from "@app/lib/launcherUrlState"; import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + getEffectiveLauncherConfig, + shouldShowFlatResourceList, + shouldShowLauncherGroupList, + shouldShowSearchFirstGate +} from "@app/lib/launcherScale"; +import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherScaleInfo, LauncherViewConfig, LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -52,7 +60,9 @@ 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 { LauncherFlatResourceList } from "./LauncherFlatResourceList"; import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate"; import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; @@ -66,6 +76,7 @@ type ResourceLauncherProps = { activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; + scale: LauncherScaleInfo; groups: LauncherGroup[]; groupsPagination: { total: number; @@ -81,6 +92,7 @@ export default function ResourceLauncher({ activeViewId, config, savedConfig, + scale: initialScale, groups, groupsPagination }: ResourceLauncherProps) { @@ -100,6 +112,38 @@ export default function ResourceLauncher({ const isDesktop = useMediaQuery("(min-width: 768px)"); + const scaleFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order + }), + [ + config.groupBy, + config.labelIds, + config.order, + config.query, + config.siteIds, + config.sortBy + ] + ); + + const { data: scale = initialScale } = useQuery({ + ...launcherQueries.scale(orgId, scaleFilters), + initialData: initialScale + }); + + const showGroupList = shouldShowLauncherGroupList(scale, config); + const showSearchFirstGate = shouldShowSearchFirstGate(scale, config); + const showFlatResourceList = shouldShowFlatResourceList(scale, config); + const effectiveConfig = useMemo( + () => getEffectiveLauncherConfig(scale, config), + [config, scale] + ); + const configRef = useRef(config); configRef.current = config; const searchInputRef = useRef(config.query); @@ -451,6 +495,9 @@ export default function ResourceLauncher({ { if (!isDefaultView) { @@ -502,14 +549,31 @@ export default function ResourceLauncher({
)} - + {scale.mode === "compact" && !showSearchFirstGate ? ( +

+ {t("resourceLauncherCompactModeHint")} +

+ ) : null} + + {showSearchFirstGate ? ( + + ) : showGroupList ? ( + + ) : showFlatResourceList ? ( + + ) : null} diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts index 6e1fb60a5..1d2b64219 100644 --- a/src/lib/launcherLocalStorage.ts +++ b/src/lib/launcherLocalStorage.ts @@ -10,7 +10,7 @@ function lastViewKey(orgId: string) { function groupOpenKey( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label" + groupBy: "site" | "label" | "none" ) { return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; } @@ -64,7 +64,7 @@ export function writeLauncherLastView( export function readLauncherGroupOpenState( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label" + groupBy: "site" | "label" | "none" ): Record { return readJson>( groupOpenKey(orgId, viewId, groupBy), @@ -75,7 +75,7 @@ export function readLauncherGroupOpenState( export function readLauncherGroupOpen( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label", + groupBy: "site" | "label" | "none", groupKey: string, defaultOpen: boolean ): boolean { @@ -86,7 +86,7 @@ export function readLauncherGroupOpen( export function writeLauncherGroupOpen( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label", + groupBy: "site" | "label" | "none", groupKey: string, isOpen: boolean ) { diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts new file mode 100644 index 000000000..6b086bab9 --- /dev/null +++ b/src/lib/launcherScale.ts @@ -0,0 +1,95 @@ +import type { + LauncherScaleInfo, + LauncherViewConfig +} from "@server/routers/launcher/types"; + +export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { + return ( + config.query.trim().length > 0 || + config.siteIds.length > 0 || + config.labelIds.length > 0 + ); +} + +export function getEffectiveGroupBy( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): LauncherViewConfig["groupBy"] { + if (scale.mode === "compact") { + return "none"; + } + + return config.groupBy; +} + +export function getEffectiveLauncherConfig( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): LauncherViewConfig { + const groupBy = getEffectiveGroupBy(scale, config); + if (groupBy === config.groupBy) { + return config; + } + + return { ...config, groupBy }; +} + +export function shouldFetchLauncherGroups( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + const groupBy = getEffectiveGroupBy(scale, config); + + if (groupBy === "none") { + return false; + } + + if (scale.mode === "full") { + return true; + } + + return ( + scale.capabilities.allowSiteGrouping && + groupBy === "site" && + config.siteIds.length > 0 + ); +} + +export function shouldShowLauncherGroupList( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + return shouldFetchLauncherGroups(scale, config); +} + +export function shouldShowSearchFirstGate( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + return ( + scale.mode === "compact" && + scale.capabilities.requireSearchOrFilter && + !hasActiveLauncherFilters(config) + ); +} + +export function shouldShowFlatResourceList( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + if (shouldShowSearchFirstGate(scale, config)) { + return false; + } + + const groupBy = getEffectiveGroupBy(scale, config); + + if (groupBy === "none") { + return true; + } + + if (scale.mode === "full") { + return false; + } + + return !shouldShowLauncherGroupList(scale, config); +} diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index 2c542c5fc..994ca2ff4 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -1,12 +1,15 @@ import { internal } from "@app/lib/api"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { shouldFetchLauncherGroups } from "@app/lib/launcherScale"; import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, + LauncherScaleInfo, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, + ListLauncherScaleResponse, ListLauncherViewsResponse } from "@server/routers/launcher/types"; import { AxiosResponse } from "axios"; @@ -16,6 +19,7 @@ export type LauncherPageData = { activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; + scale: LauncherScaleInfo; groups: LauncherGroup[]; groupsPagination: { total: number; @@ -45,6 +49,37 @@ export async function fetchLauncherPageData( null ); + const scaleFilters = { + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order + }; + + let scale: LauncherScaleInfo = { + mode: "full", + resourceCount: 0, + siteGroupCount: 0, + labelGroupCount: 0, + capabilities: { + allowSiteGrouping: true, + allowLabelGrouping: true, + requireSearchOrFilter: false + } + }; + + try { + const sp = buildLauncherSearchParams(scaleFilters, 1); + sp.delete("page"); + sp.delete("pageSize"); + const scaleRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader); + scale = scaleRes.data.data.scale; + } catch (e) {} + const groupFilters = { query: config.query, groupBy: config.groupBy, @@ -62,20 +97,23 @@ export async function fetchLauncherPageData( 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) {} + if (shouldFetchLauncherGroups(scale, config)) { + 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) {} + } return { views, activeViewId, config, savedConfig, + scale, groups, groupsPagination }; diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts index cfe8e26c4..c3b2da568 100644 --- a/src/lib/launcherUrlState.ts +++ b/src/lib/launcherUrlState.ts @@ -113,7 +113,7 @@ function parseConfigOverrides( } const groupBy = searchParams.get("groupBy"); - if (groupBy === "site" || groupBy === "label") { + if (groupBy === "site" || groupBy === "label" || groupBy === "none") { overrides.groupBy = groupBy; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 14fa1d3da..5ca6624d6 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -50,6 +50,7 @@ import type { ListLauncherGroupsResponse, ListLauncherLabelsResponse, ListLauncherResourcesResponse, + ListLauncherScaleResponse, ListLauncherSitesResponse, ListLauncherViewsResponse, LauncherListQuery, @@ -1298,5 +1299,19 @@ export const launcherQueries = { const nextPage = page + 1; return page * pageSize < total ? nextPage : undefined; } + }), + scale: (orgId: string, filters: LauncherQueryFilters) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const, + queryFn: async ({ signal, meta }) => { + const sp = buildLauncherSearchParams(filters, 1); + sp.delete("page"); + sp.delete("pageSize"); + sp.delete("groupKey"); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal }); + return res.data.data.scale; + } }) }; From 12f9ac94fd63ca02c7cdfcd09a40abbfdf1f2b3d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 22:07:30 -0400 Subject: [PATCH 225/264] allow grouping when filter applied --- messages/en-US.json | 2 +- server/routers/launcher/launcherScale.ts | 5 ++- .../LauncherSettingsMenu.tsx | 12 ++++--- .../resource-launcher/ResourceLauncher.tsx | 6 ---- src/lib/launcherScale.ts | 31 +++++++++++++++---- 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e699aba9f..fede28c11 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3613,7 +3613,7 @@ "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", - "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.", + "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts index ec7750cfb..5d9eb42c2 100644 --- a/server/routers/launcher/launcherScale.ts +++ b/server/routers/launcher/launcherScale.ts @@ -76,6 +76,7 @@ async function getLauncherScaleForUserUncached( : "compact"; const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); const allowSiteGrouping = siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || @@ -83,7 +84,9 @@ async function getLauncherScaleForUserUncached( siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); const allowLabelGrouping = - labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS; + labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || + (labelFilterIds.length > 0 && + labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); const requireSearchOrFilter = mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index abce2e5e6..2cf2a3cbb 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -78,8 +78,9 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupBySite")} @@ -87,8 +88,9 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupByLabel")} @@ -97,7 +99,7 @@ export function LauncherSettingsMenu({ {isCompactMode ? (

- {t("resourceLauncherCompactGroupingLocked")} + {t("resourceLauncherCompactGroupingHint")}

) : null} {!isCompactMode && !capabilities.allowSiteGrouping ? ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 2cc9d50a2..23db498ea 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -549,12 +549,6 @@ export default function ResourceLauncher({
)} - {scale.mode === "compact" && !showSearchFirstGate ? ( -

- {t("resourceLauncherCompactModeHint")} -

- ) : null} - {showSearchFirstGate ? ( ) : showGroupList ? ( diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts index 6b086bab9..a9e03ecb4 100644 --- a/src/lib/launcherScale.ts +++ b/src/lib/launcherScale.ts @@ -15,11 +15,27 @@ export function getEffectiveGroupBy( scale: LauncherScaleInfo, config: LauncherViewConfig ): LauncherViewConfig["groupBy"] { - if (scale.mode === "compact") { - return "none"; + if (scale.mode !== "compact") { + return config.groupBy; } - return config.groupBy; + if ( + config.groupBy === "site" && + config.siteIds.length > 0 && + scale.capabilities.allowSiteGrouping + ) { + return "site"; + } + + if ( + config.groupBy === "label" && + config.labelIds.length > 0 && + scale.capabilities.allowLabelGrouping + ) { + return "label"; + } + + return "none"; } export function getEffectiveLauncherConfig( @@ -49,9 +65,12 @@ export function shouldFetchLauncherGroups( } return ( - scale.capabilities.allowSiteGrouping && - groupBy === "site" && - config.siteIds.length > 0 + (scale.capabilities.allowSiteGrouping && + groupBy === "site" && + config.siteIds.length > 0) || + (scale.capabilities.allowLabelGrouping && + groupBy === "label" && + config.labelIds.length > 0) ); } From f0546eb6224eb17a15a1a1bbb1a12d093bbea1dd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 22:27:51 -0400 Subject: [PATCH 226/264] add save default view --- messages/en-US.json | 13 +- server/routers/external.ts | 12 + server/routers/launcher/createLauncherView.ts | 10 + .../launcher/deleteLauncherDefaultView.ts | 104 +++++++++ server/routers/launcher/deleteLauncherView.ts | 10 + server/routers/launcher/index.ts | 2 + .../routers/launcher/launcherDefaultView.ts | 144 ++++++++++++ server/routers/launcher/listLauncherViews.ts | 23 +- server/routers/launcher/types.ts | 22 ++ server/routers/launcher/updateLauncherView.ts | 10 + .../launcher/upsertLauncherDefaultView.ts | 82 +++++++ src/app/[orgId]/page.tsx | 1 + .../LauncherSettingsMenu.tsx | 16 +- .../resource-launcher/LauncherViewTabs.tsx | 41 +++- .../resource-launcher/ResourceLauncher.tsx | 211 +++++++++++++++--- src/lib/launcherServerData.ts | 11 +- src/lib/launcherUrlState.ts | 26 ++- src/lib/queries.ts | 2 +- 18 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 server/routers/launcher/deleteLauncherDefaultView.ts create mode 100644 server/routers/launcher/launcherDefaultView.ts create mode 100644 server/routers/launcher/upsertLauncherDefaultView.ts diff --git a/messages/en-US.json b/messages/en-US.json index fede28c11..a4e99d5c5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3571,12 +3571,16 @@ "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...", + "resourceLauncherDescription": "View all available resources and launch them from one central hub", + "resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveDefaultPersonal": "Save for Me", "resourceLauncherResetView": "Reset View", + "resourceLauncherResetSystemDefault": "Reset to System Default", + "resourceLauncherSystemDefaultRestored": "System default restored", + "resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", @@ -3598,6 +3602,9 @@ "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", + "resourceLauncherDeleteViewTitle": "Delete View", + "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", + "resourceLauncherDeleteViewConfirm": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", @@ -3609,7 +3616,7 @@ "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.", "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", - "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.", "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", diff --git a/server/routers/external.ts b/server/routers/external.ts index 43cf88a43..2b95c2bfc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -512,6 +512,18 @@ authenticated.put( launcher.updateLauncherView ); +authenticated.put( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.upsertLauncherDefaultView +); + +authenticated.delete( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.deleteLauncherDefaultView +); + authenticated.delete( "/org/:orgId/launcher/views/:viewId", verifyOrgAccess, diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 593bad304..4c77cd4f0 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -7,6 +7,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -40,6 +41,15 @@ export async function createLauncherView( ); } + if (isLauncherDefaultOverrideViewName(parsed.data.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This view name is reserved" + ) + ); + } + if (parsed.data.orgWide) { const canCreateOrgWide = await checkUserActionPermission( ActionsEnum.createOrgWideLauncherView, diff --git a/server/routers/launcher/deleteLauncherDefaultView.ts b/server/routers/launcher/deleteLauncherDefaultView.ts new file mode 100644 index 000000000..50a1a7beb --- /dev/null +++ b/server/routers/launcher/deleteLauncherDefaultView.ts @@ -0,0 +1,104 @@ +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 { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAllDefaultViewOverrides, + deleteDefaultViewOverride +} from "./launcherDefaultView"; + +const deleteLauncherDefaultViewBodySchema = z.strictObject({ + orgWide: z.boolean().optional().default(false), + all: z.boolean().optional().default(false) +}); + +export async function deleteLauncherDefaultView( + 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 = deleteLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.all) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteAllDefaultViewOverrides(orgId, userId); + } else if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: true + }); + } else { + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: false + }); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher default view reset successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error resetting launcher default 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 index beaf1b433..41d7dd214 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; export async function deleteLauncherView( req: Request, @@ -46,6 +47,15 @@ export async function deleteLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The default view cannot be deleted from here" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index e941bbe10..b9edafc22 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -8,3 +8,5 @@ export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; +export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView"; +export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView"; diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts new file mode 100644 index 000000000..4d180bd1d --- /dev/null +++ b/server/routers/launcher/launcherDefaultView.ts @@ -0,0 +1,144 @@ +import { db, launcherViews } from "@server/db"; +import { and, eq, isNull } from "drizzle-orm"; +import moment from "moment"; +import { + LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + launcherViewConfigSchema, + type LauncherDefaultViewOverrides, + type LauncherViewConfig, + type LauncherViewRecord +} from "./types"; + +export function isLauncherDefaultOverrideViewName(name: string) { + return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME; +} + +export 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 function extractDefaultViewOverrides( + rows: Array +): LauncherDefaultViewOverrides { + const overrideRows = rows.filter((row) => + isLauncherDefaultOverrideViewName(row.name) + ); + + const personalRow = overrideRows.find((row) => row.userId !== null); + const orgWideRow = overrideRows.find((row) => row.userId === null); + + return { + personal: personalRow ? mapViewRow(personalRow) : null, + orgWide: orgWideRow ? mapViewRow(orgWideRow) : null + }; +} + +export function listVisibleLauncherViews( + rows: Array +): LauncherViewRecord[] { + return rows + .filter((row) => !isLauncherDefaultOverrideViewName(row.name)) + .map(mapViewRow); +} + +export async function findDefaultViewOverride( + orgId: string, + orgWide: boolean, + userId: string +) { + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME), + orgWide + ? isNull(launcherViews.userId) + : eq(launcherViews.userId, userId) + ) + ) + .limit(1); + + return existing ?? null; +} + +export async function upsertDefaultViewOverride({ + orgId, + userId, + orgWide, + config +}: { + orgId: string; + userId: string; + orgWide: boolean; + config: LauncherViewConfig; +}) { + const now = moment().toISOString(); + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + + if (existing) { + const [updated] = await db + .update(launcherViews) + .set({ + config: JSON.stringify(config), + updatedAt: now + }) + .where(eq(launcherViews.viewId, existing.viewId)) + .returning(); + + return mapViewRow(updated); + } + + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: orgWide ? null : userId, + name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + config: JSON.stringify(config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return mapViewRow(created); +} + +export async function deleteDefaultViewOverride({ + orgId, + userId, + orgWide +}: { + orgId: string; + userId: string; + orgWide: boolean; +}) { + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + if (!existing) { + return; + } + + await db + .delete(launcherViews) + .where(eq(launcherViews.viewId, existing.viewId)); +} + +export async function deleteAllDefaultViewOverrides( + orgId: string, + userId: string +) { + await deleteDefaultViewOverride({ orgId, userId, orgWide: false }); + await deleteDefaultViewOverride({ orgId, userId, orgWide: true }); +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index e176bae46..8cdcf76ef 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -4,22 +4,10 @@ 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"; - -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 - }; -} +import { + extractDefaultViewOverrides, + listVisibleLauncherViews +} from "./launcherDefaultView"; export async function listLauncherViews( req: Request, @@ -51,7 +39,8 @@ export async function listLauncherViews( return response(res, { data: { - views: rows.map(mapViewRow) + views: listVisibleLauncherViews(rows), + defaultViewOverrides: extractDefaultViewOverrides(rows) }, success: true, error: false, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 2b7ee7f6e..9f9701c96 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -91,8 +91,28 @@ export type LauncherViewRecord = { isOrgWide: boolean; }; +export type LauncherDefaultViewOverrides = { + personal: LauncherViewRecord | null; + orgWide: LauncherViewRecord | null; +}; + +export function getEffectiveDefaultLauncherConfig( + overrides: LauncherDefaultViewOverrides +): LauncherViewConfig { + if (overrides.personal) { + return overrides.personal.config; + } + + if (overrides.orgWide) { + return overrides.orgWide.config; + } + + return defaultLauncherViewConfig; +} + export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; }; export const launcherFilterListQuerySchema = z.strictObject({ @@ -161,6 +181,8 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; +export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__"; + export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 9450da153..757a16dac 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -9,6 +9,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -66,6 +67,15 @@ export async function updateLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Use the default view save endpoint for this view" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/upsertLauncherDefaultView.ts b/server/routers/launcher/upsertLauncherDefaultView.ts new file mode 100644 index 000000000..dd9c8bdba --- /dev/null +++ b/server/routers/launcher/upsertLauncherDefaultView.ts @@ -0,0 +1,82 @@ +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 { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { upsertDefaultViewOverride } from "./launcherDefaultView"; +import { launcherViewConfigSchema } from "./types"; + +const upsertLauncherDefaultViewBodySchema = z.strictObject({ + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function upsertLauncherDefaultView( + 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 = upsertLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + const view = await upsertDefaultViewOverride({ + orgId, + userId, + orgWide: parsed.data.orgWide, + config: parsed.data.config + }); + + return response(res, { + data: view, + success: true, + error: false, + message: "Launcher default view saved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error saving launcher default 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 0197a5502..d543b2752 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -82,6 +82,7 @@ export default async function OrgPage(props: OrgPageProps) { orgId={orgId} isAdmin={isAdminOrOwner} views={launcherData.views} + defaultViewOverrides={launcherData.defaultViewOverrides} activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 2cf2a3cbb..fc10d4a7f 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -24,22 +24,18 @@ import { Settings } from "lucide-react"; type LauncherSettingsMenuProps = { config: LauncherViewConfig; - isDefaultView: boolean; capabilities: LauncherScaleCapabilities; isCompactMode: boolean; selectedGroupBy: LauncherViewConfig["groupBy"]; onConfigChange: (patch: Partial) => void; - onDeleteView: () => void; }; export function LauncherSettingsMenu({ config, - isDefaultView, capabilities, isCompactMode, selectedGroupBy, - onConfigChange, - onDeleteView + onConfigChange }: LauncherSettingsMenuProps) { const t = useTranslations(); @@ -157,16 +153,6 @@ export function LauncherSettingsMenu({ />
- - {!isDefaultView ? ( - - ) : null}
diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx index 98d8f7289..0b0261560 100644 --- a/src/components/resource-launcher/LauncherViewTabs.tsx +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = { isAdmin: boolean; isOrgWideView: boolean; hasUnsavedChanges: boolean; + canResetSystemDefault: boolean; onSaveToCurrent: () => void; onSaveAsNew: () => void; onSaveForEveryone: () => void; onMakePersonal: () => void; onResetView: () => void; + onResetSystemDefault: () => void; + onDeleteView: () => void; }; export function LauncherSaveViewMenu({ @@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({ isAdmin, isOrgWideView, hasUnsavedChanges, + canResetSystemDefault, onSaveToCurrent, onSaveAsNew, onSaveForEveryone, onMakePersonal, - onResetView + onResetView, + onResetSystemDefault, + onDeleteView }: LauncherSaveViewMenuProps) { const t = useTranslations(); + const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView); return ( @@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({ ) : null} - {!isDefaultView && (isAdmin || !isOrgWideView) ? ( + {isDefaultView && canResetSystemDefault ? ( + <> + + {t("resourceLauncherResetSystemDefault")} + + + + ) : null} + {isDefaultView && hasUnsavedChanges ? ( + <> + + {t("resourceLauncherSaveDefaultPersonal")} + + {isAdmin ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + + ) : !isDefaultView && (isAdmin || !isOrgWideView) ? ( {t("resourceLauncherSaveToCurrentView")} @@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({ {t("resourceLauncherMakePersonal")} ) : null} + {canDeleteView ? ( + <> + + + {t("resourceLauncherDeleteView")} + + + ) : null} ); diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 23db498ea..9f9d44d11 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -36,11 +36,13 @@ import { shouldShowSearchFirstGate } from "@app/lib/launcherScale"; import { launcherQueries } from "@app/lib/queries"; -import type { - LauncherGroup, - LauncherScaleInfo, - LauncherViewConfig, - LauncherViewRecord +import { + getEffectiveDefaultLauncherConfig, + type LauncherDefaultViewOverrides, + type LauncherGroup, + type LauncherScaleInfo, + type LauncherViewConfig, + type LauncherViewRecord } from "@server/routers/launcher/types"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; @@ -67,12 +69,14 @@ import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourceLauncherProps = { orgId: string; isAdmin: boolean; views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; @@ -89,6 +93,7 @@ export default function ResourceLauncher({ orgId, isAdmin, views, + defaultViewOverrides, activeViewId, config, savedConfig, @@ -107,6 +112,7 @@ export default function ResourceLauncher({ const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -173,13 +179,24 @@ export default function ResourceLauncher({ return; } - const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const baseConfig = getLauncherUrlBaseConfig( + lastView, + views, + defaultViewOverrides + ); const params = serializeLauncherUrlState({ viewId: lastView, config: baseConfig }); navigate({ searchParams: params, replace: true }); - }, [activeViewId, navigate, orgId, searchParams, views]); + }, [ + activeViewId, + defaultViewOverrides, + navigate, + orgId, + searchParams, + views + ]); const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { @@ -202,10 +219,14 @@ export default function ResourceLauncher({ const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); - const baseConfig = getLauncherUrlBaseConfig(viewId, views); + const baseConfig = getLauncherUrlBaseConfig( + viewId, + views, + defaultViewOverrides + ); navigateToConfig(viewId, baseConfig); }, - [navigateToConfig, orgId, views] + [defaultViewOverrides, navigateToConfig, orgId, views] ); const activeSavedView = useMemo( @@ -219,6 +240,10 @@ export default function ResourceLauncher({ const isDefaultView = activeViewId === "default"; const isOrgWideView = Boolean(activeSavedView?.isOrgWide); const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); + const canResetSystemDefault = + isDefaultView && + (Boolean(defaultViewOverrides.personal) || + (isAdmin && Boolean(defaultViewOverrides.orgWide))); const selectedSites: Selectedsite[] = useMemo( () => @@ -314,6 +339,87 @@ export default function ResourceLauncher({ } }); + const saveDefaultViewMutation = useMutation({ + mutationFn: async (payload: { + config: LauncherViewConfig; + orgWide: boolean; + }) => { + const res = await api.put( + `/org/${orgId}/launcher/default-view`, + payload + ); + return res.data.data as LauncherViewRecord; + }, + onSuccess: () => { + writeLauncherLastView(orgId, "default"); + const params = serializeLauncherUrlState({ + viewId: "default", + config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const resetSystemDefaultMutation = useMutation({ + mutationFn: async () => { + const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide); + await api.delete(`/org/${orgId}/launcher/default-view`, { + data: resetAll ? { all: true } : { orgWide: false } + }); + }, + onSuccess: () => { + writeLauncherLastView(orgId, "default"); + const nextOverrides: LauncherDefaultViewOverrides = { + personal: null, + orgWide: + isAdmin && defaultViewOverrides.orgWide + ? null + : defaultViewOverrides.orgWide + }; + const targetConfig = + getEffectiveDefaultLauncherConfig(nextOverrides); + searchInputRef.current = targetConfig.query; + setSearchInputResetKey((key) => key + 1); + const params = serializeLauncherUrlState({ + viewId: "default", + config: targetConfig + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherSystemDefaultRestored"), + description: t( + "resourceLauncherSystemDefaultRestoredDescription" + ) + }); + }, + 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}`); @@ -322,7 +428,11 @@ export default function ResourceLauncher({ writeLauncherLastView(orgId, "default"); const params = serializeLauncherUrlState({ viewId: "default", - config: getLauncherUrlBaseConfig("default", views) + config: getLauncherUrlBaseConfig( + "default", + views, + defaultViewOverrides + ) }); navigate({ searchParams: params, replace: true }); router.refresh(); @@ -372,6 +482,10 @@ export default function ResourceLauncher({ navigateToConfig(activeViewIdRef.current, savedConfig); }, [navigateToConfig, savedConfig]); + const handleResetSystemDefault = useCallback(() => { + resetSystemDefaultMutation.mutate(); + }, [resetSystemDefaultMutation]); + const refreshData = () => { startRefreshTransition(async () => { try { @@ -387,7 +501,14 @@ export default function ResourceLauncher({ }; const handleSaveToCurrent = () => { - if (isDefaultView || (isOrgWideView && !isAdmin)) { + if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: false + }); + return; + } + if (isOrgWideView && !isAdmin) { return; } updateViewMutation.mutate({ @@ -404,6 +525,10 @@ export default function ResourceLauncher({ const handleSaveForEveryone = () => { if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: true + }); return; } updateViewMutation.mutate({ @@ -456,19 +581,8 @@ export default function ResourceLauncher({
); - const renderToolbarActions = () => ( + const renderToolbarFilterSort = () => ( <> - + + ); + + const renderToolbarActions = () => ( + <> + setDeleteDialogOpen(true)} + /> { - if (!isDefaultView) { - deleteViewMutation.mutate(activeViewId); - } - }} /> {renderToolbarSearch("w-64")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -542,7 +672,12 @@ export default function ResourceLauncher({
{renderToolbarActions()}
- {renderToolbarSearch("w-full")} +
+
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -569,6 +704,22 @@ export default function ResourceLauncher({ /> ) : null} + {activeSavedView ? ( + {t("resourceLauncherDeleteViewQuestion")}

} + onConfirm={async () => { + await deleteViewMutation.mutateAsync( + activeSavedView.viewId + ); + }} + /> + ) : null} + diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index 994ca2ff4..8abb0d2bc 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -6,6 +6,7 @@ import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, LauncherScaleInfo, + LauncherDefaultViewOverrides, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, @@ -16,6 +17,7 @@ import { AxiosResponse } from "axios"; export type LauncherPageData = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; @@ -36,17 +38,23 @@ export async function fetchLauncherPageData( > ): Promise { let views: LauncherViewRecord[] = []; + let defaultViewOverrides: LauncherDefaultViewOverrides = { + personal: null, + orgWide: null + }; try { const viewsRes = await internal.get< AxiosResponse >(`/org/${orgId}/launcher/views`, cookieHeader); views = viewsRes.data.data.views; + defaultViewOverrides = viewsRes.data.data.defaultViewOverrides; } catch (e) {} const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( searchParams, views, - null + null, + defaultViewOverrides ); const scaleFilters = { @@ -110,6 +118,7 @@ export async function fetchLauncherPageData( return { views, + defaultViewOverrides, activeViewId, config, savedConfig, diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts index c3b2da568..dff904568 100644 --- a/src/lib/launcherUrlState.ts +++ b/src/lib/launcherUrlState.ts @@ -1,7 +1,9 @@ import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import { defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig, parseIdListParam, + type LauncherDefaultViewOverrides, type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; @@ -64,10 +66,13 @@ export function isLauncherConfigEqual( export function getLauncherUrlBaseConfig( viewId: LauncherActiveViewId, - views: LauncherViewRecord[] + views: LauncherViewRecord[], + defaultViewOverrides?: LauncherDefaultViewOverrides ): LauncherViewConfig { if (viewId === "default") { - return defaultLauncherViewConfig; + return defaultViewOverrides + ? getEffectiveDefaultLauncherConfig(defaultViewOverrides) + : defaultLauncherViewConfig; } const savedView = views.find((view) => view.viewId === viewId); @@ -172,7 +177,8 @@ function isValidActiveViewId( export function resolveLauncherStateFromUrl( searchParams: URLSearchParams, views: LauncherViewRecord[], - fallbackViewId: LauncherActiveViewId | null + fallbackViewId: LauncherActiveViewId | null, + defaultViewOverrides?: LauncherDefaultViewOverrides ): ResolvedLauncherState { const parsed = parseLauncherUrlState(searchParams); @@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl( : "default"; } - const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + const savedConfig = getLauncherUrlBaseConfig( + activeViewId, + views, + defaultViewOverrides + ); let config: LauncherViewConfig; if (hasLauncherConfigParams(searchParams)) { config = resolveLauncherConfig( - defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ), parsed.configOverrides ); } else if (activeViewId !== "default") { config = savedConfig; } else { - config = defaultLauncherViewConfig; + config = getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ); } return { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5ca6624d6..2626a3ba3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1190,7 +1190,7 @@ export const launcherQueries = { const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/views`, { signal }); - return res.data.data.views; + return res.data.data; } }), sites: ({ From a89509c8bd09034527ec790de58b5094f83543fc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 23:32:16 -0400 Subject: [PATCH 227/264] add more caching --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 291 +++++++++++++++--- server/routers/launcher/launcherScale.ts | 108 ++++--- .../LauncherFilterPopover.tsx | 23 +- 4 files changed, 334 insertions(+), 89 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a4e99d5c5..45393e184 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3587,6 +3587,7 @@ "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", "resourceLauncherMakePersonal": "Make Personal", "resourceLauncherFilter": "Filter", + "resourceLauncherFilterWithCount": "Filter, {count} applied", "resourceLauncherSort": "Sort", "resourceLauncherSortAscending": "Sort ascending", "resourceLauncherSortDescending": "Sort descending", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 2ce6d87d4..246f86688 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { db } from "@server/db"; import { exitNodes, @@ -31,7 +32,8 @@ import { isNull, like, or, - sql + sql, + type SQL } from "drizzle-orm"; import { formatPublicResourceAccess, @@ -60,6 +62,68 @@ export type AccessibleIds = { }; const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; +const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60; +const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60; + +type LauncherResourcesCacheEntry = { + items: LauncherResource[]; + total: number; +}; + +type LauncherGroupsCacheEntry = { + groups: LauncherGroup[]; + total: number; +}; + +function launcherListQueryHash( + userRoleIds: number[], + query: LauncherListQuery, + extra?: Record +) { + const payload = JSON.stringify({ + roles: [...userRoleIds].sort((a, b) => a - b), + query: query.query, + groupBy: query.groupBy, + siteIds: query.siteIds ?? "", + labelIds: query.labelIds ?? "", + sort_by: query.sort_by, + order: query.order, + ...extra + }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} + +function launcherResourcesQueryHash( + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +) { + return launcherListQueryHash(userRoleIds, query, { + groupKey: query.groupKey + }); +} + +function launcherGroupsQueryHash( + userRoleIds: number[], + query: LauncherListQuery +) { + return launcherListQueryHash(userRoleIds, query); +} + +function launcherResourcesCacheKey( + orgId: string, + userId: string, + queryHash: string +) { + return `launcher:results:${orgId}:${userId}:${queryHash}`; +} + +function launcherGroupsCacheKey( + orgId: string, + userId: string, + queryHash: string +) { + return `launcher:groups:${orgId}:${userId}:${queryHash}`; +} function launcherAccessibleIdsCacheKey( orgId: string, @@ -195,6 +259,21 @@ function searchPattern(query: string) { return `%${query.trim()}%`; } +function combineOrConditions( + ...conditions: (SQL | undefined)[] +): SQL | undefined { + const parts = conditions.filter( + (condition): condition is SQL => !!condition + ); + if (parts.length === 0) { + return undefined; + } + if (parts.length === 1) { + return parts[0]; + } + return or(...parts); +} + function buildSearchConditionForPublic( query: string, labelsFeatureEnabled: boolean @@ -206,7 +285,17 @@ function buildSearchConditionForPublic( const queryList = [ like(sql`LOWER(${resources.name})`, pattern), like(sql`LOWER(${resources.fullDomain})`, pattern), - like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) + like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern), + inArray( + resources.resourceId, + db + .select({ id: resources.resourceId }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(like(sql`LOWER(${exitNodes.endpoint})`, pattern)) + ) ]; if (labelsFeatureEnabled) { @@ -239,6 +328,11 @@ function buildSearchConditionForSiteResource( const queryList = [ like(sql`LOWER(${siteResources.name})`, pattern), like(sql`LOWER(${siteResources.destination})`, pattern), + like( + sql`LOWER(cast(${siteResources.destinationPort} as text))`, + pattern + ), + like(sql`LOWER(${siteResources.scheme})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern), like(sql`LOWER(${siteResources.aliasAddress})`, pattern) @@ -263,6 +357,79 @@ function buildSearchConditionForSiteResource( return or(...queryList); } +async function filterPublicResourceIdsByTextSearch( + orgId: string, + resourceIds: number[], + query: string, + labelsFeatureEnabled: boolean +): Promise { + if (!query.trim() || resourceIds.length === 0) { + return resourceIds; + } + + const textMatch = combineOrConditions( + buildSearchConditionForPublic(query, labelsFeatureEnabled), + buildSiteNameSearchCondition(query) + ); + if (!textMatch) { + return resourceIds; + } + + const rows = await db + .selectDistinct({ resourceId: resources.resourceId }) + .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), + textMatch + ) + ); + + return rows.map((row) => row.resourceId); +} + +async function filterSiteResourceIdsByTextSearch( + orgId: string, + siteResourceIds: number[], + query: string, + labelsFeatureEnabled: boolean +): Promise { + if (!query.trim() || siteResourceIds.length === 0) { + return siteResourceIds; + } + + const textMatch = combineOrConditions( + buildSearchConditionForSiteResource(query, labelsFeatureEnabled), + buildSiteNameSearchCondition(query) + ); + if (!textMatch) { + return siteResourceIds; + } + + const rows = await db + .selectDistinct({ siteResourceId: siteResources.siteResourceId }) + .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), + textMatch + ) + ); + + return rows.map((row) => row.siteResourceId); +} + async function labelsEnabled(orgId: string): Promise { return isLicensedOrSubscribed(orgId, tierMatrix.labels); } @@ -589,9 +756,8 @@ async function listSiteGroups( }); const total = groups.length; - const offset = (query.page - 1) * query.pageSize; return { - groups: groups.slice(offset, offset + query.pageSize), + groups, total }; } @@ -789,26 +955,53 @@ async function listLabelGroups( }); const total = groups.length; - const offset = (query.page - 1) * query.pageSize; return { - groups: groups.slice(offset, offset + query.pageSize), + groups, total }; } +async function listLauncherGroupsForUserUncached( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery +): Promise { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + if (query.groupBy === "label") { + return listLabelGroups(orgId, accessible, query); + } + + return listSiteGroups(orgId, accessible, query); +} + export async function listLauncherGroupsForUser( orgId: string, userId: string, userRoleIds: number[], query: LauncherListQuery ): Promise<{ groups: LauncherGroup[]; total: number }> { - const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const queryHash = launcherGroupsQueryHash(userRoleIds, query); + const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash); + const cached = await cache.get(cacheKey); - if (query.groupBy === "label") { - return listLabelGroups(orgId, accessible, query); + let result = cached; + if (!result) { + result = await listLauncherGroupsForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC); } - return listSiteGroups(orgId, accessible, query); + const offset = (query.page - 1) * query.pageSize; + return { + groups: result.groups.slice(offset, offset + query.pageSize), + total: result.total + }; } async function mapPublicResources( @@ -1019,26 +1212,6 @@ function filterResourcesByLabel( ); } -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" @@ -1051,12 +1224,12 @@ function sortLauncherResources( }); } -export async function listLauncherResourcesForUser( +async function listLauncherResourcesForUserUncached( orgId: string, userId: string, userRoleIds: number[], query: LauncherListQuery & { groupKey: string } -): Promise<{ resources: LauncherResource[]; total: number }> { +): Promise { const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const siteFilterIds = parseIdListParam(query.siteIds); @@ -1129,6 +1302,27 @@ export async function listLauncherResourcesForUser( } } + const labelsFeatureEnabled = await labelsEnabled(orgId); + + if (query.query.trim()) { + if (filteredResourceIds.length > 0) { + filteredResourceIds = await filterPublicResourceIdsByTextSearch( + orgId, + filteredResourceIds, + query.query, + labelsFeatureEnabled + ); + } + if (filteredSiteResourceIds.length > 0) { + filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch( + orgId, + filteredSiteResourceIds, + query.query, + labelsFeatureEnabled + ); + } + } + const labelMaps = await fetchLabelsForResources( orgId, filteredResourceIds, @@ -1160,7 +1354,6 @@ export async function listLauncherResourcesForUser( ]); let items = [...publicItems, ...siteItems]; - items = filterResourcesBySearch(items, query.query); if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { if (query.groupBy === "label") { @@ -1172,11 +1365,37 @@ export async function listLauncherResourcesForUser( items = sortLauncherResources(items, query.order); - const total = items.length; + return { + items, + total: items.length + }; +} + +export async function listLauncherResourcesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + const queryHash = launcherResourcesQueryHash(userRoleIds, query); + const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash); + const cached = await cache.get(cacheKey); + + let result = cached; + if (!result) { + result = await listLauncherResourcesForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC); + } + const offset = (query.page - 1) * query.pageSize; return { - resources: items.slice(offset, offset + query.pageSize), - total + resources: result.items.slice(offset, offset + query.pageSize), + total: result.total }; } diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts index 5d9eb42c2..8d512834b 100644 --- a/server/routers/launcher/launcherScale.ts +++ b/server/routers/launcher/launcherScale.ts @@ -14,32 +14,57 @@ export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200; export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100; export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20; -const LAUNCHER_SCALE_TTL_SEC = 60; +const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60; -function launcherScaleCacheKey( +type LauncherScaleCountsCacheEntry = { + resourceCount: number; + siteGroupCount: number; + labelGroupCount: number; + mode: LauncherScaleInfo["mode"]; +}; + +function launcherScaleCountsCacheKey( orgId: string, userId: string, - roleIds: number[], - query: LauncherScaleQuery + roleIds: number[] ) { const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); - const filterKey = [ - query.query, - query.groupBy, - query.siteIds ?? "", - query.labelIds ?? "", - query.sort_by, - query.order - ].join("|"); - return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`; + return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`; } -async function getLauncherScaleForUserUncached( +function buildScaleCapabilities( + counts: LauncherScaleCountsCacheEntry, + query: LauncherScaleQuery +): LauncherScaleInfo["capabilities"] { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + + return { + allowSiteGrouping: + counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || + (siteFilterIds.length > 0 && + siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX), + allowLabelGrouping: + counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || + (labelFilterIds.length > 0 && + labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX), + requireSearchOrFilter: + counts.mode === "compact" && + counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES + }; +} + +async function getLauncherScaleCountsForUser( orgId: string, userId: string, - userRoleIds: number[], - query: LauncherScaleQuery -): Promise { + userRoleIds: number[] +): Promise { + const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const resourceCount = accessible.resourceIds.length + accessible.siteResourceIds.length; @@ -75,33 +100,15 @@ async function getLauncherScaleForUserUncached( ? "full" : "compact"; - const siteFilterIds = parseIdListParam(query.siteIds); - const labelFilterIds = parseIdListParam(query.labelIds); - - const allowSiteGrouping = - siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || - (siteFilterIds.length > 0 && - siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const allowLabelGrouping = - labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || - (labelFilterIds.length > 0 && - labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const requireSearchOrFilter = - mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; - - return { - mode, + const result: LauncherScaleCountsCacheEntry = { resourceCount, siteGroupCount, labelGroupCount, - capabilities: { - allowSiteGrouping, - allowLabelGrouping, - requireSearchOrFilter - } + mode }; + + await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC); + return result; } export async function getLauncherScaleForUser( @@ -110,18 +117,17 @@ export async function getLauncherScaleForUser( userRoleIds: number[], query: LauncherScaleQuery ): Promise { - const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query); - const cached = await cache.get(cacheKey); - if (cached) { - return cached; - } - - const result = await getLauncherScaleForUserUncached( + const counts = await getLauncherScaleCountsForUser( orgId, userId, - userRoleIds, - query + userRoleIds ); - await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC); - return result; + + return { + mode: counts.mode, + resourceCount: counts.resourceCount, + siteGroupCount: counts.siteGroupCount, + labelGroupCount: counts.labelGroupCount, + capabilities: buildScaleCapabilities(counts, query) + }; } diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 9306a752c..8f44b1139 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -10,6 +10,7 @@ import { type SelectedLabel } from "@app/components/labels-selector"; import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { Popover, @@ -96,14 +97,32 @@ export function LauncherFilterPopover({ [labels, selectedLabels] ); + const activeFilterCount = selectedSites.length + selectedLabels.length; + return ( - From 1b1fba60f141cc3ef6dcf24efee59dab36291e1b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 09:02:08 -0400 Subject: [PATCH 228/264] Make sure to retry rebuilds --- server/lib/dbRetry.ts | 83 +++++++++++++++++++++++++ server/lib/rebuildClientAssociations.ts | 56 ++++++++++++++--- server/lib/rebuildQueue.ts | 35 +++++++++-- server/private/lib/rebuildQueue.ts | 40 ++++++++++-- server/routers/newt/pingAccumulator.ts | 83 +------------------------ 5 files changed, 200 insertions(+), 97 deletions(-) create mode 100644 server/lib/dbRetry.ts diff --git a/server/lib/dbRetry.ts b/server/lib/dbRetry.ts new file mode 100644 index 000000000..7f8d1eea8 --- /dev/null +++ b/server/lib/dbRetry.ts @@ -0,0 +1,83 @@ +import logger from "@server/logger"; + +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 50; + +/** + * Detect transient errors that are safe to retry (connection drops, deadlocks, + * serialization failures). PostgreSQL deadlocks (40P01) are always safe to + * retry: the database guarantees exactly one winner per deadlock pair, so the + * loser just needs to try again. + */ +export function isTransientError(error: any): boolean { + if (!error) return false; + + const message = (error.message || "").toLowerCase(); + const causeMessage = (error.cause?.message || "").toLowerCase(); + const code = error.code || error.cause?.code || ""; + + // Connection timeout / terminated + if ( + message.includes("connection timeout") || + message.includes("connection terminated") || + message.includes("timeout exceeded when trying to connect") || + causeMessage.includes("connection terminated unexpectedly") || + causeMessage.includes("connection timeout") + ) { + return true; + } + + // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) + if (code === "40P01" || message.includes("deadlock")) { + return true; + } + + // PostgreSQL serialization failure + if (code === "40001") { + return true; + } + + // ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT + if ( + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT" + ) { + return true; + } + + return false; +} + +/** + * Simple retry wrapper with exponential backoff for transient errors + * (deadlocks, connection timeouts, unexpected disconnects). + */ +export async function withRetry( + operation: () => Promise, + context: string, + maxRetries: number = MAX_RETRIES, + baseDelayMs: number = BASE_DELAY_MS +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isTransientError(error) && attempt < maxRetries) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, + { code: error?.code ?? error?.cause?.code } + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 150001662..b7647a182 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -45,6 +45,7 @@ import { } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; +import { withRetry, isTransientError } from "@server/lib/dbRetry"; import { checkOrgRebuildRateLimit, decrementOrgRebuildCount, @@ -285,10 +286,20 @@ export async function rebuildClientAssociationsFromSiteResource( ) { await incrementOrgRebuildCount(siteResource.orgId); try { - return await lockManager.withLock( - `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => rebuildClientAssociationsFromSiteResourceImpl(siteResource), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS + // The whole locked rebuild is idempotent (it diffs full expected vs. + // actual state each time), so on a transient DB error it's safe to + // retry the entire thing rather than just the failed query. + return await withRetry( + () => + lockManager.withLock( + `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, + () => + rebuildClientAssociationsFromSiteResourceImpl( + siteResource + ), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ), + `rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}` ); } catch (err: any) { if ( @@ -304,6 +315,17 @@ export async function rebuildClientAssociationsFromSiteResource( }); return { mergedAllClients: [] }; } + if (isTransientError(err)) { + logger.warn( + `rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`, + err + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + return { mergedAllClients: [] }; + } throw err; } finally { await decrementOrgRebuildCount(siteResource.orgId); @@ -1656,10 +1678,17 @@ export async function rebuildClientAssociationsFromClient( await incrementOrgRebuildCount(client.orgId); try { const trx = primaryDb; - return await lockManager.withLock( - `rebuild-client-associations:client:${client.clientId}`, - () => rebuildClientAssociationsFromClientImpl(client, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS + // The whole locked rebuild is idempotent (it diffs full expected vs. + // actual state each time), so on a transient DB error it's safe to + // retry the entire thing rather than just the failed query. + return await withRetry( + () => + lockManager.withLock( + `rebuild-client-associations:client:${client.clientId}`, + () => rebuildClientAssociationsFromClientImpl(client, trx), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ), + `rebuildClientAssociationsFromClient:${client.clientId}` ); } catch (err: any) { if ( @@ -1675,6 +1704,17 @@ export async function rebuildClientAssociationsFromClient( }); return; } + if (isTransientError(err)) { + logger.warn( + `rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`, + err + ); + await rebuildQueue.enqueue({ + type: "client", + id: client.clientId + }); + return; + } throw err; } finally { await decrementOrgRebuildCount(client.orgId); diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 07f2cb003..7491f5f52 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -1,10 +1,14 @@ import logger from "@server/logger"; +import { isTransientError } from "@server/lib/dbRetry"; export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { type: RebuildJobType; id: number; + // Number of times this job has already been re-queued after a transient + // failure. Absent/0 means it has not failed yet. + attempt?: number; } export interface RebuildJobHandlers { @@ -24,6 +28,10 @@ export interface RebuildQueueManager { // retried shortly after against fresh DB state. const POLL_INTERVAL_MS = 500; const BATCH_SIZE = 5; +// A job that fails with a transient DB error gets re-queued with backoff +// instead of being dropped, up to this many times. +const MAX_JOB_ATTEMPTS = 5; +const JOB_RETRY_BASE_DELAY_MS = 1000; function dedupeKey(job: RebuildJob): string { return `${job.type}:${job.id}`; @@ -106,10 +114,29 @@ class InMemoryRebuildQueue implements RebuildQueueManager { `Rebuild queue: completed ${job.type}:${job.id}` ); } catch (err) { - logger.error( - `Rebuild queue: job ${job.type}:${job.id} threw an error:`, - err - ); + const attempt = (job.attempt ?? 0) + 1; + if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) { + const delay = + JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + logger.warn( + `Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`, + err + ); + setTimeout(() => { + this.enqueue({ ...job, attempt }).catch( + (enqueueErr) => + logger.error( + `Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`, + enqueueErr + ) + ); + }, delay); + } else { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } } } } finally { diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index b5e112545..87da95f60 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -14,12 +14,16 @@ import { redis } from "#private/lib/redis"; import { lockManager } from "#private/lib/lock"; import logger from "@server/logger"; +import { isTransientError } from "@server/lib/dbRetry"; export type RebuildJobType = "site-resource" | "client"; export interface RebuildJob { type: RebuildJobType; id: number; + // Number of times this job has already been re-queued after a transient + // failure. Absent/0 means it has not failed yet. + attempt?: number; } export interface RebuildJobHandlers { @@ -43,6 +47,11 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s const POLL_INTERVAL_MS = 500; +// A job that fails with a transient DB error gets re-queued with backoff +// instead of being dropped, up to this many times. +const MAX_JOB_ATTEMPTS = 5; +const JOB_RETRY_BASE_DELAY_MS = 1000; + class RedisRebuildQueue { private processingStarted = false; @@ -180,10 +189,33 @@ class RedisRebuildQueue { `Rebuild queue: completed ${job.type}:${job.id}` ); } catch (err) { - logger.error( - `Rebuild queue: job ${job.type}:${job.id} threw an error:`, - err - ); + const attempt = (job.attempt ?? 0) + 1; + if ( + isTransientError(err) && + attempt <= MAX_JOB_ATTEMPTS + ) { + const delay = + JOB_RETRY_BASE_DELAY_MS * + Math.pow(2, attempt - 1); + logger.warn( + `Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`, + err + ); + setTimeout(() => { + this.enqueue({ ...job, attempt }).catch( + (enqueueErr) => + logger.error( + `Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`, + enqueueErr + ) + ); + }, delay); + } else { + logger.error( + `Rebuild queue: job ${job.type}:${job.id} threw an error:`, + err + ); + } } } }, diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index be9cd4972..f82190790 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOnlineAlert } from "@server/lib/alerts"; +import { withRetry } from "@server/lib/dbRetry"; /** * Ping Accumulator @@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts"; */ const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds -const MAX_RETRIES = 5; -const BASE_DELAY_MS = 50; // ── Site (newt) pings ────────────────────────────────────────────────── // Map of siteId -> latest ping timestamp (unix seconds) @@ -266,85 +265,7 @@ export async function flushPingsToDb(): Promise { } // ── Retry / Error Helpers ────────────────────────────────────────────── - -/** - * Simple retry wrapper with exponential backoff for transient errors - * (deadlocks, connection timeouts, unexpected disconnects). - * - * PostgreSQL deadlocks (40P01) are always safe to retry: the database - * guarantees exactly one winner per deadlock pair, so the loser just needs - * to try again. MAX_RETRIES is intentionally higher than typical connection - * retry budgets to give deadlock victims enough chances to succeed. - */ -async function withRetry( - operation: () => Promise, - context: string -): Promise { - let attempt = 0; - while (true) { - try { - return await operation(); - } catch (error: any) { - if (isTransientError(error) && attempt < MAX_RETRIES) { - attempt++; - const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; - const jitter = Math.random() * baseDelay; - const delay = baseDelay + jitter; - logger.warn( - `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`, - { code: error?.code ?? error?.cause?.code } - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - throw error; - } - } -} - -/** - * Detect transient errors that are safe to retry. - */ -function isTransientError(error: any): boolean { - if (!error) return false; - - const message = (error.message || "").toLowerCase(); - const causeMessage = (error.cause?.message || "").toLowerCase(); - const code = error.code || error.cause?.code || ""; - - // Connection timeout / terminated - if ( - message.includes("connection timeout") || - message.includes("connection terminated") || - message.includes("timeout exceeded when trying to connect") || - causeMessage.includes("connection terminated unexpectedly") || - causeMessage.includes("connection timeout") - ) { - return true; - } - - // PostgreSQL deadlock detected - always safe to retry (one winner guaranteed) - if (code === "40P01" || message.includes("deadlock")) { - return true; - } - - // PostgreSQL serialization failure - if (code === "40001") { - return true; - } - - // ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT - if ( - code === "ECONNRESET" || - code === "ECONNREFUSED" || - code === "EPIPE" || - code === "ETIMEDOUT" - ) { - return true; - } - - return false; -} +// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers. // ── Lifecycle ────────────────────────────────────────────────────────── From b53f80d31711f4f35d9519e908675e095df3c20f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 09:59:49 -0400 Subject: [PATCH 229/264] use column for default view --- messages/en-US.json | 2 +- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 3 +++ server/routers/launcher/createLauncherView.ts | 13 ++---------- server/routers/launcher/deleteLauncherView.ts | 3 +-- .../routers/launcher/launcherDefaultView.ts | 21 +++++++------------ server/routers/launcher/types.ts | 3 +-- server/routers/launcher/updateLauncherView.ts | 6 +++--- 8 files changed, 19 insertions(+), 33 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 45393e184..90be25679 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3621,7 +3621,7 @@ "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", - "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.", + "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 683c572b6..5375e4fc2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", { }), name: varchar("name").notNull(), config: text("config").notNull(), + isDefault: boolean("isDefault").notNull().default(false), createdAt: varchar("createdAt").notNull(), updatedAt: varchar("updatedAt").notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 28797bcd9..5f0c2f9a7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", { }), name: text("name").notNull(), config: text("config").notNull(), + isDefault: integer("isDefault", { mode: "boolean" }) + .notNull() + .default(false), createdAt: text("createdAt").notNull(), updatedAt: text("updatedAt").notNull() }); diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 4c77cd4f0..195dfc555 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -7,7 +7,6 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -41,15 +40,6 @@ export async function createLauncherView( ); } - if (isLauncherDefaultOverrideViewName(parsed.data.name)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "This view name is reserved" - ) - ); - } - if (parsed.data.orgWide) { const canCreateOrgWide = await checkUserActionPermission( ActionsEnum.createOrgWideLauncherView, @@ -89,7 +79,8 @@ export async function createLauncherView( ), createdAt: created.createdAt, updatedAt: created.updatedAt, - isOrgWide: created.userId == null + isOrgWide: created.userId == null, + isDefault: created.isDefault }, success: true, error: false, diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index 41d7dd214..8377872fb 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -6,7 +6,6 @@ import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; export async function deleteLauncherView( req: Request, @@ -47,7 +46,7 @@ export async function deleteLauncherView( ); } - if (isLauncherDefaultOverrideViewName(existing.name)) { + if (existing.isDefault) { return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts index 4d180bd1d..a0c2fd066 100644 --- a/server/routers/launcher/launcherDefaultView.ts +++ b/server/routers/launcher/launcherDefaultView.ts @@ -2,17 +2,12 @@ import { db, launcherViews } from "@server/db"; import { and, eq, isNull } from "drizzle-orm"; import moment from "moment"; import { - LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, launcherViewConfigSchema, type LauncherDefaultViewOverrides, type LauncherViewConfig, type LauncherViewRecord } from "./types"; -export function isLauncherDefaultOverrideViewName(name: string) { - return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME; -} - export function mapViewRow( row: typeof launcherViews.$inferSelect ): LauncherViewRecord { @@ -24,16 +19,15 @@ export function mapViewRow( config: launcherViewConfigSchema.parse(JSON.parse(row.config)), createdAt: row.createdAt, updatedAt: row.updatedAt, - isOrgWide: row.userId == null + isOrgWide: row.userId == null, + isDefault: row.isDefault }; } export function extractDefaultViewOverrides( rows: Array ): LauncherDefaultViewOverrides { - const overrideRows = rows.filter((row) => - isLauncherDefaultOverrideViewName(row.name) - ); + const overrideRows = rows.filter((row) => row.isDefault); const personalRow = overrideRows.find((row) => row.userId !== null); const orgWideRow = overrideRows.find((row) => row.userId === null); @@ -47,9 +41,7 @@ export function extractDefaultViewOverrides( export function listVisibleLauncherViews( rows: Array ): LauncherViewRecord[] { - return rows - .filter((row) => !isLauncherDefaultOverrideViewName(row.name)) - .map(mapViewRow); + return rows.filter((row) => !row.isDefault).map(mapViewRow); } export async function findDefaultViewOverride( @@ -63,7 +55,7 @@ export async function findDefaultViewOverride( .where( and( eq(launcherViews.orgId, orgId), - eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME), + eq(launcherViews.isDefault, true), orgWide ? isNull(launcherViews.userId) : eq(launcherViews.userId, userId) @@ -106,7 +98,8 @@ export async function upsertDefaultViewOverride({ .values({ orgId, userId: orgWide ? null : userId, - name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + name: "", + isDefault: true, config: JSON.stringify(config), createdAt: now, updatedAt: now diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 9f9701c96..4a986118c 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -89,6 +89,7 @@ export type LauncherViewRecord = { createdAt: string; updatedAt: string; isOrgWide: boolean; + isDefault: boolean; }; export type LauncherDefaultViewOverrides = { @@ -181,8 +182,6 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; -export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__"; - export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 757a16dac..e4373151f 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -9,7 +9,6 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -67,7 +66,7 @@ export async function updateLauncherView( ); } - if (isLauncherDefaultOverrideViewName(existing.name)) { + if (existing.isDefault) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -145,7 +144,8 @@ export async function updateLauncherView( ), createdAt: updated.createdAt, updatedAt: updated.updatedAt, - isOrgWide: updated.userId == null + isOrgWide: updated.userId == null, + isDefault: updated.isDefault }, success: true, error: false, From 811119a9a6ec3b496f2562032feaed9a94beb4bc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 10:13:58 -0400 Subject: [PATCH 230/264] reduce filter dropdown page size --- server/routers/launcher/types.ts | 4 ++-- src/components/LabelsFilterSelector.tsx | 20 ++++++++++++++++--- src/components/multi-site-selector.tsx | 2 +- .../LauncherFilterPopover.tsx | 5 +++-- src/lib/queries.ts | 4 ++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 4a986118c..f235e824d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -122,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({ .int() .positive() .optional() - .catch(500) - .default(500), + .catch(20) + .default(20), page: z.coerce.number().int().min(1).optional().catch(1).default(1), query: z.string().optional().default("") }); diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx index 3c9410e43..84a1819d8 100644 --- a/src/components/LabelsFilterSelector.tsx +++ b/src/components/LabelsFilterSelector.tsx @@ -11,7 +11,7 @@ import { import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { Checkbox } from "./ui/checkbox"; @@ -25,6 +25,7 @@ type LabelsFilterSelectorProps = { orgId: string; isSelected: (label: LabelFilterOption) => boolean; onToggle: (label: LabelFilterOption) => void; + selectedLabels?: LabelFilterOption[]; onClear?: () => void; showClear?: boolean; scope?: "org" | "launcher"; @@ -34,6 +35,7 @@ export function LabelsFilterSelector({ orgId, isSelected, onToggle, + selectedLabels = [], onClear, showClear = false, scope = "org" @@ -54,7 +56,7 @@ export function LabelsFilterSelector({ ...launcherQueries.labels({ orgId, query: debouncedQuery, - perPage: 500 + perPage: 20 }), enabled: scope === "launcher" }); @@ -63,6 +65,18 @@ export function LabelsFilterSelector({ ? (launcherLabelsQuery.data ?? []) : (orgLabelsQuery.data ?? []); + const labelsShown = useMemo(() => { + const base = [...labels]; + if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) { + const selectedNotInBase = selectedLabels.filter( + (selected) => + !base.some((label) => label.labelId === selected.labelId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, labels, selectedLabels]); + return ( )} - {labels.map((label) => ( + {labelsShown.map((label) => ( selectedLabelIds.has(label.labelId) } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2626a3ba3..211ab7824 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1196,7 +1196,7 @@ export const launcherQueries = { sites: ({ orgId, query, - perPage = 500 + perPage = 20 }: { orgId: string; query?: string; @@ -1228,7 +1228,7 @@ export const launcherQueries = { labels: ({ orgId, query, - perPage = 500 + perPage = 20 }: { orgId: string; query?: string; From b399d2a291423f535a3138124575e73eeda535a1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 10:23:32 -0400 Subject: [PATCH 231/264] Add some retry and database confict mitigation --- server/lib/rebuildClientAssociations.ts | 257 ++++++++++++---------- server/routers/newt/buildConfiguration.ts | 4 +- server/routers/olm/buildConfiguration.ts | 2 +- 3 files changed, 145 insertions(+), 118 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index b7647a182..abad6b7aa 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -485,6 +485,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) + .onConflictDoNothing() .returning(); logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` @@ -532,121 +533,141 @@ async function rebuildClientAssociationsFromSiteResourceImpl( for (const site of sitesToProcess) { const siteId = site.siteId; - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` - ); + try { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` + ); - const existingClientSites = await trx - .select({ - clientId: clientSitesAssociationsCache.clientId - }) - .from(clientSitesAssociationsCache) - .where(eq(clientSitesAssociationsCache.siteId, siteId)); + const existingClientSites = await trx + .select({ + clientId: clientSitesAssociationsCache.clientId + }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); - const existingClientSiteIds = existingClientSites.map( - (row) => row.clientId - ); + const existingClientSiteIds = existingClientSites.map( + (row) => row.clientId + ); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` - ); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` + ); - // Get full client details for existing clients (needed for sending delete messages) - const existingClients = - existingClientSiteIds.length > 0 - ? await trx - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .where(inArray(clients.clientId, existingClientSiteIds)) + // Get full client details for existing clients (needed for sending delete messages) + const existingClients = + existingClientSiteIds.length > 0 + ? await trx + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .where( + inArray(clients.clientId, existingClientSiteIds) + ) + : []; + + const otherResourceClientIds = + clientsFromOtherResourcesBySite.get(siteId) ?? + new Set(); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` + ); + + // Expected clients from this resource are site-scoped: if this site is + // no longer attached to the resource, the expected set is empty. + const expectedClientIdsForSite = currentSiteIdSet.has(siteId) + ? mergedAllClientIds : []; - const otherResourceClientIds = - clientsFromOtherResourcesBySite.get(siteId) ?? new Set(); - - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` - ); - - // Expected clients from this resource are site-scoped: if this site is - // no longer attached to the resource, the expected set is empty. - const expectedClientIdsForSite = currentSiteIdSet.has(siteId) - ? mergedAllClientIds - : []; - - const clientSitesToAdd = expectedClientIdsForSite.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource - ); - - const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ - clientId, - siteId - })); - - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` - ); - - if (clientSitesToInsert.length > 0) { - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` + const clientSitesToAdd = expectedClientIdsForSite.filter( + (clientId) => + !existingClientSiteIds.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource ); - await trx - .insert(clientSitesAssociationsCache) - .values(clientSitesToInsert) - .returning(); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` - ); - } else { - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` - ); - } - // Now remove any client-site associations that should no longer exist - const clientSitesToRemove = existingClientSiteIds.filter( - (clientId) => - !expectedClientIdsForSite.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource - ); + const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ + clientId, + siteId + })); - logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` - ); - - if (clientSitesToRemove.length > 0) { logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]` ); - await trx - .delete(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.siteId, siteId), - inArray( - clientSitesAssociationsCache.clientId, - clientSitesToRemove - ) - ) + + if (clientSitesToInsert.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)` ); - } + await trx + .insert(clientSitesAssociationsCache) + .values(clientSitesToInsert) + .onConflictDoNothing() + .returning(); + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` + ); + } else { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert` + ); + } - // Now handle the messages to add/remove peers on both the newt and olm sides - await handleMessagesForSiteClients( - site, - siteId, - mergedAllClients, - existingClients, - clientSitesToAdd, - clientSitesToRemove, - trx - ); + // Now remove any client-site associations that should no longer exist + const clientSitesToRemove = existingClientSiteIds.filter( + (clientId) => + !expectedClientIdsForSite.includes(clientId) && + !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource + ); + + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]` + ); + + if (clientSitesToRemove.length > 0) { + logger.debug( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.siteId, siteId), + inArray( + clientSitesAssociationsCache.clientId, + clientSitesToRemove + ) + ) + ); + } + + // Now handle the messages to add/remove peers on both the newt and olm sides + await handleMessagesForSiteClients( + site, + siteId, + mergedAllClients, + existingClients, + clientSitesToAdd, + clientSitesToRemove, + trx + ); + } catch (err) { + // Don't let a failure on one site abort processing of every + // other site queued after it in this run. Since we're not + // re-throwing, the outer wrapper's retry/requeue logic never + // sees this failure, so explicitly queue this resource for a + // follow-up pass to reconcile whatever this site didn't get to. + logger.error( + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`, + err + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + } } // Handle subnet proxy target updates for the resource associations @@ -939,7 +960,7 @@ export async function updateClientSiteDestinations( for (const site of sitesData) { if (!site.sites.subnet) { - logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); + logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`); continue; } @@ -1866,12 +1887,15 @@ async function rebuildClientAssociationsFromClientImpl( // Insert new associations if (resourcesToAdd.length > 0) { - await trx.insert(clientSiteResourcesAssociationsCache).values( - resourcesToAdd.map((siteResourceId) => ({ - clientId: client.clientId, - siteResourceId - })) - ); + await trx + .insert(clientSiteResourcesAssociationsCache) + .values( + resourcesToAdd.map((siteResourceId) => ({ + clientId: client.clientId, + siteResourceId + })) + ) + .onConflictDoNothing(); } // Remove old associations @@ -1909,12 +1933,15 @@ async function rebuildClientAssociationsFromClientImpl( // Insert new site associations if (sitesToAdd.length > 0) { - await trx.insert(clientSitesAssociationsCache).values( - sitesToAdd.map((siteId) => ({ - clientId: client.clientId, - siteId - })) - ); + await trx + .insert(clientSitesAssociationsCache) + .values( + sitesToAdd.map((siteId) => ({ + clientId: client.clientId, + siteId + })) + ) + .onConflictDoNothing(); } // Remove old site associations diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 5083a6c56..5b7f211ae 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient( clientsRes .filter((client) => { if (!client.clients.pubKey) { - logger.warn( + logger.debug( `Client ${client.clients.clientId} has no public key, skipping` ); return false; } if (!client.clients.subnet) { - logger.warn( + logger.debug( `Client ${client.clients.clientId} has no subnet, skipping` ); return false; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 41bb6d60d..f72731c92 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient( } if (!site.subnet) { - logger.warn(`Site ${site.siteId} has no subnet, skipping`); + logger.debug(`Site ${site.siteId} has no subnet, skipping`); continue; } From 440ebfe08e1ecf2bc0f31f2e48a9bb12ade38e08 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 10:26:13 -0400 Subject: [PATCH 232/264] Clarify error messages --- server/routers/newt/handleNewtDisconnectingMessage.ts | 2 +- server/routers/olm/handleOlmDisconnectingMessage.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index a2b963fc9..afc208ac8 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -43,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( ); }); } catch (error) { - logger.error("Error handling disconnecting message", { error }); + logger.error("Error handling site disconnecting message", error); } }; diff --git a/server/routers/olm/handleOlmDisconnectingMessage.ts b/server/routers/olm/handleOlmDisconnectingMessage.ts index ecd101724..241183497 100644 --- a/server/routers/olm/handleOlmDisconnectingMessage.ts +++ b/server/routers/olm/handleOlmDisconnectingMessage.ts @@ -6,7 +6,9 @@ import logger from "@server/logger"; /** * Handles disconnecting messages from clients to show disconnected in the ui */ -export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { +export const handleOlmDisconnectingMessage: MessageHandler = async ( + context +) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; @@ -29,6 +31,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (context) => }) .where(eq(clients.clientId, olm.clientId)); } catch (error) { - logger.error("Error handling disconnecting message", { error }); + logger.error("Error handling client disconnecting message", error); } }; From 9edb86fd73d95beb869009779f89953567ed987b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 10:30:41 -0400 Subject: [PATCH 233/264] add invalidate launcher cache on refresh --- server/routers/external.ts | 6 ++ server/routers/launcher/index.ts | 1 + .../launcher/invalidateLauncherCache.ts | 65 +++++++++++++++++++ .../resource-launcher/ResourceLauncher.tsx | 7 +- 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 server/routers/launcher/invalidateLauncherCache.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 2b95c2bfc..33fcb3ab3 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -500,6 +500,12 @@ authenticated.get( launcher.listLauncherViews ); +authenticated.post( + "/org/:orgId/launcher/invalidate-cache", + verifyOrgAccess, + launcher.invalidateLauncherCache +); + authenticated.post( "/org/:orgId/launcher/views", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index b9edafc22..1c3fed44c 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -10,3 +10,4 @@ export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView"; export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView"; +export { invalidateLauncherCache } from "./invalidateLauncherCache"; diff --git a/server/routers/launcher/invalidateLauncherCache.ts b/server/routers/launcher/invalidateLauncherCache.ts new file mode 100644 index 000000000..5e4ff1618 --- /dev/null +++ b/server/routers/launcher/invalidateLauncherCache.ts @@ -0,0 +1,65 @@ +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; + +async function invalidateLauncherCacheForUser( + orgId: string, + userId: string +): Promise { + const prefixes = [ + `launcherAccessibleIds:${orgId}:${userId}:`, + `launcher:groups:${orgId}:${userId}:`, + `launcher:results:${orgId}:${userId}:`, + `launcher:scale:counts:${orgId}:${userId}:` + ]; + + const keys = ( + await Promise.all( + prefixes.map((prefix) => cache.keysWithPrefix(prefix)) + ) + ).flat(); + + if (keys.length > 0) { + await cache.del(keys); + } +} + +export async function invalidateLauncherCache( + 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") + ); + } + + await invalidateLauncherCacheForUser(orgId, userId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher cache invalidated successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error invalidating launcher cache:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 9f9d44d11..3f1249efc 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -44,7 +44,7 @@ import { type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -105,6 +105,7 @@ export default function ResourceLauncher({ const { toast } = useToast(); const { env } = useEnvContext(); const api = createApiClient({ env }); + const queryClient = useQueryClient(); const router = useRouter(); const { navigate, isNavigating, searchParams } = useNavigationContext(); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -489,6 +490,10 @@ export default function ResourceLauncher({ const refreshData = () => { startRefreshTransition(async () => { try { + await api.post(`/org/${orgId}/launcher/invalidate-cache`); + await queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "LAUNCHER"] + }); router.refresh(); } catch { toast({ From 4087d7fb6b67fdb69f3b38d48ba7397683082c36 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 11:18:06 -0400 Subject: [PATCH 234/264] remove text from refresh button --- src/components/resource-launcher/LauncherRefreshButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx index 854128736..1d0a3d2f3 100644 --- a/src/components/resource-launcher/LauncherRefreshButton.tsx +++ b/src/components/resource-launcher/LauncherRefreshButton.tsx @@ -23,9 +23,8 @@ export function LauncherRefreshButton({ className="shrink-0" > - {t("refresh")} ); } From e4e0da3723542e9ecd3eb32ec460b87b4ca2c57b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:24:53 -0400 Subject: [PATCH 235/264] Add not exists check --- server/routers/newt/handleNewtDisconnectingMessage.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index afc208ac8..811dc68a1 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( } if (!newt.siteId) { - logger.warn("Newt has no client ID!"); + logger.warn("Newt has no site ID!"); return; } @@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( .where(eq(sites.siteId, newt.siteId!)) .returning(); + if (!site) { + throw new Error( + `Could not find site ${newt.siteId} to update disconnection from disconnect message` + ); + } + await fireSiteOfflineAlert( site.orgId, site.siteId, From 600a96c13b0f6cf333cfd7a252a9e71c5f1317eb Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:52:59 -0400 Subject: [PATCH 236/264] Update rate limit to 10 --- server/private/lib/orgRebuildCounter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/orgRebuildCounter.ts b/server/private/lib/orgRebuildCounter.ts index 58d954f66..7107b9b8d 100644 --- a/server/private/lib/orgRebuildCounter.ts +++ b/server/private/lib/orgRebuildCounter.ts @@ -14,7 +14,7 @@ import { redis } from "#private/lib/redis"; import logger from "@server/logger"; -export const ORG_REBUILD_CONCURRENCY_LIMIT = 5; +export const ORG_REBUILD_CONCURRENCY_LIMIT = 10; // 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 From 5da186b528a4dc8bb0adb12d8b130ec59b9eead3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 11:58:00 -0400 Subject: [PATCH 237/264] Quiet warning messages --- server/lib/rebuildClientAssociations.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index abad6b7aa..d3fcaea3c 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -700,7 +700,7 @@ async function handleMessagesForSiteClients( trx: Transaction | typeof db = db ): Promise { if (!site.exitNodeId) { - logger.warn( + logger.debug( `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; @@ -714,14 +714,14 @@ async function handleMessagesForSiteClients( .limit(1); if (!exitNode) { - logger.warn( + logger.debug( `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; } if (!site.publicKey) { - logger.warn( + logger.debug( `Site publicKey not set for site ${site.siteId} so cannot add peers to clients` ); return; @@ -735,7 +735,7 @@ async function handleMessagesForSiteClients( .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { - logger.warn( + logger.debug( `Newt not found for site ${siteId} so cannot add peers to clients` ); return; From 2bfc1901a65675c625edc1c6246a01a9925227c7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 14:38:01 -0400 Subject: [PATCH 238/264] Dont check the subnet because we dont use it --- server/routers/site/createSite.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index bddf5b251..b24608609 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -268,7 +268,11 @@ export async function createSite( let newSite: Site | undefined; try { - if (subnet && exitNodeId) { + if (type === "wireguard" && subnet && exitNodeId) { + // Only wireguard sites actually persist the provided subnet/exitNodeId. + // Newt sites have their subnet/exit node chosen (under a lock) when the + // newt connects, so validating them here is both unnecessary and racy, + // since pickSiteDefaults does not lock the subnet it suggests. //make sure the subnet is in the range of the exit node if provided const [exitNode] = await db .select() From 390c822bb4a372eb7dce9b502301940da8d899b7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 16:13:50 -0400 Subject: [PATCH 239/264] Fix issue with overlapping site resources not sending --- server/lib/rebuildClientAssociations.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index d3fcaea3c..efb856825 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -582,10 +582,17 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ? mergedAllClientIds : []; + // Note: we deliberately do NOT exclude clients covered by another + // site resource here (unlike clientSitesToRemove below). Doing so + // previously caused a permanent gap: if resource A saw resource B's + // cache row and skipped adding (assuming B would maintain it), and + // B's own rebuild made the same assumption about A, the site-level + // row could end up never inserted by anyone even though both + // resources' client associations were otherwise correct. + // onConflictDoNothing makes a redundant insert harmless, so there's + // no correctness reason to skip here. const clientSitesToAdd = expectedClientIdsForSite.filter( - (clientId) => - !existingClientSiteIds.includes(clientId) && - !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource + (clientId) => !existingClientSiteIds.includes(clientId) ); const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ From 40549763884e0998a0e0c4431cc05d9ee865ffe9 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:15:48 -0400 Subject: [PATCH 240/264] Fix #3395 --- messages/en-US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4701d4da1..5d49b0ca9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -864,8 +864,8 @@ "policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthSummary": "Header configured", - "policyAuthHeaderName": "Header name", - "policyAuthHeaderValue": "Expected value", + "policyAuthHeaderName": "Username", + "policyAuthHeaderValue": "Password", "policyAuthSetPasscode": "Set Passcode", "policyAuthSetPincode": "Set PIN Code", "policyAuthSetEmailWhitelist": "Set Email Whitelist", From f60b8795ad034c7c1abd1efaebe0edeca3979849 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:26:49 -0400 Subject: [PATCH 241/264] Fix #3383 --- server/routers/resource/listResources.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f15a3beda..d1deb9d39 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -616,8 +616,8 @@ export async function listResources( and( inArray(resources.mode, browserGatewayModes), or( - eq(effectiveSso, true), - eq(effectiveWhitelist, true), + effectiveSso, + effectiveWhitelist, not(isNull(effectiveHeaderAuthId)), not(isNull(effectivePincodeId)), not(isNull(effectivePasswordId)) @@ -629,8 +629,8 @@ export async function listResources( conditions.push( and( inArray(resources.mode, browserGatewayModes), - not(eq(effectiveSso, true)), - not(eq(effectiveWhitelist, true)), + not(effectiveSso), + not(effectiveWhitelist), isNull(effectiveHeaderAuthId), isNull(effectivePincodeId), isNull(effectivePasswordId) From 2c3151da9bb8d731da8e18ff22790a811f19a725 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Jul 2026 17:36:06 -0400 Subject: [PATCH 242/264] Fix #3374 --- .../resources/public/[niceId]/http/page.tsx | 17 ++- src/components/HeadersInput.tsx | 129 +++++++++++------- 2 files changed, 92 insertions(+), 54 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index d9e2fefe0..e991d7b97 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -41,7 +41,7 @@ import { import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -137,11 +137,21 @@ function ProxyResourceHttpForm({ }); const [, formAction, saveLoading] = useActionState(onSubmit, null); + const [headersValid, setHeadersValid] = useState(true); async function onSubmit() { const isValid = await form.trigger(); if (!isValid) return; + if (!headersValid) { + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: t("headersValidationError") + }); + return; + } + const data = form.getValues(); const res = await api @@ -318,6 +328,9 @@ function ProxyResourceHttpForm({ onChange={ field.onChange } + onValidityChange={ + setHeadersValid + } rows={4} /> @@ -341,7 +354,7 @@ function ProxyResourceHttpForm({