From e05114233445cf5af834b8a0195499ff00e82e8e Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 22 Dec 2025 17:44:56 +0100 Subject: [PATCH 001/122] Add region-based resource rule --- messages/de-DE.json | 28 +++ messages/en-US.json | 34 +++ messages/ru-RU.json | 28 +++ server/db/regions.ts | 196 +++++++++++++++ server/routers/badger/verifySession.test.ts | 96 ++++++- server/routers/badger/verifySession.ts | 46 ++++ server/routers/resource/createResourceRule.ts | 12 +- server/routers/resource/updateResourceRule.ts | 12 +- .../resources/proxy/[niceId]/rules/page.tsx | 238 +++++++++++++++++- 9 files changed, 678 insertions(+), 12 deletions(-) create mode 100644 server/db/regions.ts diff --git a/messages/de-DE.json b/messages/de-DE.json index 13ab3d11c..94950748f 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1734,6 +1734,34 @@ "exitNode": "Exit-Node", "country": "Land", "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", + "region": "Region", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "Nordafrika", + "regionEasternAfrica": "Ostafrika", + "regionMiddleAfrica": "Zentralafrika", + "regionSouthernAfrica": "Südliches Afrika", + "regionWesternAfrica": "Westafrika", + "regionAmericas": "Amerika", + "regionCaribbean": "Karibik", + "regionCentralAmerica": "Mittelamerika", + "regionSouthAmerica": "Südamerika", + "regionNorthernAmerica": "Nordamerika", + "regionAsia": "Asien", + "regionCentralAsia": "Zentralasien", + "regionEasternAsia": "Ostasien", + "regionSouthEasternAsia": "Südostasien", + "regionSouthernAsia": "Südasien", + "regionWesternAsia": "Westasien", + "regionEurope": "Europa", + "regionEasternEurope": "Osteuropa", + "regionNorthernEurope": "Nordeuropa", + "regionSouthernEurope": "Südeuropa", + "regionWesternEurope": "Westeuropa", + "regionOceania": "Ozeanien", + "regionAustraliaAndNewZealand": "Australien und Neuseeland", + "regionMelanesia": "Melanesien", + "regionMicronesia": "Mikronesien", + "regionPolynesia": "Polynesien", "managedSelfHosted": { "title": "Verwaltetes Selbsthosted", "description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen", diff --git a/messages/en-US.json b/messages/en-US.json index b023ac754..55cf2d6bd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1734,6 +1734,40 @@ "exitNode": "Exit Node", "country": "Country", "rulesMatchCountry": "Currently based on source IP", + "region": "Region", + "selectRegion": "Select region", + "searchRegions": "Search regions...", + "noRegionFound": "No region found.", + "rulesMatchRegion": "Select a regional grouping of countries", + "rulesErrorInvalidRegion": "Invalid region", + "rulesErrorInvalidRegionDescription": "Please select a valid region.", + "regionAfrica": "Africa", + "regionNorthernAfrica": "Northern Africa", + "regionEasternAfrica": "Eastern Africa", + "regionMiddleAfrica": "Middle Africa", + "regionSouthernAfrica": "Southern Africa", + "regionWesternAfrica": "Western Africa", + "regionAmericas": "Americas", + "regionCaribbean": "Caribbean", + "regionCentralAmerica": "Central America", + "regionSouthAmerica": "South America", + "regionNorthernAmerica": "Northern America", + "regionAsia": "Asia", + "regionCentralAsia": "Central Asia", + "regionEasternAsia": "Eastern Asia", + "regionSouthEasternAsia": "South-Eastern Asia", + "regionSouthernAsia": "Southern Asia", + "regionWesternAsia": "Western Asia", + "regionEurope": "Europe", + "regionEasternEurope": "Eastern Europe", + "regionNorthernEurope": "Northern Europe", + "regionSouthernEurope": "Southern Europe", + "regionWesternEurope": "Western Europe", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia and New Zealand", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Managed Self-Hosted", "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index d687b7837..5eb1ee076 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1734,6 +1734,34 @@ "exitNode": "Узел выхода", "country": "Страна", "rulesMatchCountry": "В настоящее время основано на исходном IP", + "region": "Регион", + "regionAfrica": "Африка", + "regionNorthernAfrica": "Северная Африка", + "regionEasternAfrica": "Восточная Африка", + "regionMiddleAfrica": "Центральная Африка", + "regionSouthernAfrica": "Южная Африка", + "regionWesternAfrica": "Западная Африка", + "regionAmericas": "Америка", + "regionCaribbean": "Карибы", + "regionCentralAmerica": "Центральная Америка", + "regionSouthAmerica": "Южная Америка", + "regionNorthernAmerica": "Северная Америка", + "regionAsia": "Азия", + "regionCentralAsia": "Центральная Азия", + "regionEasternAsia": "Восточная Азия", + "regionSouthEasternAsia": "Юго-Восточная Азия", + "regionSouthernAsia": "Южная Азия", + "regionWesternAsia": "Западная Азия", + "regionEurope": "Европа", + "regionEasternEurope": "Восточная Европа", + "regionNorthernEurope": "Северная Европа", + "regionSouthernEurope": "Южная Европа", + "regionWesternEurope": "Западная Европа", + "regionOceania": "Океания", + "regionAustraliaAndNewZealand": "Австралия и Новая Зеландия", + "regionMelanesia": "Меланезия", + "regionMicronesia": "Микронезия", + "regionPolynesia": "Полинезия", "managedSelfHosted": { "title": "Управляемый с самовывоза", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", diff --git a/server/db/regions.ts b/server/db/regions.ts new file mode 100644 index 000000000..90a380a29 --- /dev/null +++ b/server/db/regions.ts @@ -0,0 +1,196 @@ +// Regions of the World +// as of 2025-10-25 +// +// Adapted according to the United Nations Geoscheme +// see https://www.unicode.org/cldr/charts/48/supplemental/territory_containment_un_m_49.html +// see https://unstats.un.org/unsd/methodology/m49 + +export const REGIONS = [ + { + name: "regionAfrica", + id: "002", + includes: [ + { + name: "regionNorthernAfrica", + id: "015", + countries: ["DZ", "EG", "LY", "MA", "SD", "TN", "EH"] + }, + { + name: "regionEasternAfrica", + id: "014", + countries: ["IO", "BI", "KM", "DJ", "ER", "ET", "TF", "KE", "MG", "MW", "MU", "YT", "MZ", "RE", "RW", "SC", "SO", "SS", "UG", "ZM", "ZW"] + }, + { + name: "regionMiddleAfrica", + id: "017", + countries: ["AO", "CM", "CF", "TD", "CG", "CD", "GQ", "GA", "ST"] + }, + { + name: "regionSouthernAfrica", + id: "018", + countries: ["BW", "SZ", "LS", "NA", "ZA"] + }, + { + name: "regionWesternAfrica", + id: "011", + countries: ["BJ", "BF", "CV", "CI", "GM", "GH", "GN", "GW", "LR", "ML", "MR", "NE", "NG", "SH", "SN", "SL", "TG"] + } + ] + }, + { + name: "regionAmericas", + id: "019", + includes: [ + { + name: "regionCaribbean", + id: "029", + countries: ["AI", "AG", "AW", "BS", "BB", "BQ", "VG", "KY", "CU", "CW", "DM", "DO", "GD", "GP", "HT", "JM", "MQ", "MS", "PR", "BL", "KN", "LC", "MF", "VC", "SX", "TT", "TC", "VI"] + }, + { + name: "regionCentralAmerica", + id: "013", + countries: ["BZ", "CR", "SV", "GT", "HN", "MX", "NI", "PA"] + }, + { + name: "regionSouthAmerica", + id: "005", + countries: ["AR", "BO", "BV", "BR", "CL", "CO", "EC", "FK", "GF", "GY", "PY", "PE", "GS", "SR", "UY", "VE"] + }, + { + name: "regionNorthernAmerica", + id: "021", + countries: ["BM", "CA", "GL", "PM", "US"] + } + ] + }, + { + name: "regionAsia", + id: "142", + includes: [ + { + name: "regionCentralAsia", + id: "143", + countries: ["KZ", "KG", "TJ", "TM", "UZ"] + }, + { + name: "regionEasternAsia", + id: "030", + countries: ["CN", "HK", "MO", "KP", "JP", "MN", "KR"] + }, + { + name: "regionSouthEasternAsia", + id: "035", + countries: ["BN", "KH", "ID", "LA", "MY", "MM", "PH", "SG", "TH", "TL", "VN"] + }, + { + name: "regionSouthernAsia", + id: "034", + countries: ["AF", "BD", "BT", "IN", "IR", "MV", "NP", "PK", "LK"] + }, + { + name: "regionWesternAsia", + id: "145", + countries: ["AM", "AZ", "BH", "CY", "GE", "IQ", "IL", "JO", "KW", "LB", "OM", "QA", "SA", "PS", "SY", "TR", "AE", "YE"] + } + ] + }, + { + name: "regionEurope", + id: "150", + includes: [ + { + name: "regionEasternEurope", + id: "151", + countries: ["BY", "BG", "CZ", "HU", "PL", "MD", "RO", "RU", "SK", "UA"] + }, + { + name: "regionNorthernEurope", + id: "154", + countries: ["AX", "DK", "EE", "FO", "FI", "GG", "IS", "IE", "IM", "JE", "LV", "LT", "NO", "SJ", "SE", "GB"] + }, + { + name: "regionSouthernEurope", + id: "039", + countries: ["AL", "AD", "BA", "HR", "GI", "GR", "VA", "IT", "MT", "ME", "MK", "PT", "SM", "RS", "SI", "ES"] + }, + { + name: "regionWesternEurope", + id: "155", + countries: ["AT", "BE", "FR", "DE", "LI", "LU", "MC", "NL", "CH"] + } + ] + }, + { + name: "regionOceania", + id: "009", + includes: [ + { + name: "regionAustraliaAndNewZealand", + id: "053", + countries: ["AU", "CX", "CC", "HM", "NZ", "NF"] + }, + { + name: "regionMelanesia", + id: "054", + countries: ["FJ", "NC", "PG", "SB", "VU"] + }, + { + name: "regionMicronesia", + id: "057", + countries: ["GU", "KI", "MH", "FM", "NR", "MP", "PW", "UM"] + }, + { + name: "regionPolynesia", + id: "061", + countries: ["AS", "CK", "PF", "NU", "PN", "WS", "TK", "TO", "TV", "WF"] + } + ] + } +]; + +type Subregion = { + name: string; + id: string; + countries: string[]; +}; + +type Region = { + name: string; + id: string; + includes: Subregion[]; +}; + +export function getRegionNameById(regionId: string): string | undefined { + // Check top-level regions + const region = REGIONS.find((r) => r.id === regionId); + if (region) { + return region.name; + } + + // Check subregions + for (const region of REGIONS) { + for (const subregion of region.includes) { + if (subregion.id === regionId) { + return subregion.name; + } + } + } + + return undefined; +} + +export function isValidRegionId(regionId: string): boolean { + // Check top-level regions + if (REGIONS.find((r) => r.id === regionId)) { + return true; + } + + // Check subregions + for (const region of REGIONS) { + if (region.includes.find((s) => s.id === regionId)) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 7c967acef..8333a4578 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -1,4 +1,37 @@ import { assertEquals } from "@test/assert"; +import { REGIONS } from "@server/db/regions"; + +function isIpInRegion( + ipCountryCode: string | undefined, + checkRegionCode: string +): boolean { + if (!ipCountryCode) { + return false; + } + + const upperCode = ipCountryCode.toUpperCase(); + + for (const region of REGIONS) { + // Check if it's a top-level region (continent) + if (region.id === checkRegionCode) { + for (const subregion of region.includes) { + if (subregion.countries.includes(upperCode)) { + return true; + } + } + return false; + } + + // Check subregions + for (const subregion of region.includes) { + if (subregion.id === checkRegionCode) { + return subregion.countries.includes(upperCode); + } + } + } + + return false; +} function isPathAllowed(pattern: string, path: string): boolean { // Normalize and split paths into segments @@ -272,12 +305,71 @@ function runTests() { "Root path should not match non-root path" ); - console.log("All tests passed!"); + console.log("All path matching tests passed!"); +} + +function runRegionTests() { + console.log("\nRunning isIpInRegion tests..."); + + // Test undefined country code + assertEquals( + isIpInRegion(undefined, "150"), + false, + "Undefined country code should return false" + ); + + // Test subregion matching (Western Europe) + assertEquals( + isIpInRegion("DE", "155"), + true, + "Country should match its subregion" + ); + assertEquals( + isIpInRegion("GB", "155"), + false, + "Country should NOT match wrong subregion" + ); + + // Test continent matching (Europe) + assertEquals( + isIpInRegion("DE", "150"), + true, + "Country should match its continent" + ); + assertEquals( + isIpInRegion("GB", "150"), + true, + "Different European country should match Europe" + ); + assertEquals( + isIpInRegion("US", "150"), + false, + "Non-European country should NOT match Europe" + ); + + // Test case insensitivity + assertEquals( + isIpInRegion("de", "155"), + true, + "Lowercase country code should work" + ); + + // Test invalid region code + assertEquals( + isIpInRegion("DE", "999"), + false, + "Invalid region code should return false" + ); + + console.log("All region tests passed!"); } // Run all tests try { runTests(); + runRegionTests(); + console.log("\n✅ All tests passed!"); } catch (error) { - console.error("Test failed:", error); + console.error("❌ Test failed:", error); + process.exit(1); } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0e3a3489c..0bc9bde88 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -39,6 +39,7 @@ import { } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; import cache from "@server/lib/cache"; +import { REGIONS } from "@server/db/regions"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -967,6 +968,12 @@ async function checkRules( (await isIpInAsn(ipAsn, rule.value)) ) { return rule.action as any; + } else if ( + clientIp && + rule.match == "REGION" && + (await isIpInRegion(ipCC, rule.value)) + ) { + return rule.action as any; } } @@ -1133,6 +1140,45 @@ async function isIpInAsn( return match; } +export async function isIpInRegion( + ipCountryCode: string | undefined, + checkRegionCode: string +): Promise { + if (!ipCountryCode) { + return false; + } + + const upperCode = ipCountryCode.toUpperCase(); + + for (const region of REGIONS) { + // Check if it's a top-level region (continent) + if (region.id === checkRegionCode) { + for (const subregion of region.includes) { + if (subregion.countries.includes(upperCode)) { + logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`); + return true; + } + } + logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`); + return false; + } + + // Check subregions + for (const subregion of region.includes) { + if (subregion.id === checkRegionCode) { + if (subregion.countries.includes(upperCode)) { + logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`); + return true; + } + logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`); + return false; + } + } + } + + return false; +} + async function getAsnFromIp(ip: string): Promise { const asnCacheKey = `asn:${ip}`; diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index a516d14af..0796191b1 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -14,10 +14,11 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() @@ -126,6 +127,15 @@ export async function createResourceRule( ) ); } + } else if (match === "REGION") { + if (!isValidRegionId(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid region ID provided" + ) + ); + } } // Create the new resource rule diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index b443bd1c2..fe717bb5e 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -14,6 +14,7 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ @@ -25,7 +26,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() @@ -166,6 +167,15 @@ export async function updateResourceRule( ) ); } + } else if (match === "REGION") { + if (!isValidRegionId(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid region ID provided" + ) + ); + } } } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index d10a38d65..c00f9e0df 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -75,6 +77,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { COUNTRIES } from "@server/db/countries"; import { MAJOR_ASNS } from "@server/db/asns"; +import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions"; import { Command, CommandEmpty, @@ -119,6 +122,8 @@ export default function ResourceRules(props: { useState(false); const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = + useState(false); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); @@ -138,7 +143,8 @@ export default function ResourceRules(props: { IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), - ASN: "ASN" + ASN: "ASN", + REGION: t("region") } as const; const addRuleForm = useForm({ @@ -258,6 +264,20 @@ export default function ResourceRules(props: { setLoading(false); return; } + if ( + data.match === "REGION" && + !isValidRegionId(data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidRegion"), + description: + t("rulesErrorInvalidRegionDescription") || + "Invalid region." + }); + setLoading(false); + return; + } // find the highest priority and add one let priority = data.priority; @@ -311,6 +331,8 @@ export default function ResourceRules(props: { return t("rulesMatchCountry"); case "ASN": return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + case "REGION": + return t("rulesMatchRegion"); } } @@ -536,12 +558,12 @@ export default function ResourceRules(props: { )} + {isMaxmindAvailable && ( + + { + RuleMatch.REGION + } + + )} {isMaxmindAsnAvailable && ( { @@ -1163,6 +1279,112 @@ export default function ResourceRules(props: { + ) : addRuleForm.watch( + "match" + ) === "REGION" ? ( + + + + + + + + + + {t( + "noRegionFound" + )} + + {REGIONS.map((continent) => ( + + { + field.onChange( + continent.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(continent.name)} ({continent.id}) + + {continent.includes.map((subregion) => ( + { + field.onChange( + subregion.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(subregion.name)} ({subregion.id}) + + ))} + + ))} + + + + ) : ( )} From 3d4df906cfa9edf05ea8f9a645365f8577e3a421 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 22 Dec 2025 18:43:43 +0100 Subject: [PATCH 002/122] Added missing translations --- messages/de-DE.json | 6 ++++++ messages/ru-RU.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/messages/de-DE.json b/messages/de-DE.json index 94950748f..ea3647cf7 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1735,6 +1735,12 @@ "country": "Land", "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", "region": "Region", + "selectRegion": "Region wählen...", + "searchRegions": "Regionen suchen...", + "noRegionFound": "Keine Region gefunden.", + "rulesMatchRegion": "Wählen Sie eine Regionalgruppe von Ländern", + "rulesErrorInvalidRegion": "Ungültige Region", + "rulesErrorInvalidRegionDescription": "Bitte wählen Sie eine gültige Region aus.", "regionAfrica": "Afrika", "regionNorthernAfrica": "Nordafrika", "regionEasternAfrica": "Ostafrika", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 5eb1ee076..95814125d 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1735,6 +1735,12 @@ "country": "Страна", "rulesMatchCountry": "В настоящее время основано на исходном IP", "region": "Регион", + "selectRegion": "Выберите регион", + "searchRegions": "Поиск регионов...", + "noRegionFound": "Регион не найден.", + "rulesMatchRegion": "Выберите региональную группу стран", + "rulesErrorInvalidRegion": "Некорректный регион", + "rulesErrorInvalidRegionDescription": "Пожалуйста, выберите корректный регион.", "regionAfrica": "Африка", "regionNorthernAfrica": "Северная Африка", "regionEasternAfrica": "Восточная Африка", From 20e547a0f602cdc7f212b5f519436d1e6c140a73 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 24 Feb 2026 17:58:11 -0800 Subject: [PATCH 003/122] first pass --- server/auth/actions.ts | 35 ++-- server/auth/canUserAccessResource.ts | 29 +-- server/auth/canUserAccessSiteResource.ts | 29 +-- server/db/pg/schema/schema.ts | 21 +- server/db/queries/verifySessionQueries.ts | 48 ++++- server/db/sqlite/schema/schema.ts | 28 ++- server/index.ts | 2 +- server/lib/calculateUserClientsForOrgs.ts | 33 ++- server/lib/rebuildClientAssociations.ts | 15 +- server/lib/userOrg.ts | 16 +- server/lib/userOrgRoles.ts | 22 ++ server/middlewares/getUserOrgs.ts | 3 +- server/middlewares/verifyAccessTokenAccess.ts | 8 +- server/middlewares/verifyAdmin.ts | 25 ++- server/middlewares/verifyApiKeyAccess.ts | 9 +- server/middlewares/verifyClientAccess.ts | 38 ++-- server/middlewares/verifyDomainAccess.ts | 8 +- server/middlewares/verifyOrgAccess.ts | 7 +- server/middlewares/verifyResourceAccess.ts | 35 ++-- server/middlewares/verifyRoleAccess.ts | 4 +- server/middlewares/verifySiteAccess.ts | 37 ++-- .../middlewares/verifySiteResourceAccess.ts | 38 ++-- server/middlewares/verifyTargetAccess.ts | 8 +- server/middlewares/verifyUserInRole.ts | 6 +- server/private/middlewares/verifyIdpAccess.ts | 9 +- .../middlewares/verifyRemoteExitNodeAccess.ts | 13 +- .../routers/org/sendUsageNotifications.ts | 18 +- .../remoteExitNode/createRemoteExitNode.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 62 ++++-- .../routers/accessToken/listAccessTokens.ts | 2 +- server/routers/badger/verifySession.ts | 33 ++- server/routers/client/createClient.ts | 4 +- server/routers/client/listClients.ts | 2 +- server/routers/client/listUserDevices.ts | 2 +- server/routers/external.ts | 10 + server/routers/idp/validateOidcCallback.ts | 51 +++-- server/routers/integration.ts | 10 + server/routers/newt/createNewt.ts | 2 +- server/routers/olm/createOlm.ts | 2 +- server/routers/org/checkOrgUserAccess.ts | 38 +++- server/routers/org/createOrg.ts | 13 +- server/routers/org/getOrgOverview.ts | 18 +- server/routers/org/listUserOrgs.ts | 30 ++- server/routers/resource/createResource.ts | 6 +- server/routers/resource/getUserResources.ts | 46 ++-- server/routers/resource/listResources.ts | 2 +- server/routers/role/deleteRole.ts | 8 +- server/routers/site/createSite.ts | 4 +- server/routers/site/listSites.ts | 2 +- server/routers/user/acceptInvite.ts | 4 +- server/routers/user/addUserRole.ts | 35 ++-- server/routers/user/createOrgUser.ts | 32 +-- server/routers/user/getOrgUser.ts | 38 +++- server/routers/user/index.ts | 1 + server/routers/user/listUsers.ts | 37 +++- server/routers/user/myDevice.ts | 23 +- server/routers/user/removeUserRole.ts | 157 ++++++++++++++ server/types/Auth.ts | 2 +- .../users/[userId]/access-controls/page.tsx | 196 ++++++++++++------ .../[orgId]/settings/access/users/page.tsx | 4 +- 60 files changed, 1023 insertions(+), 399 deletions(-) create mode 100644 server/lib/userOrgRoles.ts create mode 100644 server/routers/user/removeUserRole.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 3f5a145b6..feb91560a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -1,7 +1,7 @@ import { Request } from "express"; import { db } from "@server/db"; -import { userActions, roleActions, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { userActions, roleActions } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -52,6 +52,7 @@ export enum ActionsEnum { listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", + removeUserRole = "removeUserRole", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", @@ -153,29 +154,19 @@ export async function checkUserActionPermission( } try { - let userOrgRoleId = req.userOrgRoleId; + let userOrgRoleIds = req.userOrgRoleIds; - // If userOrgRoleId is not available on the request, fetch it - if (userOrgRoleId === undefined) { - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, req.userOrgId!) - ) - ) - .limit(1); - - if (userOrgRole.length === 0) { + if (userOrgRoleIds === undefined) { + const { getUserOrgRoleIds } = await import( + "@server/lib/userOrgRoles" + ); + userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!); + if (userOrgRoleIds.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } - - userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org @@ -186,7 +177,7 @@ export async function checkUserActionPermission( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), - eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org + eq(userActions.orgId, req.userOrgId!) ) ) .limit(1); @@ -195,14 +186,14 @@ export async function checkUserActionPermission( return true; } - // If no direct permission, check role-based permission + // If no direct permission, check role-based permission (any of user's roles) const roleActionPermission = await db .select() .from(roleActions) .where( and( eq(roleActions.actionId, actionId), - eq(roleActions.roleId, userOrgRoleId!), + inArray(roleActions.roleId, userOrgRoleIds), eq(roleActions.orgId, req.userOrgId!) ) ) diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index 161a0bee9..2c8911490 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts index 959b0eff6..7e6ec9bb8 100644 --- a/server/auth/canUserAccessSiteResource.ts +++ b/server/auth/canUserAccessSiteResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleSiteResources, userSiteResources } from "@server/db"; export async function canUserAccessSiteResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, resourceId), - eq(roleSiteResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, resourceId), + inArray(roleSiteResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index ae90020a0..1d38bfb3e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -9,6 +9,7 @@ import { real, serial, text, + unique, varchar } from "drizzle-orm/pg-core"; @@ -332,9 +333,6 @@ export const userOrgs = pgTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: boolean("isOwner").notNull().default(false), autoProvisioned: boolean("autoProvisioned").default(false), pamUsername: varchar("pamUsername") // cleaned username for ssh and such @@ -383,6 +381,22 @@ export const roles = pgTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = pgTable( + "userOrgRoles", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = pgTable("roleActions", { roleId: integer("roleId") .notNull() @@ -1031,6 +1045,7 @@ export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 280c8a119..469df590d 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -12,6 +12,7 @@ import { resources, roleResources, sessions, + userOrgRoles, userOrgs, userResources, users, @@ -104,24 +105,57 @@ export async function getUserSessionWithUser( } /** - * Get user organization role + * Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role). + * @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support. */ export async function getUserOrgRole(userId: string, orgId: string) { - const userOrgRole = await db + const userOrg = await db .select({ userId: userOrgs.userId, orgId: userOrgs.orgId, - roleId: userOrgs.roleId, isOwner: userOrgs.isOwner, - autoProvisioned: userOrgs.autoProvisioned, - roleName: roles.name + autoProvisioned: userOrgs.autoProvisioned }) .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(1); - return userOrgRole.length > 0 ? userOrgRole[0] : null; + if (userOrg.length === 0) return null; + + const [firstRole] = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ) + .limit(1); + + return firstRole + ? { + ...userOrg[0], + roleId: firstRole.roleId, + roleName: firstRole.roleName + } + : { ...userOrg[0], roleId: null, roleName: null }; +} + +/** + * Get role name by role ID (for display). + */ +export async function getRoleName(roleId: number): Promise { + const [row] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + return row?.name ?? null; } /** diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 64866e679..2d475808b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,12 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + unique +} from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -635,9 +641,6 @@ export const userOrgs = sqliteTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" @@ -692,6 +695,22 @@ export const roles = sqliteTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = sqliteTable( + "userOrgRoles", + { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() @@ -1126,6 +1145,7 @@ export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index a61daca7f..0fc44c279 100644 --- a/server/index.ts +++ b/server/index.ts @@ -74,7 +74,7 @@ declare global { session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 4be76dddc..02ac0c417 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -10,6 +10,7 @@ import { roles, Transaction, userClients, + userOrgRoles, userOrgs } from "@server/db"; import { getUniqueClientName } from "@server/db/names"; @@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs( return; } - // Get all user orgs - const allUserOrgs = await transaction + // Get all user orgs with all roles (for org list and role-based logic) + const userOrgRoleRows = await transaction .select() .from(userOrgs) - .innerJoin(roles, eq(roles.roleId, userOrgs.roleId)) + .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 = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId); + 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); + } // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { - for (const userRoleOrg of allUserOrgs) { - const { userOrgs: userOrg, roles: role } = userRoleOrg; - const orgId = userOrg.orgId; + for (const orgId of orgIdToRoleRows.keys()) { + const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; + const userOrg = roleRowsForOrg[0].userOrgs; const [org] = await transaction .select() @@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs( const requireApproval = build !== "oss" && isOrgLicensed && - role.requireDeviceApproval; + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); const newClientData: InferInsertModel = { userId, diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 625e57935..7ec767492 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -14,6 +14,7 @@ import { siteResources, sites, Transaction, + userOrgRoles, userOrgs, userSiteResources } from "@server/db"; @@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess( // get all of the users in these roles const userIdsFromRoles = await trx .select({ - userId: userOrgs.userId + userId: userOrgRoles.userId }) - .from(userOrgs) - .where(inArray(userOrgs.roleId, roleIds)) + .from(userOrgRoles) + .where(inArray(userOrgRoles.roleId, roleIds)) .then((rows) => rows.map((row) => row.userId)); const newAllUserIds = Array.from( @@ -811,12 +812,12 @@ export async function rebuildClientAssociationsFromClient( // Role-based access const roleIds = await trx - .select({ roleId: userOrgs.roleId }) - .from(userOrgs) + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) .where( and( - eq(userOrgs.userId, client.userId), - eq(userOrgs.orgId, client.orgId) + eq(userOrgRoles.userId, client.userId), + eq(userOrgRoles.orgId, client.orgId) ) ) // this needs to be locked onto this org or else cross-org access could happen .then((rows) => rows.map((row) => row.roleId)); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 6ed10039b..fb0b88c2b 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -6,7 +6,7 @@ import { siteResources, sites, Transaction, - UserOrg, + userOrgRoles, userOrgs, userResources, userSiteResources, @@ -19,9 +19,15 @@ import { FeatureId } from "@server/lib/billing"; export async function assignUserToOrg( org: Org, values: typeof userOrgs.$inferInsert, + roleId: number, trx: Transaction | typeof db = db ) { const [userOrg] = await trx.insert(userOrgs).values(values).returning(); + await trx.insert(userOrgRoles).values({ + userId: userOrg.userId, + orgId: userOrg.orgId, + roleId + }); // calculate if the user is in any other of the orgs before we count it as an add to the billing org if (org.billingOrgId) { @@ -58,6 +64,14 @@ export async function removeUserFromOrg( userId: string, trx: Transaction | typeof db = db ) { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, org.orgId) + ) + ); await trx .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts new file mode 100644 index 000000000..5a4d75659 --- /dev/null +++ b/server/lib/userOrgRoles.ts @@ -0,0 +1,22 @@ +import { db, userOrgRoles } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Get all role IDs a user has in an organization. + * Returns empty array if the user has no roles in the org (callers must treat as no access). + */ +export async function getUserOrgRoleIds( + userId: string, + orgId: string +): Promise { + const rows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + return rows.map((r) => r.roleId); +} diff --git a/server/middlewares/getUserOrgs.ts b/server/middlewares/getUserOrgs.ts index d7905700e..fa9794fb9 100644 --- a/server/middlewares/getUserOrgs.ts +++ b/server/middlewares/getUserOrgs.ts @@ -21,8 +21,7 @@ export async function getUserOrgs( try { const userOrganizations = await db .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId + orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 033b326d9..f1f2ca52e 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAccessTokenAccess( req: Request, @@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 253bfc2dd..0dbeac2cb 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAdmin( req: Request, @@ -62,13 +63,29 @@ export async function verifyAdmin( } } - const userRole = await db + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!); + + if (req.userOrgRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have Admin access" + ) + ); + } + + const userAdminRoles = await db .select() .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)) + .where( + and( + inArray(roles.roleId, req.userOrgRoleIds), + eq(roles.isAdmin, true) + ) + ) .limit(1); - if (userRole.length === 0 || !userRole[0].isAdmin) { + if (userAdminRoles.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 6edc5ab8e..b497892c8 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyApiKeyAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyApiKeyAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + orgId + ); return next(); } catch (error) { diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index d2df38a4b..1d994b53f 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { Client, db } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import logger from "@server/logger"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyClientAccess( req: Request, @@ -113,21 +114,30 @@ export async function verifyClientAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + client.orgId + ); req.userOrgId = client.orgId; - // Check role-based site access first - const [roleClientAccess] = await db - .select() - .from(roleClients) - .where( - and( - eq(roleClients.clientId, client.clientId), - eq(roleClients.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based client access (any of user's roles) + const roleClientAccessList = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, client.clientId), + inArray( + roleClients.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; + const [roleClientAccess] = roleClientAccessList; if (roleClientAccess) { // User has access to the site through their role diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 88ffe678d..c9ecf42e0 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains } from "@server/db"; -import { userOrgs, apiKeyOrg } from "@server/db"; +import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyDomainAccess( req: Request, @@ -63,7 +64,7 @@ export async function verifyDomainAccess( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, apiKeyOrg.orgId) + eq(userOrgs.orgId, orgId) ) ) .limit(1); @@ -97,8 +98,7 @@ export async function verifyDomainAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); return next(); } catch (error) { diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 729766abd..cb797afb0 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db, orgs } from "@server/db"; +import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyOrgAccess( req: Request, @@ -64,8 +65,8 @@ export async function verifyOrgAccess( } } - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; + // User has access, attach the user's role(s) to the request for potential future use + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.userOrgId = orgId; return next(); diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 2ae591ee1..ba49f02e3 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyResourceAccess( req: Request, @@ -107,20 +108,28 @@ export async function verifyResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource.orgId + ); req.userOrgId = resource.orgId; - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resource.resourceId), + inArray( + roleResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 8858ab53f..380b82048 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRoleAccess( req: Request, @@ -99,7 +100,6 @@ export async function verifyRoleAccess( } if (!req.userOrg) { - // get the userORg const userOrg = await db .select() .from(userOrgs) @@ -109,7 +109,7 @@ export async function verifyRoleAccess( .limit(1); req.userOrg = userOrg[0]; - req.userOrgRoleId = userOrg[0].roleId; + req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!); } if (!req.userOrg) { diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 98858cfb9..e630cf0f1 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteAccess( req: Request, @@ -112,21 +113,29 @@ export async function verifySiteAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + site.orgId + ); req.userOrgId = site.orgId; - // Check role-based site access first - const roleSiteAccess = await db - .select() - .from(roleSites) - .where( - and( - eq(roleSites.siteId, site.siteId), - eq(roleSites.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based site access first (any of user's roles) + const roleSiteAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSites) + .where( + and( + eq(roleSites.siteId, site.siteId), + inArray( + roleSites.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleSiteAccess.length > 0) { // User's role has access to the site diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index ca7d37fb3..8d5bd656f 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteResourceAccess( req: Request, @@ -109,23 +110,34 @@ export async function verifySiteResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + siteResource.orgId + ); req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceIdNum), - eq(roleSiteResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceIdNum + ), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 7e433fcb8..141a04549 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyTargetAccess( req: Request, @@ -99,7 +100,10 @@ export async function verifyTargetAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -126,7 +130,7 @@ export async function verifyTargetAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts index 2a153114d..18eeb44f3 100644 --- a/server/middlewares/verifyUserInRole.ts +++ b/server/middlewares/verifyUserInRole.ts @@ -12,7 +12,7 @@ export async function verifyUserInRole( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - const userRoleId = req.userOrgRoleId; + const userOrgRoleIds = req.userOrgRoleIds ?? []; if (isNaN(roleId)) { return next( @@ -20,7 +20,7 @@ export async function verifyUserInRole( ); } - if (!userRoleId) { + if (userOrgRoleIds.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -29,7 +29,7 @@ export async function verifyUserInRole( ); } - if (userRoleId !== roleId) { + if (!userOrgRoleIds.includes(roleId)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts index 410956844..2dbc1b8ff 100644 --- a/server/private/middlewares/verifyIdpAccess.ts +++ b/server/private/middlewares/verifyIdpAccess.ts @@ -13,9 +13,10 @@ import { Request, Response, NextFunction } from "express"; import { userOrgs, db, idp, idpOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyIdpAccess( req: Request, @@ -84,8 +85,10 @@ export async function verifyIdpAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + idpRes.idpOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts index a2cd2bace..7d6128d8f 100644 --- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts +++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts @@ -12,11 +12,12 @@ */ import { Request, Response, NextFunction } from "express"; -import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; -import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { db, exitNodeOrgs, remoteExitNodes } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRemoteExitNodeAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + exitNodeOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts index 4aa421520..72fc00d4c 100644 --- a/server/private/routers/org/sendUsageNotifications.ts +++ b/server/private/routers/org/sendUsageNotifications.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userOrgs, users, roles, orgs } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) { }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( and( eq(userOrgs.orgId, orgId), @@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) { ) ); - // Filter to only include users with verified emails - const orgAdmins = admins.filter( + // Dedupe by userId (user may have multiple roles) + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( (admin) => admin.email && admin.email.length > 0 ); diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index 6d5b5ea6f..f24afdde1 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -79,7 +79,7 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index fbdee72d1..f45db3c85 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -30,7 +30,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, or, and } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; import config from "@server/lib/config"; @@ -122,7 +122,7 @@ export async function signSshKey( resource: resourceQueryString } = parsedBody.data; const userId = req.user?.userId; - const roleId = req.userOrgRoleId!; + const roleIds = req.userOrgRoleIds ?? []; if (!userId) { return next( @@ -130,6 +130,15 @@ export async function signSshKey( ); } + if (roleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User has no role in organization" + ) + ); + } + const [userOrg] = await db .select() .from(userOrgs) @@ -310,11 +319,11 @@ export async function signSshKey( ); } - // Check if the user has access to the resource + // Check if the user has access to the resource (any of their roles) const hasAccess = await canUserAccessSiteResource({ userId: userId, resourceId: resource.siteResourceId, - roleId: roleId + roleIds }); if (!hasAccess) { @@ -326,28 +335,39 @@ export async function signSshKey( ); } - const [roleRow] = await db + const roleRows = await db .select() .from(roles) - .where(eq(roles.roleId, roleId)) - .limit(1); + .where(inArray(roles.roleId, roleIds)); - let parsedSudoCommands: string[] = []; - let parsedGroups: string[] = []; - try { - parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); - if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; - } catch { - parsedSudoCommands = []; + const parsedSudoCommands: string[] = []; + const parsedGroupsSet = new Set(); + let homedir: boolean | null = null; + const sudoModeOrder = { none: 0, commands: 1, all: 2 }; + let sudoMode: "none" | "commands" | "all" = "none"; + for (const roleRow of roleRows) { + try { + const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); + } catch { + // skip + } + try { + const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g)); + } catch { + // skip + } + if (roleRow?.sshCreateHomeDir === true) homedir = true; + const m = roleRow?.sshSudoMode ?? "none"; + if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { + sudoMode = m as "none" | "commands" | "all"; + } } - try { - parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); - if (!Array.isArray(parsedGroups)) parsedGroups = []; - } catch { - parsedGroups = []; + const parsedGroups = Array.from(parsedGroupsSet); + if (homedir === null && roleRows.length > 0) { + homedir = roleRows[0].sshCreateHomeDir ?? null; } - const homedir = roleRow?.sshCreateHomeDir ?? null; - const sudoMode = roleRow?.sshSudoMode ?? "none"; // get the site const [newt] = await db diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 2f929fc62..495afeb3c 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -208,7 +208,7 @@ export async function listAccessTokens( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b5c66c0e9..2f6f7ac12 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -3,12 +3,13 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke import { getResourceByDomain, getResourceRules, + getRoleName, getRoleResourceAccess, - getUserOrgRole, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { LoginPage, Org, @@ -916,9 +917,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); + const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId); - if (!userOrgRole) { + if (!userOrgRoleIds.length) { return null; } @@ -934,17 +935,23 @@ async function isUserAllowedToAccessResource( return null; } - const roleResourceAccess = await getRoleResourceAccess( - resource.resourceId, - userOrgRole.roleId - ); - - if (roleResourceAccess) { + const roleNames: string[] = []; + for (const roleId of userOrgRoleIds) { + const roleResourceAccess = await getRoleResourceAccess( + resource.resourceId, + roleId + ); + if (roleResourceAccess) { + const roleName = await getRoleName(roleId); + if (roleName) roleNames.push(roleName); + } + } + if (roleNames.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role: roleNames.join(", ") }; } @@ -954,11 +961,15 @@ async function isUserAllowedToAccessResource( ); if (userResourceAccess) { + const names = await Promise.all( + userOrgRoleIds.map((id) => getRoleName(id)) + ); + const role = names.filter(Boolean).join(", ") || ""; return { username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role }; } diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 4eafb0616..3e5ba4fa1 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -92,7 +92,7 @@ export async function createClient( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -234,7 +234,7 @@ export async function createClient( clientId: newClient.clientId }); - if (req.user && req.userOrgRoleId != adminRole.roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) { // make sure the user can access the client trx.insert(userClients).values({ userId: req.user.userId, diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 53a66150c..95d6281bf 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -297,7 +297,7 @@ export async function listClients( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 54fffe43b..4d37dc440 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -316,7 +316,7 @@ export async function listUserDevices( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/external.ts b/server/routers/external.ts index 45ab58bba..bae7bb4db 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -654,6 +654,16 @@ authenticated.post( user.addUserRole ); +authenticated.delete( + "/role/:roleId/remove/:userId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); + authenticated.post( "/resource/:resourceId/roles", verifyResourceAccess, diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e34621856..8714c4d38 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -13,6 +13,7 @@ import { orgs, Role, roles, + userOrgRoles, userOrgs, users } from "@server/db"; @@ -570,32 +571,28 @@ export async function validateOidcCallback( } } - // Update roles for existing auto-provisioned orgs where the role has changed - const orgsToUpdate = autoProvisionedOrgs.filter( - (currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - } - ); - - if (orgsToUpdate.length > 0) { - for (const org of orgsToUpdate) { - const newRole = userOrgInfo.find( - (newOrg) => newOrg.orgId === org.orgId - ); - if (newRole) { - await trx - .update(userOrgs) - .set({ roleId: newRole.roleId }) - .where( - and( - eq(userOrgs.userId, userId!), - eq(userOrgs.orgId, org.orgId) - ) - ); - } + // Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles) + const userRolesInOrgs = await trx + .select() + .from(userOrgRoles) + .where(eq(userOrgRoles.userId, userId!)); + for (const currentOrg of autoProvisionedOrgs) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + if (!newRole) continue; + const currentRolesInOrg = userRolesInOrgs.filter( + (r) => r.orgId === currentOrg.orgId + ); + const hasIdpRole = currentRolesInOrg.some( + (r) => r.roleId === newRole.roleId + ); + if (!hasIdpRole) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: currentOrg.orgId, + roleId: newRole.roleId + }); } } @@ -619,9 +616,9 @@ export async function validateOidcCallback( { orgId: org.orgId, userId: userId!, - roleId: org.roleId, autoProvisioned: true, }, + org.roleId, trx ); } diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6c39fe983..56e44c661 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -532,6 +532,16 @@ authenticated.post( user.addUserRole ); +authenticated.delete( + "/role/:roleId/remove/:userId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); + authenticated.post( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/olm/createOlm.ts +++ b/server/routers/olm/createOlm.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index d9f0364e3..19e39c4fe 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 1a5d8799f..22dc742fa 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -9,6 +9,7 @@ import { orgs, roleActions, roles, + userOrgRoles, userOrgs, users, actions @@ -312,9 +313,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner @@ -332,9 +337,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: serverAdmin.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = serverAdmin.userId; } diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index d368d1b3c..fcdd7c0ed 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -117,20 +117,26 @@ export async function getOrgOverview( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)); + const roleIds = req.userOrgRoleIds ?? []; + const roleRows = + roleIds.length > 0 + ? await db + .select({ name: roles.name, isAdmin: roles.isAdmin }) + .from(roles) + .where(inArray(roles.roleId, roleIds)) + : []; + const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? ""; + const isAdmin = roleRows.some((r) => r.isAdmin === true); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, - userRoleName: role.name, + userRoleName, numSites, numUsers, numResources, - isAdmin: role.isAdmin || false, + isAdmin, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 301d0203e..8e6ce649d 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, roles } from "@server/db"; -import { Org, orgs, userOrgs } from "@server/db"; +import { Org, orgs, userOrgRoles, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -82,10 +82,7 @@ export async function listUserOrgs( const { userId } = parsedParams.data; const userOrganizations = await db - .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId - }) + .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); @@ -116,10 +113,27 @@ export async function listUserOrgs( userOrgs, and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) ) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(limit) .offset(offset); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + inArray(userOrgRoles.orgId, userOrgIds) + ) + ); + + const orgHasAdmin = new Set( + roleRows.filter((r) => r.isAdmin).map((r) => r.orgId) + ); + const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs) @@ -133,8 +147,8 @@ export async function listUserOrgs( if (val.userOrgs && val.userOrgs.isOwner) { res.isOwner = val.userOrgs.isOwner; } - if (val.roles && val.roles.isAdmin) { - res.isAdmin = val.roles.isAdmin; + if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) { + res.isAdmin = true; } if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { res.isPrimaryOrg = val.orgs.isBillingOrg; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 232cea266..d2124d22e 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -112,7 +112,7 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -278,7 +278,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, @@ -371,7 +371,7 @@ async function createRawResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index eb5f8a8d9..9afd6b4f3 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -5,6 +5,7 @@ import { resources, userResources, roleResources, + userOrgRoles, userOrgs, resourcePassword, resourcePincode, @@ -32,22 +33,29 @@ export async function getUserResources( ); } - // First get the user's role in the organization - const userOrgResult = await db - .select({ - roleId: userOrgs.roleId - }) + // Check user is in organization and get their role IDs + const [userOrg] = await db + .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - if (userOrgResult.length === 0) { + if (!userOrg) { return next( createHttpError(HttpCode.FORBIDDEN, "User not in organization") ); } - const userRoleId = userOrgResult[0].roleId; + 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)); // Get resources accessible through direct assignment or role assignment const directResourcesQuery = db @@ -55,20 +63,28 @@ export async function getUserResources( .from(userResources) .where(eq(userResources.userId, userId)); - const roleResourcesQuery = db - .select({ resourceId: roleResources.resourceId }) - .from(roleResources) - .where(eq(roleResources.roleId, userRoleId)); + const roleResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(inArray(roleResources.roleId, userRoleIds)) + : Promise.resolve([]); const directSiteResourcesQuery = db .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) .where(eq(userSiteResources.userId, userId)); - const roleSiteResourcesQuery = db - .select({ siteResourceId: roleSiteResources.siteResourceId }) - .from(roleSiteResources) - .where(eq(roleSiteResources.roleId, userRoleId)); + const roleSiteResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]); const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ directResourcesQuery, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index a26a5df50..e6524a72e 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -276,7 +276,7 @@ export async function listResources( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 490fe91cc..24c26e654 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userOrgs } from "@server/db"; +import { roles, userOrgRoles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -114,11 +114,11 @@ export async function deleteRole( } await db.transaction(async (trx) => { - // move all users from the userOrgs table with roleId to newRoleId + // move all users from userOrgRoles with roleId to newRoleId await trx - .update(userOrgs) + .update(userOrgRoles) .set({ roleId: newRoleId }) - .where(eq(userOrgs.roleId, roleId)); + .where(eq(userOrgRoles.roleId, roleId)); // delete the old role await trx.delete(roles).where(eq(roles.roleId, roleId)); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index ea4bc3e85..57e963e56 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -111,7 +111,7 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -399,7 +399,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index e4881b1ab..f2d460ff7 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -235,7 +235,7 @@ export async function listSites( .where( or( eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 388db4a31..30d3be7b9 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -165,9 +165,9 @@ export async function acceptInvite( org, { userId: existingUser[0].userId, - orgId: existingInvite.orgId, - roleId: existingInvite.roleId + orgId: existingInvite.orgId }, + existingInvite.roleId, trx ); diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index 32eaa19d7..d41ad2051 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db, UserOrg } from "@server/db"; -import { userOrgs, roles } from "@server/db"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -111,20 +111,23 @@ export async function addUserRole( ); } - let newUserRole: UserOrg | null = null; + let newUserRole: { userId: string; orgId: string; roleId: number } | null = + null; await db.transaction(async (trx) => { - [newUserRole] = await trx - .update(userOrgs) - .set({ roleId }) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, role.orgId) - ) - ) + const inserted = await trx + .insert(userOrgRoles) + .values({ + userId, + orgId: role.orgId, + roleId + }) + .onConflictDoNothing() .returning(); - // get the client associated with this user in this org + if (inserted.length > 0) { + newUserRole = inserted[0]; + } + const orgClients = await trx .select() .from(clients) @@ -133,17 +136,15 @@ export async function addUserRole( eq(clients.userId, userId), eq(clients.orgId, role.orgId) ) - ) - .limit(1); + ); for (const orgClient of orgClients) { - // we just changed the user's role, so we need to rebuild client associations and what they have access to await rebuildClientAssociationsFromClient(orgClient, trx); } }); return response(res, { - data: newUserRole, + data: newUserRole ?? { userId, orgId: role.orgId, roleId }, success: true, error: false, message: "Role added to user successfully", diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index b39ea22e2..891836651 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -221,12 +221,16 @@ export async function createOrgUser( ); } - await assignUserToOrg(org, { - orgId, - userId: existingUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: existingUser.userId, + autoProvisioned: false, + }, + role.roleId, + trx + ); } else { userId = generateId(15); @@ -244,12 +248,16 @@ export async function createOrgUser( }) .returning(); - await assignUserToOrg(org, { - orgId, - userId: newUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: newUser.userId, + autoProvisioned: false, + }, + role.roleId, + trx + ); } await calculateUserClientsForOrgs(userId, trx); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index f22a29d37..2cced3fc2 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -20,10 +20,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -33,13 +30,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type GetOrgUserResponse = NonNullable< diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 35c5c4a7c..2de44d8b1 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -2,6 +2,7 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; export * from "./addUserRole"; +export * from "./removeUserRole"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 401dcf58b..aeced75b1 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgs, users } from "@server/db"; +import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -31,7 +31,7 @@ const listUsersSchema = z.strictObject({ }); async function queryUsers(orgId: string, limit: number, offset: number) { - return await db + const rows = await db .select({ id: users.userId, email: users.email, @@ -41,8 +41,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, @@ -52,12 +50,39 @@ async function queryUsers(orgId: string, limit: number, offset: number) { }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); + + const roleRows = await db + .select({ + userId: userOrgRoles.userId, + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(eq(userOrgRoles.orgId, orgId)); + + const rolesByUser = new Map< + string, + { roleId: number; roleName: string }[] + >(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); + rolesByUser.set(r.userId, list); + } + + return rows.map((row) => { + const userRoles = rolesByUser.get(row.id) ?? []; + return { + ...row, + roles: userRoles + }; + }); } export type ListUsersResponse = { diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 144108e11..3b991ca56 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db"; import { idp, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -84,16 +84,31 @@ export async function myDevice( .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); - const userOrganizations = await db + const userOrgRows = await db .select({ orgId: userOrgs.orgId, - orgName: orgs.name, - roleId: userOrgs.roleId + orgName: orgs.name }) .from(userOrgs) .where(eq(userOrgs.userId, userId)) .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + roleId: userOrgRoles.roleId + }) + .from(userOrgRoles) + .where(eq(userOrgRoles.userId, userId)); + + const roleByOrg = new Map( + roleRows.map((r) => [r.orgId, r.roleId]) + ); + const userOrganizations = userOrgRows.map((row) => ({ + ...row, + roleId: roleByOrg.get(row.orgId) ?? 0 + })); + return response(res, { data: { user, diff --git a/server/routers/user/removeUserRole.ts b/server/routers/user/removeUserRole.ts new file mode 100644 index 000000000..8d353fea3 --- /dev/null +++ b/server/routers/user/removeUserRole.ts @@ -0,0 +1,157 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgRoles, userOrgs, roles, clients } from "@server/db"; +import { eq, and } 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 stoi from "@server/lib/stoi"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const removeUserRoleParamsSchema = z.strictObject({ + userId: z.string(), + roleId: z.string().transform(stoi).pipe(z.number()) +}); + +registry.registerPath({ + method: "delete", + path: "/role/{roleId}/remove/{userId}", + description: "Remove a role from a user. User must have at least one role left in the org.", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: removeUserRoleParamsSchema + }, + responses: {} +}); + +export async function removeUserRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeUserRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + const remainingRoles = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + if (remainingRoles.length <= 1) { + const hasThisRole = remainingRoles.some((r) => r.roleId === roleId); + if (hasThisRole) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User must have at least one role in the organization. Remove the last role is not allowed." + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId), + eq(userOrgRoles.roleId, roleId) + ) + ); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role removed from user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/types/Auth.ts b/server/types/Auth.ts index 8e222987c..398c02406 100644 --- a/server/types/Auth.ts +++ b/server/types/Auth.ts @@ -5,5 +5,5 @@ import { Session } from "@server/db"; export interface AuthenticatedRequest extends Request { user: User; session: Session; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; } diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 6313d512a..7a1dab309 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -8,7 +8,6 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { Select, SelectContent, @@ -19,7 +18,6 @@ import { import { Checkbox } from "@app/components/ui/checkbox"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,6 +42,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { UserType } from "@server/types/UserTypes"; +import { Badge } from "@app/components/ui/badge"; + +type UserRole = { roleId: number; name: string }; export default function AccessControlsPage() { const { orgUser: user } = userOrgUserContext(); @@ -54,12 +55,12 @@ export default function AccessControlsPage() { const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [userRoles, setUserRoles] = useState([]); const t = useTranslations(); const formSchema = z.object({ username: z.string(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), autoProvisioned: z.boolean() }); @@ -67,11 +68,17 @@ export default function AccessControlsPage() { resolver: zodResolver(formSchema), defaultValues: { username: user.username!, - roleId: user.roleId?.toString(), autoProvisioned: user.autoProvisioned || false } }); + const currentRoleIds = user.roleIds ?? []; + const currentRoles: UserRole[] = user.roles ?? []; + + useEffect(() => { + setUserRoles(currentRoles); + }, [user.userId, currentRoleIds.join(",")]); + useEffect(() => { async function fetchRoles() { const res = await api @@ -94,32 +101,20 @@ export default function AccessControlsPage() { } fetchRoles(); - - form.setValue("roleId", user.roleId.toString()); form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); - async function onSubmit(values: z.infer) { + async function handleAddRole(roleId: number) { setLoading(true); - try { - // Execute both API calls simultaneously - const [roleRes, userRes] = await Promise.all([ - api.post>( - `/role/${values.roleId}/add/${user.userId}` - ), - api.post(`/org/${orgId}/user/${user.userId}`, { - autoProvisioned: values.autoProvisioned - }) - ]); - - if (roleRes.status === 200 && userRes.status === 200) { - toast({ - variant: "default", - title: t("userSaved"), - description: t("userSavedDescription") - }); - } + await api.post(`/role/${roleId}/add/${user.userId}`); + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); + const role = roles.find((r) => r.roleId === roleId); + if (role) setUserRoles((prev) => [...prev, role]); } catch (e) { toast({ variant: "destructive", @@ -130,10 +125,61 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } + async function handleRemoveRole(roleId: number) { + setLoading(true); + try { + await api.delete(`/role/${roleId}/remove/${user.userId}`); + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); + setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId)); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: formatAxiosError( + e, + t("accessRoleErrorAddDescription") + ) + }); + } + setLoading(false); + } + + async function onSubmit(values: z.infer) { + setLoading(true); + try { + await api.post(`/org/${orgId}/user/${user.userId}`, { + autoProvisioned: values.autoProvisioned + }); + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: formatAxiosError( + e, + t("accessRoleErrorAddDescription") + ) + }); + } + setLoading(false); + } + + const availableRolesToAdd = roles.filter( + (r) => !userRoles.some((ur) => ur.roleId === r.roleId) + ); + const canRemoveRole = userRoles.length > 1; + return ( @@ -154,7 +200,6 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > - {/* IDP Type Display */} {user.type !== UserType.Internal && user.idpType && (
@@ -171,49 +216,72 @@ export default function AccessControlsPage() {
)} - ( - - {t("role")} + + {t("role")} +
+ {userRoles.map((r) => ( + + {r.name} + {canRemoveRole && ( + + )} + + ))} + {availableRolesToAdd.length > 0 && ( - - + )} +
+ {userRoles.length === 0 && ( +

+ {t("accessRoleSelectPlease")} +

)} - /> +
{user.idpAutoProvision && (
- {t("autoProvisioned")} + {t( + "autoProvisioned" + )}

{t( diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..c64ee6396 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -88,7 +88,9 @@ export default async function UsersPage(props: UsersPageProps) { status: t("userConfirmed"), role: user.isOwner ? t("accessRoleOwner") - : user.roleName || t("accessRoleMember"), + : user.roles?.length + ? user.roles.map((r) => r.roleName).join(", ") + : t("accessRoleMember"), isOwner: user.isOwner || false }; }); From dae169540bd3430f698725f9d8c830f441e5be96 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 2 Mar 2026 16:49:17 -0800 Subject: [PATCH 004/122] Fix defaults for orgs --- server/lib/readConfigFile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index cca0aa6aa..f6c8592e9 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -302,8 +302,8 @@ export const configSchema = z .optional() .default({ block_size: 24, - subnet_group: "100.90.128.0/24", - utility_subnet_group: "100.96.128.0/24" + subnet_group: "100.90.128.0/20", + utility_subnet_group: "100.96.128.0/20" }), rate_limits: z .object({ From 6cf1b9b0108d5f4eda8e22f716f91e8874a4c558 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 2 Mar 2026 18:51:48 -0800 Subject: [PATCH 005/122] Support improved targets msg v2 --- server/lib/ip.ts | 123 +++++++++++ server/lib/rebuildClientAssociations.ts | 32 ++- server/routers/client/targets.ts | 209 ++++++++++++++---- server/routers/newt/buildConfiguration.ts | 26 ++- server/routers/newt/handleGetConfigMessage.ts | 5 +- .../siteResource/updateSiteResource.ts | 10 +- 6 files changed, 326 insertions(+), 79 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 21ec78c1b..3a29b8661 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -571,6 +571,129 @@ export function generateSubnetProxyTargets( return targets; } +export type SubnetProxyTargetV2 = { + sourcePrefixes: string[]; // must be cidrs + destPrefix: string; // must be a cidr + disableIcmp?: boolean; + rewriteTo?: string; // must be a cidr + portRange?: { + min: number; + max: number; + protocol: "tcp" | "udp"; + }[]; +}; + +export function generateSubnetProxyTargetV2( + siteResource: SiteResource, + clients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[] +): SubnetProxyTargetV2 | undefined { + if (clients.length === 0) { + logger.debug( + `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` + ); + return; + } + + let target: SubnetProxyTargetV2 | null = null; + + const portRange = [ + ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), + ...parsePortRangeString(siteResource.udpPortRangeString, "udp") + ]; + const disableIcmp = siteResource.disableIcmp ?? false; + + if (siteResource.mode == "host") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + + target = { + sourcePrefixes: [], + destPrefix: destination, + portRange, + disableIcmp + }; + } + + if (siteResource.alias && siteResource.aliasAddress) { + // also push a match for the alias address + target = { + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination, + portRange, + disableIcmp + }; + } + } else if (siteResource.mode == "cidr") { + target = { + sourcePrefixes: [], + destPrefix: siteResource.destination, + portRange, + disableIcmp + }; + } + + if (!target) { + return; + } + + for (const clientSite of clients) { + if (!clientSite.subnet) { + logger.debug( + `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` + ); + continue; + } + + const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; + + // add client prefix to source prefixes + target.sourcePrefixes.push(clientPrefix); + } + + // print a nice representation of the targets + // logger.debug( + // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` + // ); + + return target; +} + + +/** + * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) + * by expanding each source prefix into its own target entry. + * @param targetV2 - The v2 target to convert + * @returns Array of v1 SubnetProxyTarget objects + */ + export function convertSubnetProxyTargetsV2ToV1( + targetsV2: SubnetProxyTargetV2[] + ): SubnetProxyTarget[] { + return targetsV2.flatMap((targetV2) => + targetV2.sourcePrefixes.map((sourcePrefix) => ({ + sourcePrefix, + destPrefix: targetV2.destPrefix, + ...(targetV2.disableIcmp !== undefined && { + disableIcmp: targetV2.disableIcmp + }), + ...(targetV2.rewriteTo !== undefined && { + rewriteTo: targetV2.rewriteTo + }), + ...(targetV2.portRange !== undefined && { + portRange: targetV2.portRange + }) + })) + ); + } + + // Custom schema for validating port range strings // Format: "80,443,8000-9000" or "*" for all ports, or empty string export const portRangeStringSchema = z diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 625e57935..915c3648d 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -32,7 +32,7 @@ import logger from "@server/logger"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets, + generateSubnetProxyTargetV2, parseEndpoint, formatEndpoint } from "@server/lib/ip"; @@ -659,17 +659,14 @@ async function handleSubnetProxyTargetUpdates( ); if (addedClients.length > 0) { - const targetsToAdd = generateSubnetProxyTargets( + const targetToAdd = generateSubnetProxyTargetV2( siteResource, addedClients ); - if (targetsToAdd.length > 0) { - logger.info( - `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToAdd) { proxyJobs.push( - addSubnetProxyTargets(newt.newtId, targetsToAdd) + addSubnetProxyTargets(newt.newtId, [targetToAdd]) ); } @@ -695,17 +692,14 @@ async function handleSubnetProxyTargetUpdates( ); if (removedClients.length > 0) { - const targetsToRemove = generateSubnetProxyTargets( + const targetToRemove = generateSubnetProxyTargetV2( siteResource, removedClients ); - if (targetsToRemove.length > 0) { - logger.info( - `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToRemove) { proxyJobs.push( - removeSubnetProxyTargets(newt.newtId, targetsToRemove) + removeSubnetProxyTargets(newt.newtId, [targetToRemove]) ); } @@ -1159,7 +1153,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1167,8 +1161,8 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { - proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets)); + if (target) { + proxyJobs.push(addSubnetProxyTargets(newt.newtId, [target])); } try { @@ -1230,7 +1224,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1238,9 +1232,9 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { + if (target) { proxyJobs.push( - removeSubnetProxyTargets(newt.newtId, targets) + removeSubnetProxyTargets(newt.newtId, [target]) ); } diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index bf612d352..48b7e216d 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,8 +1,15 @@ import { sendToClient } from "#dynamic/routers/ws"; -import { db, olms, Transaction } from "@server/db"; -import { Alias, SubnetProxyTarget } from "@server/lib/ip"; +import { S } from "@faker-js/faker/dist/airline-Dz1uGqgJ"; +import { db, newts, olms, Transaction } from "@server/db"; +import { + Alias, + convertSubnetProxyTargetsV2ToV1, + SubnetProxyTarget, + SubnetProxyTargetV2 +} from "@server/lib/ip"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; +import semver from "semver"; const BATCH_SIZE = 50; const BATCH_DELAY_MS = 50; @@ -19,57 +26,149 @@ function chunkArray(array: T[], size: number): T[][] { return chunks; } -export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) { - const batches = chunkArray(targets, BATCH_SIZE); +const NEWT_V2_TARGETS_VERSION = ">=1.11.0"; + +export async function convertTargetsIfNessicary( + newtId: string, + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] +) { + // get the newt + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + if (!newt) { + throw new Error(`No newt found for id: ${newtId}`); + } + + // check the semver + if ( + newt.version && + !semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION) + ) { + logger.debug( + `addTargets Newt version ${newt.version} does not support targets v2 falling back` + ); + targets = convertSubnetProxyTargetsV2ToV1( + targets as SubnetProxyTargetV2[] + ); + } + + return targets; +} + +export async function addTargets( + newtId: string, + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] +) { + targets = await convertTargetsIfNessicary(newtId, targets); + + const batches = chunkArray( + targets, + BATCH_SIZE + ); + for (let i = 0; i < batches.length; i++) { if (i > 0) { await sleep(BATCH_DELAY_MS); } - await sendToClient(newtId, { - type: `newt/wg/targets/add`, - data: batches[i] - }, { incrementConfigVersion: true }); + await sendToClient( + newtId, + { + type: `newt/wg/targets/add`, + data: batches[i] + }, + { incrementConfigVersion: true } + ); } } export async function removeTargets( newtId: string, - targets: SubnetProxyTarget[] + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] ) { - const batches = chunkArray(targets, BATCH_SIZE); + targets = await convertTargetsIfNessicary(newtId, targets); + + const batches = chunkArray( + targets, + BATCH_SIZE + ); for (let i = 0; i < batches.length; i++) { if (i > 0) { await sleep(BATCH_DELAY_MS); } - await sendToClient(newtId, { - type: `newt/wg/targets/remove`, - data: batches[i] - },{ incrementConfigVersion: true }); + await sendToClient( + newtId, + { + type: `newt/wg/targets/remove`, + data: batches[i] + }, + { incrementConfigVersion: true } + ); } } export async function updateTargets( newtId: string, targets: { - oldTargets: SubnetProxyTarget[]; - newTargets: SubnetProxyTarget[]; + oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; } ) { - const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE); - const newBatches = chunkArray(targets.newTargets, BATCH_SIZE); + // get the newt + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + if (!newt) { + logger.error(`addTargetsL No newt found for id: ${newtId}`); + return; + } + + // check the semver + if ( + newt.version && + !semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION) + ) { + logger.debug( + `addTargets Newt version ${newt.version} does not support targets v2 falling back` + ); + targets = { + oldTargets: convertSubnetProxyTargetsV2ToV1( + targets.oldTargets as SubnetProxyTargetV2[] + ), + newTargets: convertSubnetProxyTargetsV2ToV1( + targets.newTargets as SubnetProxyTargetV2[] + ) + }; + } + + const oldBatches = chunkArray( + targets.oldTargets, + BATCH_SIZE + ); + const newBatches = chunkArray( + targets.newTargets, + BATCH_SIZE + ); + const maxBatches = Math.max(oldBatches.length, newBatches.length); for (let i = 0; i < maxBatches; i++) { if (i > 0) { await sleep(BATCH_DELAY_MS); } - await sendToClient(newtId, { - type: `newt/wg/targets/update`, - data: { - oldTargets: oldBatches[i] || [], - newTargets: newBatches[i] || [] - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: `newt/wg/targets/update`, + data: { + oldTargets: oldBatches[i] || [], + newTargets: newBatches[i] || [] + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -94,14 +193,18 @@ export async function addPeerData( olmId = olm.olmId; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/add`, - data: { - siteId: siteId, - remoteSubnets: remoteSubnets, - aliases: aliases - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/add`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -125,14 +228,18 @@ export async function removePeerData( olmId = olm.olmId; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/remove`, - data: { - siteId: siteId, - remoteSubnets: remoteSubnets, - aliases: aliases - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/remove`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -166,14 +273,18 @@ export async function updatePeerData( olmId = olm.olmId; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/update`, - data: { - siteId: siteId, - ...remoteSubnets, - ...aliases - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/update`, + data: { + siteId: siteId, + ...remoteSubnets, + ...aliases + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index e349f24e8..c20e713f6 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,9 +1,23 @@ -import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db"; +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + ExitNode, + resources, + Site, + siteResources, + targetHealthCheck, + targets +} from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; -import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip"; +import { + generateSubnetProxyTargetV2, + SubnetProxyTargetV2 +} from "@server/lib/ip"; export async function buildClientConfigurationForNewtClient( site: Site, @@ -126,7 +140,7 @@ export async function buildClientConfigurationForNewtClient( .from(siteResources) .where(eq(siteResources.siteId, siteId)); - const targetsToSend: SubnetProxyTarget[] = []; + const targetsToSend: SubnetProxyTargetV2[] = []; for (const resource of allSiteResources) { // Get clients associated with this specific resource @@ -151,12 +165,14 @@ export async function buildClientConfigurationForNewtClient( ) ); - const resourceTargets = generateSubnetProxyTargets( + const resourceTarget = generateSubnetProxyTargetV2( resource, resourceClients ); - targetsToSend.push(...resourceTargets); + if (resourceTarget) { + targetsToSend.push(resourceTarget); + } } return { diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 801c8b65a..d17a37e48 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -6,6 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; +import { convertTargetsIfNessicary } from "../client/targets"; const inputSchema = z.object({ publicKey: z.string(), @@ -126,13 +127,15 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { exitNode ); + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); + return { message: { type: "newt/wg/receive-config", data: { ipAddress: site.address, peers, - targets + targets: targetsToSend } }, broadcast: false, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 242b92265..c8119840f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -24,7 +24,7 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets, + generateSubnetProxyTargetV2, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; @@ -608,18 +608,18 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if destination changed if (destinationChanged || portRangesChanged) { - const oldTargets = generateSubnetProxyTargets( + const oldTarget = generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients ); - const newTargets = generateSubnetProxyTargets( + const newTarget = generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); await updateTargets(newt.newtId, { - oldTargets: oldTargets, - newTargets: newTargets + oldTargets: [oldTarget], + newTargets: [newTarget] }); } From b01fcc70feab3d86939c5ae6fd43e8bcb4ee50db Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 3 Mar 2026 14:45:18 -0800 Subject: [PATCH 006/122] Fix ts and add note about ipv4 --- server/routers/siteResource/createSiteResource.ts | 2 +- server/routers/siteResource/updateSiteResource.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index bbdc3638d..b12b8d100 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -88,7 +88,7 @@ const createSiteResourceSchema = z }, { message: - "Destination must be a valid IP address or valid domain AND alias is required" + "Destination must be a valid IPV4 address or valid domain AND alias is required" } ) .refine( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index c8119840f..bc5daa55f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -618,8 +618,8 @@ export async function handleMessagingForUpdatedSiteResource( ); await updateTargets(newt.newtId, { - oldTargets: [oldTarget], - newTargets: [newTarget] + oldTargets: oldTarget ? [oldTarget] : [], + newTargets: newTarget ? [newTarget] : [] }); } From 84b082e1941b243f4aca9e193abc81a083c013a9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 12 Mar 2026 23:36:35 +0100 Subject: [PATCH 007/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20show=20actual=20va?= =?UTF-8?q?lues=20for=20wireguard=20site=20credentials=20whenever=20possib?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sites/[niceId]/credentials/page.tsx | 22 +++++++++---------- src/lib/wireguard.ts | 4 +--- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx index ecf3b105a..d4463cb7c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/credentials/page.tsx @@ -39,6 +39,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { NewtSiteInstallCommands } from "@app/components/newt-install-commands"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { AxiosResponse } from "axios"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -100,7 +101,9 @@ export default function CredentialsPage() { generatedPublicKey = generatedKeypair.publicKey; setPublicKey(generatedPublicKey); - const res = await api.get(`/org/${orgId}/pick-site-defaults`); + const res = await api.get< + AxiosResponse + >(`/org/${orgId}/pick-site-defaults`); if (res && res.status === 200) { const data = res.data.data; setSiteDefaults(data); @@ -108,7 +111,7 @@ export default function CredentialsPage() { // generate config with the fetched data generatedWgConfig = generateWireGuardConfig( generatedKeypair.privateKey, - data.publicKey, + generatedKeypair.publicKey, data.subnet, data.address, data.endpoint, @@ -322,7 +325,7 @@ export default function CredentialsPage() { {!loadingDefaults && ( <> {wgConfig ? ( -

+
Date: Fri, 13 Mar 2026 11:38:00 +0000 Subject: [PATCH 008/122] feat(installer): add default install directory with existing install detection - Default to /opt/pangolin for new installations - Check current directory and /opt/pangolin for existing installs - Prompt to use existing install if found at default location - Offer to change directory ownership when running via sudo - Create installation directory if it doesn't exist --- install/main.go | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/install/main.go b/install/main.go index 9de332b60..dddcb89b8 100644 --- a/install/main.go +++ b/install/main.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "text/template" "time" @@ -90,6 +91,13 @@ func main() { var config Config var alreadyInstalled = false + // Determine installation directory + installDir := findOrSelectInstallDirectory() + if err := os.Chdir(installDir); err != nil { + fmt.Printf("Error changing to installation directory: %v\n", err) + os.Exit(1) + } + // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { config = collectUserInput() @@ -287,6 +295,117 @@ func main() { fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } +func hasExistingInstall(dir string) bool { + configPath := filepath.Join(dir, "config", "config.yml") + _, err := os.Stat(configPath) + return err == nil +} + +func findOrSelectInstallDirectory() string { + const defaultInstallDir = "/opt/pangolin" + + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + fmt.Printf("Error getting current directory: %v\n", err) + os.Exit(1) + } + + // 1. Check current directory for existing install + if hasExistingInstall(cwd) { + fmt.Printf("Found existing Pangolin installation in current directory: %s\n", cwd) + return cwd + } + + // 2. Check default location (/opt/pangolin) for existing install + if cwd != defaultInstallDir && hasExistingInstall(defaultInstallDir) { + fmt.Printf("\nFound existing Pangolin installation at: %s\n", defaultInstallDir) + if readBool(fmt.Sprintf("Would you like to use the existing installation at %s?", defaultInstallDir), true) { + return defaultInstallDir + } + } + + // 3. No existing install found, prompt for installation directory + fmt.Println("\n=== Installation Directory ===") + fmt.Println("No existing Pangolin installation detected.") + + installDir := readString("Enter the installation directory", defaultInstallDir) + + // Expand ~ to home directory if present + if strings.HasPrefix(installDir, "~") { + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %v\n", err) + os.Exit(1) + } + installDir = filepath.Join(home, installDir[1:]) + } + + // Convert to absolute path + absPath, err := filepath.Abs(installDir) + if err != nil { + fmt.Printf("Error resolving path: %v\n", err) + os.Exit(1) + } + installDir = absPath + + // Check if directory exists + if _, err := os.Stat(installDir); os.IsNotExist(err) { + // Directory doesn't exist, create it + if readBool(fmt.Sprintf("Directory %s does not exist. Create it?", installDir), true) { + if err := os.MkdirAll(installDir, 0755); err != nil { + fmt.Printf("Error creating directory: %v\n", err) + os.Exit(1) + } + fmt.Printf("Created directory: %s\n", installDir) + + // Offer to change ownership if running via sudo + changeDirectoryOwnership(installDir) + } else { + fmt.Println("Installation cancelled.") + os.Exit(0) + } + } + + fmt.Printf("Installation directory: %s\n", installDir) + return installDir +} + +func changeDirectoryOwnership(dir string) { + // Check if we're running via sudo by looking for SUDO_USER + sudoUser := os.Getenv("SUDO_USER") + if sudoUser == "" || os.Geteuid() != 0 { + return + } + + sudoUID := os.Getenv("SUDO_UID") + sudoGID := os.Getenv("SUDO_GID") + + if sudoUID == "" || sudoGID == "" { + return + } + + fmt.Printf("\nRunning as root via sudo (original user: %s)\n", sudoUser) + if readBool(fmt.Sprintf("Would you like to change ownership of %s to user '%s'? This makes it easier to manage config files without sudo.", dir, sudoUser), true) { + uid, err := strconv.Atoi(sudoUID) + if err != nil { + fmt.Printf("Warning: Could not parse SUDO_UID: %v\n", err) + return + } + gid, err := strconv.Atoi(sudoGID) + if err != nil { + fmt.Printf("Warning: Could not parse SUDO_GID: %v\n", err) + return + } + + if err := os.Chown(dir, uid, gid); err != nil { + fmt.Printf("Warning: Could not change ownership: %v\n", err) + } else { + fmt.Printf("Changed ownership of %s to %s\n", dir, sudoUser) + } + } +} + func podmanOrDocker() SupportedContainer { inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker") From 18ed38889fa19ad32009043b48652458a959e669 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 17 Mar 2026 04:07:02 +0100 Subject: [PATCH 009/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20filter=20sites=20s?= =?UTF-8?q?erver=20side=20in=20resource=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/resource/getResource.ts | 17 ++-- server/routers/target/listTargets.ts | 1 + .../resources/proxy/[niceId]/proxy/page.tsx | 26 ++--- .../settings/resources/proxy/create/page.tsx | 16 ++-- .../resource-target-address-item.tsx | 94 ++++++++++++------- src/lib/queries.ts | 18 +++- 6 files changed, 107 insertions(+), 65 deletions(-) diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index cd870dcbf..7a52c0a85 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { Resource, resources, sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { db, resources } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; import stoi from "@server/lib/stoi"; +import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const getResourceSchema = z.strictObject({ resourceId: z diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index e4ef45f3b..18e932afa 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -40,6 +40,7 @@ function queryTargets(resourceId: number) { resourceId: targets.resourceId, siteId: targets.siteId, siteType: sites.type, + siteName: sites.name, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 51f11a2c3..aff10dc52 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -124,20 +124,15 @@ export default function ReverseProxyTargetsPage(props: { resourceId: resource.resourceId }) ); - const { data: sites = [], isLoading: isLoadingSites } = useQuery( - orgQueries.sites({ - orgId: params.orgId - }) - ); - if (isLoadingSites || isLoadingTargets) { + if (isLoadingTargets) { return null; } return ( @@ -160,12 +155,12 @@ export default function ReverseProxyTargetsPage(props: { } function ProxyResourceTargetsForm({ - sites, + orgId, initialTargets, resource }: { initialTargets: LocalTarget[]; - sites: ListSitesResponse["sites"]; + orgId: string; resource: GetResourceResponse; }) { const t = useTranslations(); @@ -243,17 +238,21 @@ function ProxyResourceTargetsForm({ }); }, []); + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId + }) + ); + const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { - const site = sites.find((site) => site.siteId === data.siteId); return prevTargets.map((target) => target.targetId === targetId ? { ...target, ...data, - updated: true, - siteType: site ? site.type : target.siteType + updated: true } : target ); @@ -453,7 +452,7 @@ function ProxyResourceTargetsForm({ return ( 0 ? sites[0].siteId : 0, + siteName: sites.length > 0 ? sites[0].name : "", path: isHttp ? null : null, pathMatchType: isHttp ? null : null, rewritePath: isHttp ? null : null, diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 4c8cb8443..9a9eb3ba2 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -216,9 +216,7 @@ export default function Page() { const [remoteExitNodes, setRemoteExitNodes] = useState< ListRemoteExitNodesResponse["remoteExitNodes"] >([]); - const [loadingExitNodes, setLoadingExitNodes] = useState( - build === "saas" - ); + const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [createLoading, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); @@ -282,6 +280,7 @@ export default function Page() { method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, + siteName: sites.length > 0 ? sites[0].name : "", path: isHttp ? null : null, pathMatchType: isHttp ? null : null, rewritePath: isHttp ? null : null, @@ -336,8 +335,7 @@ export default function Page() { // In saas mode with no exit nodes, force HTTP const showTypeSelector = - build !== "saas" || - (!loadingExitNodes && remoteExitNodes.length > 0); + build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0); const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), @@ -600,7 +598,10 @@ export default function Page() { toast({ variant: "destructive", title: t("resourceErrorCreate"), - description: formatAxiosError(e, t("resourceErrorCreateMessageDescription")) + description: formatAxiosError( + e, + t("resourceErrorCreateMessageDescription") + ) }); } @@ -826,7 +827,8 @@ export default function Page() { cell: ({ row }) => ( DockerState; updateTarget: (targetId: number, data: Partial) => void; - sites: SiteWithUpdateAvailable[]; + orgId: string; proxyTarget: LocalTarget; isHttp: boolean; refreshContainersForSite: (siteId: number) => void; }; export function ResourceTargetAddressItem({ - sites, + orgId, getDockerStateForSite, updateTarget, proxyTarget, @@ -52,10 +54,34 @@ export function ResourceTargetAddressItem({ }: ResourceTargetAddressItemProps) { const t = useTranslations(); - const selectedSite = sites.find( - (site) => site.siteId === proxyTarget.siteId + const [siteSearchQuery, setSiteSearchQuery] = useState(""); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId, + query: siteSearchQuery, + perPage: 10 + }) ); + const [selectedSite, setSelectedSite] = useState | null>(() => { + if ( + proxyTarget.siteName && + proxyTarget.siteType && + proxyTarget.siteId + ) { + return { + name: proxyTarget.siteName, + siteId: proxyTarget.siteId, + type: proxyTarget.siteType + }; + } + return null; + }); + const handleContainerSelectForTarget = ( hostname: string, port?: number @@ -70,28 +96,23 @@ export function ResourceTargetAddressItem({ return (
- {selectedSite && - selectedSite.type === "newt" && - (() => { - const dockerState = getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })()} + {selectedSite && selectedSite.type === "newt" && ( + + refreshContainersForSite(selectedSite.siteId) + } + /> + )} @@ -113,8 +134,11 @@ export function ResourceTargetAddressItem({ - - + + setSiteSearchQuery(v)} + /> {t("siteNotFound")} @@ -122,14 +146,18 @@ export function ResourceTargetAddressItem({ + onSelect={() => { updateTarget( proxyTarget.targetId, { - siteId: site.siteId + siteId: site.siteId, + siteType: site.type, + siteName: site.name } - ) - } + ); + + setSelectedSite(site); + }} > + sites: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "SITES"] as const, + queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - pageSize: "10000" + pageSize: perPage.toString() }); + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/sites?${sp.toString()}`, { signal }); From 435cae06a2f5472b228496b2cf37c5f9aa2d9579 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 17 Mar 2026 04:16:24 +0100 Subject: [PATCH 010/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resource-target-address-item.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 2e06c99a9..dfefc3bf2 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -9,7 +9,7 @@ import type { ArrayElement } from "@server/types/ArrayElement"; import { useQuery } from "@tanstack/react-query"; import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ContainersSelector } from "./ContainersSelector"; import { Button } from "./ui/button"; import { @@ -82,6 +82,22 @@ export function ResourceTargetAddressItem({ return null; }); + const sitesShown = useMemo(() => { + const allSites: Array< + Pick + > = [...sites]; + if ( + selectedSite !== null && + !( + allSites.find((site) => site.siteId)?.siteId === + selectedSite?.siteId + ) + ) { + allSites.unshift(selectedSite); + } + return allSites; + }, [sites, selectedSite]); + const handleContainerSelectForTarget = ( hostname: string, port?: number @@ -137,12 +153,13 @@ export function ResourceTargetAddressItem({ setSiteSearchQuery(v)} /> {t("siteNotFound")} - {sites.map((site) => ( + {sitesShown.map((site) => ( Date: Thu, 19 Mar 2026 00:35:26 +0100 Subject: [PATCH 011/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20make=20site=20s?= =?UTF-8?q?elector=20popover=20its=20own=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resource-target-address-item.tsx | 80 +++------------- src/components/site-selector.tsx | 91 +++++++++++++++++++ 2 files changed, 104 insertions(+), 67 deletions(-) create mode 100644 src/components/site-selector.tsx diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index dfefc3bf2..851b64b54 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -23,6 +23,7 @@ import { import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; +import { SitesSelector } from "./site-selector"; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; @@ -54,16 +55,6 @@ export function ResourceTargetAddressItem({ }: ResourceTargetAddressItemProps) { const t = useTranslations(); - const [siteSearchQuery, setSiteSearchQuery] = useState(""); - - const { data: sites = [] } = useQuery( - orgQueries.sites({ - orgId, - query: siteSearchQuery, - perPage: 10 - }) - ); - const [selectedSite, setSelectedSite] = useState { - const allSites: Array< - Pick - > = [...sites]; - if ( - selectedSite !== null && - !( - allSites.find((site) => site.siteId)?.siteId === - selectedSite?.siteId - ) - ) { - allSites.unshift(selectedSite); - } - return allSites; - }, [sites, selectedSite]); - const handleContainerSelectForTarget = ( hostname: string, port?: number @@ -150,47 +125,18 @@ export function ResourceTargetAddressItem({ - - setSiteSearchQuery(v)} - /> - - {t("siteNotFound")} - - {sitesShown.map((site) => ( - { - updateTarget( - proxyTarget.targetId, - { - siteId: site.siteId, - siteType: site.type, - siteName: site.name - } - ); - - setSelectedSite(site); - }} - > - - {site.name} - - ))} - - - + { + updateTarget(proxyTarget.targetId, { + siteId: site.siteId, + siteType: site.type, + siteName: site.name + }); + setSelectedSite(site); + }} + /> diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx new file mode 100644 index 000000000..9b7d44034 --- /dev/null +++ b/src/components/site-selector.tsx @@ -0,0 +1,91 @@ +import { orgQueries } from "@app/lib/queries"; +import type { ListSitesResponse } from "@server/routers/site"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { cn } from "@app/lib/cn"; +import { CheckIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; + +type Selectedsite = Pick< + ListSitesResponse["sites"][number], + "name" | "siteId" | "type" +>; + +export type SitesSelectorProps = { + orgId: string; + selectedSite?: Selectedsite | null; + onSelectSite: (selected: Selectedsite) => void; +}; + +export function SitesSelector({ + orgId, + selectedSite, + onSelectSite +}: SitesSelectorProps) { + const t = useTranslations(); + const [siteSearchQuery, setSiteSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(siteSearchQuery, 150); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 10 + }) + ); + + // always include the selected site in the list of sites shown + const sitesShown = useMemo(() => { + const allSites: Array = [...sites]; + if ( + selectedSite && + !allSites.find((site) => site.siteId === selectedSite?.siteId) + ) { + allSites.unshift(selectedSite); + } + return allSites; + }, [sites, selectedSite]); + + return ( + + setSiteSearchQuery(v)} + /> + + {t("siteNotFound")} + + {sitesShown.map((site) => ( + { + onSelectSite(site); + }} + > + + {site.name} + + ))} + + + + ); +} From 8f33e25782b1dccb80997014d321bc2258b2a3c9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Mar 2026 01:18:27 +0100 Subject: [PATCH 012/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20site=20selec?= =?UTF-8?q?tor=20on=20private=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 81 +++++++------------------ src/components/site-selector.tsx | 2 +- 2 files changed, 24 insertions(+), 59 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 6df1aceb7..fa1ee22d3 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1,15 +1,10 @@ "use client"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { StrategySelect } from "@app/components/StrategySelect"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; import { Form, FormControl, @@ -32,24 +27,22 @@ import { SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { cn } from "@app/lib/cn"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { useQueries, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ListSitesResponse } from "@server/routers/site"; import { UserType } from "@server/types/UserTypes"; -import { Check, ChevronsUpDown, ExternalLink } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { SitesSelector, type Selectedsite } from "./site-selector"; // --- Helpers (shared) --- @@ -407,6 +400,10 @@ export function InternalResourceForm({ clients: [] }; + const [selectedSite, setSelectedSite] = useState( + availableSites[0] + ); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues @@ -578,46 +575,14 @@ export function InternalResourceForm({ - - - - - {t("noSitesFound")} - - - {availableSites.map( - (site) => ( - - field.onChange( - site.siteId - ) - } - > - - {site.name} - - ) - )} - - - + { + setSelectedSite(site); + field.onChange(site.siteId); + }} + /> diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index 9b7d44034..0afd94c9a 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -15,7 +15,7 @@ import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useDebounce } from "use-debounce"; -type Selectedsite = Pick< +export type Selectedsite = Pick< ListSitesResponse["sites"][number], "name" | "siteId" | "type" >; From e15703164d231c284090ee04e86b48a7e357cfe5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 19 Mar 2026 04:44:24 +0100 Subject: [PATCH 013/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20resource=20selecto?= =?UTF-8?q?r=20in=20create=20share=20link=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreateShareLinkForm.tsx | 83 +++++++++++++++++--------- src/components/resource-selector.tsx | 81 +++++++++++++++++++++++++ src/lib/queries.ts | 18 +++++- 3 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 src/components/resource-selector.tsx diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 2f6f9aff2..fef1fb0e1 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -69,6 +69,7 @@ import { import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; +import { ResourceSelector, type SelectedResource } from "./resource-selector"; type FormProps = { open: boolean; @@ -99,18 +100,21 @@ export default function CreateShareLinkForm({ orgQueries.resources({ orgId: org?.org.orgId ?? "" }) ); - const resources = useMemo( - () => - allResources - .filter((r) => r.http) - .map((r) => ({ - resourceId: r.resourceId, - name: r.name, - niceId: r.niceId, - resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` - })), - [allResources] - ); + const [selectedResource, setSelectedResource] = + useState(null); + + // const resources = useMemo( + // () => + // allResources + // .filter((r) => r.http) + // .map((r) => ({ + // resourceId: r.resourceId, + // name: r.name, + // niceId: r.niceId, + // resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` + // })), + // [allResources] + // ); const formSchema = z.object({ resourceId: z.number({ message: t("shareErrorSelectResource") }), @@ -199,15 +203,11 @@ export default function CreateShareLinkForm({ setAccessToken(token.accessToken); setAccessTokenId(token.accessTokenId); - const resource = resources.find( - (r) => r.resourceId === values.resourceId - ); - onCreated?.({ accessTokenId: token.accessTokenId, resourceId: token.resourceId, resourceName: values.resourceName, - resourceNiceId: resource ? resource.niceId : "", + resourceNiceId: selectedResource ? selectedResource.niceId : "", title: token.title, createdAt: token.createdAt, expiresAt: token.expiresAt @@ -217,10 +217,10 @@ export default function CreateShareLinkForm({ setLoading(false); } - function getSelectedResourceName(id: number) { - const resource = resources.find((r) => r.resourceId === id); - return `${resource?.name}`; - } + // function getSelectedResourceName(id: number) { + // const resource = resources.find((r) => r.resourceId === id); + // return `${resource?.name}`; + // } return ( <> @@ -241,7 +241,7 @@ export default function CreateShareLinkForm({ -
+
{!link && (
- {field.value - ? getSelectedResourceName( - field.value - ) + {selectedResource?.name + ? selectedResource.name : t( "resourceSelect" )} @@ -281,7 +279,7 @@ export default function CreateShareLinkForm({ - + {/* - + */} + + { + form.setValue( + "resourceId", + r.resourceId + ); + form.setValue( + "resourceName", + r.name + ); + form.setValue( + "resourceUrl", + `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` + ); + setSelectedResource( + r + ); + }} + /> diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx new file mode 100644 index 000000000..a940894b0 --- /dev/null +++ b/src/components/resource-selector.tsx @@ -0,0 +1,81 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import { useDebounce } from "use-debounce"; + +export type SelectedResource = Pick< + ListResourcesResponse["resources"][number], + "name" | "resourceId" | "fullDomain" | "niceId" | "ssl" +>; + +export type ResourceSelectorProps = { + orgId: string; + selectedResource?: SelectedResource | null; + onSelectResource: (resource: SelectedResource) => void; +}; + +export function ResourceSelector({ + orgId, + selectedResource, + onSelectResource +}: ResourceSelectorProps) { + const t = useTranslations(); + const [resourceSearchQuery, setResourceSearchQuery] = useState(""); + + const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150); + + const { data: resources = [] } = useQuery( + orgQueries.resources({ + orgId: orgId, + query: debouncedSearchQuery, + perPage: 10 + }) + ); + + return ( + + + + {t("resourcesNotFound")} + + {resources.map((r) => ( + { + onSelectResource(r); + }} + > + + {`${r.name}`} + + ))} + + + + ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f6de28fc0..dfa706a88 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -191,14 +191,26 @@ export const orgQueries = { } }), - resources: ({ orgId }: { orgId: string }) => + resources: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES"] as const, + queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - pageSize: "10000" + pageSize: perPage.toString() }); + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/resources?${sp.toString()}`, { signal }); From ce58e71c440f40a8ea26d6d4d7546c19f16b658a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 20 Mar 2026 03:59:10 +0100 Subject: [PATCH 014/122] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20make=20machine=20s?= =?UTF-8?q?elector=20a=20multi-combobox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 5 + src/components/CreateShareLinkForm.tsx | 59 ---------- src/components/InternalResourceForm.tsx | 140 ++++++++++++++---------- src/components/MachineClientsTable.tsx | 2 +- src/components/UserDevicesTable.tsx | 2 +- src/components/machine-selector.tsx | 108 ++++++++++++++++++ src/components/resource-selector.tsx | 19 +++- src/lib/queries.ts | 16 ++- 8 files changed, 231 insertions(+), 120 deletions(-) create mode 100644 src/components/machine-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3d5352293..06be10e65 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -148,6 +148,11 @@ "createLink": "Create Link", "resourcesNotFound": "No resources found", "resourceSearch": "Search resources", + "machineSearch": "Search machines", + "machinesSearch": "Search machine clients...", + "machineNotFound": "No machines found", + "userDeviceSearch": "Search user devices", + "userDevicesSearch": "Search user devices...", "openMenu": "Open menu", "resource": "Resource", "title": "Title", diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index fef1fb0e1..d0e26a1c2 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -217,11 +217,6 @@ export default function CreateShareLinkForm({ setLoading(false); } - // function getSelectedResourceName(id: number) { - // const resource = resources.find((r) => r.resourceId === id); - // return `${resource?.name}`; - // } - return ( <> - {/* - - - - {t( - "resourcesNotFound" - )} - - - {resources.map( - ( - r - ) => ( - { - form.setValue( - "resourceId", - r.resourceId - ); - form.setValue( - "resourceName", - r.name - ); - form.setValue( - "resourceUrl", - r.resourceUrl - ); - }} - > - - {`${r.name}`} - - ) - )} - - - */} - ; @@ -252,7 +261,7 @@ export function InternalResourceForm({ const rolesQuery = useQuery(orgQueries.roles({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.clients({ orgId })); + const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -310,12 +319,9 @@ export function InternalResourceForm({ })); } if (clientsData) { - existingClients = ( - clientsData as { clientId: number; name: string }[] - ).map((c) => ({ - id: c.clientId.toString(), - text: c.name - })); + existingClients = [ + ...(clientsData as { clientId: number; name: string }[]) + ]; } } @@ -592,8 +598,7 @@ export function InternalResourceForm({
-
+
-
+