From e05114233445cf5af834b8a0195499ff00e82e8e Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 22 Dec 2025 17:44:56 +0100 Subject: [PATCH 001/221] 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/221] 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 27d20eb1bcda61ccb6662a67bf74709385b1dcc2 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Mon, 9 Mar 2026 11:17:36 +0300 Subject: [PATCH 003/221] refactor(install): improve resource cleanup and remove unused funcs Signed-off-by: Rodney Osodo --- install/config.go | 14 ++++++------- install/input.go | 27 ------------------------ install/main.go | 53 +++++++++++++++++------------------------------ 3 files changed, 26 insertions(+), 68 deletions(-) diff --git a/install/config.go b/install/config.go index 548e2ab33..d03415e30 100644 --- a/install/config.go +++ b/install/config.go @@ -99,11 +99,6 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) { return values, nil } -// findPattern finds the start of a pattern in a string -func findPattern(s, pattern string) int { - return bytes.Index([]byte(s), []byte(pattern)) -} - func copyDockerService(sourceFile, destFile, serviceName string) error { // Read source file sourceData, err := os.ReadFile(sourceFile) @@ -187,7 +182,7 @@ func backupConfig() error { return nil } -func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { +func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) { buffer := new(bytes.Buffer) encoder := yaml.NewEncoder(buffer) encoder.SetIndent(indent) @@ -196,7 +191,12 @@ func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { return nil, err } - defer encoder.Close() + defer func() { + if cerr := encoder.Close(); cerr != nil && err == nil { + err = cerr + } + }() + return buffer.Bytes(), nil } diff --git a/install/input.go b/install/input.go index 8b444ecb9..93739e0d0 100644 --- a/install/input.go +++ b/install/input.go @@ -85,33 +85,6 @@ func readString(prompt string, defaultValue string) string { return value } -func readStringNoDefault(prompt string) string { - var value string - - for { - input := huh.NewInput(). - Title(prompt). - Value(&value). - Validate(func(s string) error { - if s == "" { - return fmt.Errorf("this field is required") - } - return nil - }) - - err := runField(input) - handleAbort(err) - - if value != "" { - // Print the answer so it remains visible in terminal history - if !isAccessibleMode() { - fmt.Printf("%s: %s\n", prompt, value) - } - return value - } - } -} - func readPassword(prompt string) string { var value string diff --git a/install/main.go b/install/main.go index 9de332b60..2bb73b1dd 100644 --- a/install/main.go +++ b/install/main.go @@ -8,7 +8,6 @@ import ( "io" "io/fs" "net" - "net/http" "net/url" "os" "os/exec" @@ -430,9 +429,9 @@ func createConfigFiles(config Config) error { } // Walk through all embedded files - err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err + err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) { + if walkErr != nil { + return walkErr } // Skip the root fs directory itself @@ -483,7 +482,11 @@ func createConfigFiles(config Config) error { if err != nil { return fmt.Errorf("failed to create %s: %v", path, err) } - defer outFile.Close() + defer func() { + if cerr := outFile.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Execute template if err := tmpl.Execute(outFile, config); err != nil { @@ -499,18 +502,26 @@ func createConfigFiles(config Config) error { return nil } -func copyFile(src, dst string) error { +func copyFile(src, dst string) (err error) { source, err := os.Open(src) if err != nil { return err } - defer source.Close() + defer func() { + if cerr := source.Close(); cerr != nil && err == nil { + err = cerr + } + }() destination, err := os.Create(dst) if err != nil { return err } - defer destination.Close() + defer func() { + if cerr := destination.Close(); cerr != nil && err == nil { + err = cerr + } + }() _, err = io.Copy(destination, source) return err @@ -622,32 +633,6 @@ func generateRandomSecretKey() string { return base64.StdEncoding.EncodeToString(secret) } -func getPublicIP() string { - client := &http.Client{ - Timeout: 10 * time.Second, - } - - resp, err := client.Get("https://ifconfig.io/ip") - if err != nil { - return "" - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "" - } - - ip := strings.TrimSpace(string(body)) - - // Validate that it's a valid IP address - if net.ParseIP(ip) != nil { - return ip - } - - return "" -} - // Run external commands with stdio/stderr attached. func run(name string, args ...string) error { cmd := exec.Command(name, args...) From ae39084a75564738efa059f40152f6725d83fc81 Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Tue, 10 Mar 2026 12:21:06 +0530 Subject: [PATCH 004/221] fix: persist user locale preference to database (#1547) --- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/external.ts | 5 +++ server/routers/user/getUser.ts | 3 +- server/routers/user/index.ts | 1 + server/routers/user/updateUserLocale.ts | 57 +++++++++++++++++++++++++ src/components/LocaleSwitcherSelect.tsx | 7 +++ src/services/locale.ts | 26 ++++++++++- 8 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 server/routers/user/updateUserLocale.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 504ea761f..7171fa36b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -285,7 +285,8 @@ export const users = pgTable("user", { termsVersion: varchar("termsVersion"), marketingEmailConsent: boolean("marketingEmailConsent").default(false), serverAdmin: boolean("serverAdmin").notNull().default(false), - lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) + lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }), + locale: varchar("locale") }); export const newts = pgTable("newt", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 2bd11ee0c..47c7f04ae 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -320,7 +320,8 @@ export const users = sqliteTable("user", { serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false), - lastPasswordChange: integer("lastPasswordChange") + lastPasswordChange: integer("lastPasswordChange"), + locale: text("locale") }); export const securityKeys = sqliteTable("webauthnCredentials", { diff --git a/server/routers/external.ts b/server/routers/external.ts index 45ab58bba..334678944 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -793,6 +793,11 @@ unauthenticated.get( // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); +unauthenticated.post( + "/user/locale", + verifySessionMiddleware, + user.updateUserLocale +); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index e33daab60..c2e43e16e 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -20,7 +20,8 @@ async function queryUser(userId: string) { emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + locale: users.locale }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index b6fb05d92..6aa2bf792 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -16,4 +16,5 @@ export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; export * from "./updateOrgUser"; +export * from "./updateUserLocale"; export * from "./myDevice"; diff --git a/server/routers/user/updateUserLocale.ts b/server/routers/user/updateUserLocale.ts new file mode 100644 index 000000000..6c28ce067 --- /dev/null +++ b/server/routers/user/updateUserLocale.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z.strictObject({ + locale: z.string().min(2).max(10) +}); + +export async function updateUserLocale( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not found") + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { locale } = parsedBody.data; + + await db.update(users).set({ locale }).where(eq(users.userId, userId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "User locale updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 201aeb18a..e647f7dd1 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -12,6 +12,8 @@ import clsx from "clsx"; import { useTransition } from "react"; import { Locale } from "@/i18n/config"; import { setUserLocale } from "@/services/locale"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type Props = { defaultValue: string; @@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({ label }: Props) { const [isPending, startTransition] = useTransition(); + const api = createApiClient(useEnvContext()); function onChange(value: string) { const locale = value as Locale; startTransition(() => { setUserLocale(locale); }); + // Persist locale to the database (fire-and-forget) + api.post("/user/locale", { locale }).catch(() => { + // Silently ignore errors — cookie is already set as fallback + }); } const selected = items.find((item) => item.value === defaultValue); diff --git a/src/services/locale.ts b/src/services/locale.ts index 034f4c988..3bdf688bf 100644 --- a/src/services/locale.ts +++ b/src/services/locale.ts @@ -2,10 +2,13 @@ import { cookies, headers } from "next/headers"; import { Locale, defaultLocale, locales } from "@/i18n/config"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; // In this example the locale is read from a cookie. You could alternatively // also read it from a database, backend service, or any other source. const COOKIE_NAME = "NEXT_LOCALE"; +const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds export async function getUserLocale(): Promise { const cookieLocale = (await cookies()).get(COOKIE_NAME)?.value; @@ -14,6 +17,23 @@ export async function getUserLocale(): Promise { return cookieLocale as Locale; } + // No cookie found — try to restore from user's saved locale in DB + try { + const res = await internal.get("/user", await authCookieHeader()); + const userLocale = res.data?.data?.locale; + if (userLocale && locales.includes(userLocale as Locale)) { + // Set the cookie so subsequent requests don't need the API call + (await cookies()).set(COOKIE_NAME, userLocale, { + maxAge: COOKIE_MAX_AGE, + path: "/", + sameSite: "lax" + }); + return userLocale as Locale; + } + } catch { + // User not logged in or API unavailable — fall through + } + const headerList = await headers(); const acceptLang = headerList.get("accept-language"); @@ -33,5 +53,9 @@ export async function getUserLocale(): Promise { } export async function setUserLocale(locale: Locale) { - (await cookies()).set(COOKIE_NAME, locale); + (await cookies()).set(COOKIE_NAME, locale, { + maxAge: COOKIE_MAX_AGE, + path: "/", + sameSite: "lax" + }); } From 5455d1c118f65ec9e9bfc869c0e5748166085e20 Mon Sep 17 00:00:00 2001 From: Shreyas Papinwar Date: Tue, 10 Mar 2026 12:33:05 +0530 Subject: [PATCH 005/221] fix: add locale to myDevice user query to fix type error --- server/routers/user/myDevice.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 144108e11..3d656fc7a 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -63,7 +63,8 @@ export async function myDevice( emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, - idpId: users.idpId + idpId: users.idpId, + locale: users.locale }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) From 47a99e35ee2ecd82fb1ab2da655d36fd3301715a Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 13 Mar 2026 11:38:00 +0000 Subject: [PATCH 006/221] 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 863eb8efe999d46dd3c0322b1e6157f141e36cde Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 15 Mar 2026 19:37:15 +1030 Subject: [PATCH 007/221] Update docker-compose.yml --- install/config/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index c0206e5bf..fd8fdd9a3 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -38,6 +38,7 @@ services: - 51820:51820/udp - 21820:21820/udp - 443:443 + - 443:443/udp - 80:80 {{end}} traefik: From ad3fe2fa768a3a38a90b89af645f10b08b632394 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 15 Mar 2026 19:39:36 +1030 Subject: [PATCH 008/221] Update traefik_config.yml --- install/config/traefik/traefik_config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install/config/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml index 0709b4611..45f5ebb07 100644 --- a/install/config/traefik/traefik_config.yml +++ b/install/config/traefik/traefik_config.yml @@ -40,6 +40,8 @@ entryPoints: transport: respondingTimeouts: readTimeout: "30m" + http3: + advertisedPort: 443 http: tls: certResolver: "letsencrypt" From 1d5dfd6db283a2297eae3ce6ee889891826be32d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:27:43 +0000 Subject: [PATCH 009/221] Bump github.com/charmbracelet/huh from 0.8.0 to 1.0.0 in /install Bumps [github.com/charmbracelet/huh](https://github.com/charmbracelet/huh) from 0.8.0 to 1.0.0. - [Release notes](https://github.com/charmbracelet/huh/releases) - [Commits](https://github.com/charmbracelet/huh/compare/v0.8.0...v1.0.0) --- updated-dependencies: - dependency-name: github.com/charmbracelet/huh dependency-version: 1.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- install/go.mod | 2 +- install/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/install/go.mod b/install/go.mod index da73eec0f..005a079df 100644 --- a/install/go.mod +++ b/install/go.mod @@ -3,7 +3,7 @@ module installer go 1.25.0 require ( - github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/install/go.sum b/install/go.sum index e0b2a6c5e..b67ae57e5 100644 --- a/install/go.sum +++ b/install/go.sum @@ -14,8 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= From 871f14ef3a9273b5b078f4d35f70c719bea2923c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:17:41 +0000 Subject: [PATCH 010/221] Bump flatted from 3.3.3 to 3.4.2 Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..0056b0c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13229,9 +13229,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, From 1f01108b62f8416887b47c492799326c34015e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Mar 2026 20:33:58 -0700 Subject: [PATCH 011/221] Batch set bandwidth --- server/routers/gerbil/receiveBandwidth.ts | 146 +++++++++++++--------- 1 file changed, 84 insertions(+), 62 deletions(-) diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index b73ce986d..042c844aa 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -1,6 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { eq, sql } from "drizzle-orm"; -import { sites } from "@server/db"; +import { sql } from "drizzle-orm"; import { db } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; @@ -31,7 +30,10 @@ const MAX_RETRIES = 3; const BASE_DELAY_MS = 50; // How often to flush accumulated bandwidth data to the database -const FLUSH_INTERVAL_MS = 30_000; // 30 seconds +const FLUSH_INTERVAL_MS = 300_000; // 300 seconds + +// Maximum number of sites to include in a single batch UPDATE statement +const BATCH_CHUNK_SIZE = 250; // In-memory accumulator: publicKey -> AccumulatorEntry let accumulator = new Map(); @@ -75,13 +77,33 @@ async function withDeadlockRetry( } } +/** + * Execute a raw SQL query that returns rows, in a way that works across both + * the PostgreSQL driver (which exposes `execute`) and the SQLite driver (which + * exposes `all`). Drizzle's typed query builder doesn't support bulk + * UPDATE … FROM (VALUES …) natively, so we drop to raw SQL here. + */ +async function dbQueryRows>( + query: Parameters<(typeof sql)["join"]>[0][number] +): Promise { + const anyDb = db as any; + if (typeof anyDb.execute === "function") { + // PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array + const result = await anyDb.execute(query); + return (Array.isArray(result) ? result : (result.rows ?? [])) as T[]; + } + // SQLite (better-sqlite3 via Drizzle) — returns an array directly + return (await anyDb.all(query)) as T[]; +} + /** * Flush all accumulated site bandwidth data to the database. * * Swaps out the accumulator before writing so that any bandwidth messages * received during the flush are captured in the new accumulator rather than - * being lost or causing contention. Entries that fail to write are re-queued - * back into the accumulator so they will be retried on the next flush. + * being lost or causing contention. Sites are updated in chunks via a single + * batch UPDATE per chunk. Failed chunks are discarded — exact per-flush + * accuracy is not critical and re-queuing is not worth the added complexity. * * This function is exported so that the application's graceful-shutdown * cleanup handler can call it before the process exits. @@ -108,76 +130,76 @@ export async function flushSiteBandwidthToDb(): Promise { `Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database` ); - // Aggregate billing usage by org, collected during the DB update loop. + // Build a lookup so post-processing can reach each entry by publicKey. + const snapshotMap = new Map(sortedEntries); + + // Aggregate billing usage by org across all chunks. const orgUsageMap = new Map(); - for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) { + // Process in chunks so individual queries stay at a reasonable size. + for (let i = 0; i < sortedEntries.length; i += BATCH_CHUNK_SIZE) { + const chunk = sortedEntries.slice(i, i + BATCH_CHUNK_SIZE); + const chunkEnd = i + chunk.length - 1; + + // Build a parameterised VALUES list: (pubKey, bytesIn, bytesOut), ... + // Both PostgreSQL and SQLite (≥ 3.33.0, which better-sqlite3 bundles) + // support UPDATE … FROM (VALUES …), letting us update the whole chunk + // in a single query instead of N individual round-trips. + const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => + sql`(${publicKey}, ${bytesIn}, ${bytesOut})` + ); + const valuesClause = sql.join(valuesList, sql`, `); + + let rows: { orgId: string; pubKey: string }[] = []; + try { - const updatedSite = await withDeadlockRetry(async () => { - const [result] = await db - .update(sites) - .set({ - megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`, - megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`, - lastBandwidthUpdate: currentTime, - }) - .where(eq(sites.pubKey, publicKey)) - .returning({ - orgId: sites.orgId, - siteId: sites.siteId - }); - return result; - }, `flush bandwidth for site ${publicKey}`); - - if (updatedSite) { - if (exitNodeId) { - const notAllowed = await checkExitNodeOrg( - exitNodeId, - updatedSite.orgId - ); - if (notAllowed) { - logger.warn( - `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` - ); - // Skip usage tracking for this site but continue - // processing the rest. - continue; - } - } - - if (calcUsage) { - const totalBandwidth = bytesIn + bytesOut; - const current = orgUsageMap.get(updatedSite.orgId) ?? 0; - orgUsageMap.set(updatedSite.orgId, current + totalBandwidth); - } - } + rows = await withDeadlockRetry(async () => { + return dbQueryRows<{ orgId: string; pubKey: string }>(sql` + UPDATE sites + SET + "bytesOut" = COALESCE("bytesOut", 0) + v.bytes_in, + "bytesIn" = COALESCE("bytesIn", 0) + v.bytes_out, + "lastBandwidthUpdate" = ${currentTime} + FROM (VALUES ${valuesClause}) AS v(pub_key, bytes_in, bytes_out) + WHERE sites."pubKey" = v.pub_key + RETURNING sites."orgId" AS "orgId", sites."pubKey" AS "pubKey" + `); + }, `flush bandwidth chunk [${i}–${chunkEnd}]`); } catch (error) { logger.error( - `Failed to flush bandwidth for site ${publicKey}:`, + `Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`, error ); + // Discard the chunk — exact per-flush accuracy is not critical. + continue; + } - // Re-queue the failed entry so it is retried on the next flush - // rather than silently dropped. - const existing = accumulator.get(publicKey); - if (existing) { - existing.bytesIn += bytesIn; - existing.bytesOut += bytesOut; - } else { - accumulator.set(publicKey, { - bytesIn, - bytesOut, - exitNodeId, - calcUsage - }); + // Collect billing usage from the returned rows. + for (const { orgId, pubKey } of rows) { + const entry = snapshotMap.get(pubKey); + if (!entry) continue; + + const { bytesIn, bytesOut, exitNodeId, calcUsage } = entry; + + if (exitNodeId) { + const notAllowed = await checkExitNodeOrg(exitNodeId, orgId); + if (notAllowed) { + logger.warn( + `Exit node ${exitNodeId} is not allowed for org ${orgId}` + ); + continue; + } + } + + if (calcUsage) { + const current = orgUsageMap.get(orgId) ?? 0; + orgUsageMap.set(orgId, current + bytesIn + bytesOut); } } } - // Process billing usage updates outside the site-update loop to keep - // lock scope small and concerns separated. + // Process billing usage updates after all chunks are written. if (orgUsageMap.size > 0) { - // Sort org IDs for consistent lock ordering. const sortedOrgIds = [...orgUsageMap.keys()].sort(); for (const orgId of sortedOrgIds) { From 6b3a6fa380f9c70e0a9f858c6b494b64c15aded0 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Mar 2026 22:11:56 -0700 Subject: [PATCH 012/221] Add typecasts --- server/routers/gerbil/receiveBandwidth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 042c844aa..a1159bc22 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -146,7 +146,7 @@ export async function flushSiteBandwidthToDb(): Promise { // support UPDATE … FROM (VALUES …), letting us update the whole chunk // in a single query instead of N individual round-trips. const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => - sql`(${publicKey}, ${bytesIn}, ${bytesOut})` + sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ); const valuesClause = sql.join(valuesList, sql`, `); From 5b9efc3c5f453b48b7261961d6c8fdcccc8999d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:51:09 +0000 Subject: [PATCH 013/221] Bump picomatch Bumps and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together. Updates `picomatch` from 2.3.1 to 2.3.2 - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) Updates `picomatch` from 4.0.3 to 4.0.4 - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect - dependency-name: picomatch dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..185ed7e85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9764,9 +9764,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -15177,9 +15177,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -16468,9 +16468,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -18891,9 +18891,9 @@ } }, "node_modules/tsc-alias/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { From 914e95e47feec90169ee837008cacc9df63009e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:36:44 +0000 Subject: [PATCH 014/221] Bump yaml from 2.8.2 to 2.8.3 Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3) --- updated-dependencies: - dependency-name: yaml dependency-version: 2.8.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..2a7fbd625 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,7 @@ "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", "ws": "8.19.0", - "yaml": "2.8.2", + "yaml": "2.8.3", "yargs": "18.0.0", "zod": "4.3.6", "zod-validation-error": "5.0.0" @@ -19679,9 +19679,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 05ae3b49f..988e12ed7 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", "ws": "8.19.0", - "yaml": "2.8.2", + "yaml": "2.8.3", "yargs": "18.0.0", "zod": "4.3.6", "zod-validation-error": "5.0.0" From 5ddcfeb50661747322110e8a38cf2295b9090c4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:56:52 +0000 Subject: [PATCH 015/221] Bump nodemailer from 8.0.1 to 8.0.4 Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.1 to 8.0.4. - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v8.0.4) --- updated-dependencies: - dependency-name: nodemailer dependency-version: 8.0.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..952061bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,7 @@ "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.1", + "nodemailer": "8.0.4", "oslo": "1.2.1", "pg": "8.20.0", "posthog-node": "5.28.0", @@ -15636,9 +15636,10 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } diff --git a/package.json b/package.json index 05ae3b49f..4e942701e 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", - "nodemailer": "8.0.1", + "nodemailer": "8.0.4", "oslo": "1.2.1", "pg": "8.20.0", "posthog-node": "5.28.0", From 06f840a6800db0cf8d36c9d2d58fb732ee403560 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:43:09 +0000 Subject: [PATCH 016/221] Bump brace-expansion Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 5.0.4 to 5.0.5 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.5) Updates `brace-expansion` from 1.1.12 to 1.1.13 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.5) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.5 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 1.1.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..c034e8439 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10265,9 +10265,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -12459,9 +12459,9 @@ "license": "MIT" }, "node_modules/eslint-config-next/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { From 8e160902af7b5fc16f0fef4faa0c6a6e8a425472 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:37:06 +0000 Subject: [PATCH 017/221] Bump path-to-regexp from 8.3.0 to 8.4.0 Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 8.3.0 to 8.4.0. - [Release notes](https://github.com/pillarjs/path-to-regexp/releases) - [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md) - [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.0) --- updated-dependencies: - dependency-name: path-to-regexp dependency-version: 8.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..deefd24a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16337,9 +16337,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "license": "MIT", "funding": { "type": "opencollective", From bdc45887f9b1229309009cc506aa6109be682d2e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 29 Mar 2026 12:08:29 -0700 Subject: [PATCH 018/221] Add chainId to dedup messages (#2737) * ChainId send through on sensitive messages --- server/routers/newt/handleGetConfigMessage.ts | 21 +++---------------- .../newt/handleNewtPingRequestMessage.ts | 5 +++-- .../routers/newt/handleNewtRegisterMessage.ts | 5 +++-- .../routers/olm/handleOlmRegisterMessage.ts | 6 ++++-- 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index d536e9828..6df0a8f82 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -8,13 +8,6 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; -const inputSchema = z.object({ - publicKey: z.string(), - port: z.int().positive() -}); - -type Input = z.infer; - export const handleGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; @@ -33,16 +26,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { return; } - const parsed = inputSchema.safeParse(message.data); - if (!parsed.success) { - logger.error( - "handleGetConfigMessage: Invalid input: " + - fromError(parsed.error).toString() - ); - return; - } - - const { publicKey, port } = message.data as Input; + const { publicKey, port, chainId } = message.data; const siteId = newt.siteId; // Get the current site data @@ -133,7 +117,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { data: { ipAddress: site.address, peers, - targets + targets, + chainId: chainId } }, options: { diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts index b75ddd5e4..8f6df4bec 100644 --- a/server/routers/newt/handleNewtPingRequestMessage.ts +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -33,7 +33,7 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { return; } - const { noCloud } = message.data; + const { noCloud, chainId } = message.data; const exitNodesList = await listExitNodes( site.orgId, @@ -98,7 +98,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => { message: { type: "newt/ping/exitNodes", data: { - exitNodes: filteredExitNodes + exitNodes: filteredExitNodes, + chainId: chainId } }, broadcast: false, // Send to all clients diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 90034cfbf..fce42caa3 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -43,7 +43,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey, pingResults, newtVersion, backwardsCompatible } = + const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -211,7 +211,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { udp: udpTargets, tcp: tcpTargets }, - healthCheckTargets: validHealthCheckTargets + healthCheckTargets: validHealthCheckTargets, + chainId: chainId } }, options: { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 5439245c4..26dbff1bd 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -41,7 +41,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { orgId, userToken, fingerprint, - postures + postures, + chainId } = message.data; if (!olm.clientId) { @@ -293,7 +294,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { data: { sites: siteConfigurations, tunnelIP: client.subnet, - utilitySubnet: org.utilitySubnet + utilitySubnet: org.utilitySubnet, + chainId: chainId } }, options: { From 77cef554bef6f0aa6097530ee2ffbee2b5c98ed7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 14:20:47 -0700 Subject: [PATCH 019/221] Provisioning room basics done --- messages/en-US.json | 5 + server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/routers/newt/registerNewt.ts | 3 +- server/routers/site/createSite.ts | 9 +- server/routers/site/listSites.ts | 17 +- server/routers/site/updateSite.ts | 3 +- .../settings/provisioning/keys/page.tsx | 56 +++ .../[orgId]/settings/provisioning/layout.tsx | 38 ++ .../[orgId]/settings/provisioning/page.tsx | 56 +-- .../settings/provisioning/pending/page.tsx | 82 ++++ src/app/[orgId]/settings/sites/page.tsx | 1 + src/components/PendingSitesTable.tsx | 440 ++++++++++++++++++ 13 files changed, 654 insertions(+), 62 deletions(-) create mode 100644 src/app/[orgId]/settings/provisioning/keys/page.tsx create mode 100644 src/app/[orgId]/settings/provisioning/layout.tsx create mode 100644 src/app/[orgId]/settings/provisioning/pending/page.tsx create mode 100644 src/components/PendingSitesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 7a3fde1d4..ad64cb5e2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -331,6 +331,11 @@ "provisioningKeysTitle": "Provisioning Key", "provisioningKeysManage": "Manage Provisioning Keys", "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningManage": "Provisioning", + "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", + "pendingSites": "Pending Sites", + "siteApproveSuccess": "Site approved successfully", + "siteApproveError": "Error approving site", "provisioningKeys": "Provisioning Keys", "searchProvisioningKeys": "Search provisioning keys...", "provisioningKeysAdd": "Generate Provisioning Key", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index bb05ca358..a64aad2ef 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -100,7 +100,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + status: varchar("status").$type<"pending" | "accepted">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5d7c01377..52969d183 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -110,7 +110,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + status: text("status").$type<"pending" | "accepted">() }); export const resources = sqliteTable("resources", { diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 427ac173f..4923fb88e 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -196,7 +196,8 @@ export async function registerNewt( name: niceId, niceId, type: "newt", - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "pending" }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 4edebb080..bf62e93cd 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -298,7 +298,8 @@ export async function createSite( niceId, address: updatedAddress || null, type, - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "accepted" }) .returning(); } else if (type == "wireguard") { @@ -355,7 +356,8 @@ export async function createSite( niceId, subnet, type, - pubKey: pubKey || null + pubKey: pubKey || null, + status: "accepted" }) .returning(); } else if (type == "local") { @@ -370,7 +372,8 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32" + subnet: "0.0.0.0/32", + status: "accepted" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a244c650c..1d7e99042 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -135,6 +135,15 @@ const listSitesSchema = z.object({ .openapi({ type: "boolean", description: "Filter by online status" + }), + status: z + .enum(["pending", "accepted"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "accepted"], + description: "Filter by site status" }) }); @@ -156,7 +165,8 @@ function querySitesBase() { exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, - remoteExitNodeId: remoteExitNodes.remoteExitNodeId + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + status: sites.status }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) @@ -245,7 +255,7 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const { pageSize, page, query, sort_by, order, online } = + const { pageSize, page, query, sort_by, order, online, status } = parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); @@ -273,6 +283,9 @@ export async function listSites( if (typeof online !== "undefined") { conditions.push(eq(sites.online, online)); } + if (typeof status !== "undefined") { + conditions.push(eq(sites.status, status)); + } const baseQuery = querySitesBase().where(and(...conditions)); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index ca0f76783..244adf7b8 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -19,7 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + status: z.enum(["pending", "accepted"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx new file mode 100644 index 000000000..1cfbf5b05 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -0,0 +1,56 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import SiteProvisioningKeysTable, { + SiteProvisioningKeyRow +} from "../../../../../components/SiteProvisioningKeysTable"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { getTranslations } from "next-intl/server"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; + +type ProvisioningKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningKeysPage( + props: ProvisioningKeysPageProps +) { + const params = await props.params; + const t = await getTranslations(); + + let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = + []; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/site-provisioning-keys`, + await authCookieHeader() + ); + siteProvisioningKeys = res.data.data.siteProvisioningKeys; + } catch (e) {} + + const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ + name: k.name, + id: k.siteProvisioningKeyId, + key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, + createdAt: k.createdAt, + lastUsed: k.lastUsed, + maxBatchSize: k.maxBatchSize, + numUsed: k.numUsed, + validUntil: k.validUntil + })); + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/layout.tsx b/src/app/[orgId]/settings/provisioning/layout.tsx new file mode 100644 index 000000000..bd2da7812 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +interface ProvisioningLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function ProvisioningLayout({ + children, + params +}: ProvisioningLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("provisioningKeys"), + href: `/${orgId}/settings/provisioning/keys` + }, + { + title: t("pendingSites"), + href: `/${orgId}/settings/provisioning/pending` + } + ]; + + return ( + <> + + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index e8b53104f..51db66c2d 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,60 +1,10 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import SiteProvisioningKeysTable, { - SiteProvisioningKeyRow -} from "../../../../components/SiteProvisioningKeysTable"; -import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; -import { getTranslations } from "next-intl/server"; -import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { redirect } from "next/navigation"; type ProvisioningPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - export default async function ProvisioningPage(props: ProvisioningPageProps) { const params = await props.params; - const t = await getTranslations(); - - let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = - []; - try { - const res = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/site-provisioning-keys`, - await authCookieHeader() - ); - siteProvisioningKeys = res.data.data.siteProvisioningKeys; - } catch (e) {} - - const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ - name: k.name, - id: k.siteProvisioningKeyId, - key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, - createdAt: k.createdAt, - lastUsed: k.lastUsed, - maxBatchSize: k.maxBatchSize, - numUsed: k.numUsed, - validUntil: k.validUntil - })); - - return ( - <> - - - - - - - ); -} + redirect(`/${params.orgId}/settings/provisioning/keys`); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx new file mode 100644 index 000000000..78178cb14 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -0,0 +1,82 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListSitesResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { SiteRow } from "@app/components/SitesTable"; +import PendingSitesTable from "@app/components/PendingSitesTable"; +import { getTranslations } from "next-intl/server"; + +type PendingSitesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +export default async function PendingSitesPage(props: PendingSitesPageProps) { + const params = await props.params; + + const incomingSearchParams = new URLSearchParams(await props.searchParams); + incomingSearchParams.set("status", "pending"); + + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${params.orgId}/sites?${incomingSearchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; + } catch (e) {} + + const t = await getTranslations(); + + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "-"; + } + if (mb >= 1024 * 1024) { + return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) }); + } else if (mb >= 1024) { + return t("gigabytes", { count: (mb / 1024).toFixed(2) }); + } else { + return t("megabytes", { count: mb.toFixed(2) }); + } + } + + const siteRows: SiteRow[] = sites.map((site) => ({ + name: site.name, + id: site.siteId, + nice: site.niceId.toString(), + address: site.address?.split("/")[0], + mbIn: formatSize(site.megabytesIn || 0, site.type), + mbOut: formatSize(site.megabytesOut || 0, site.type), + orgId: params.orgId, + type: site.type as any, + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, + exitNodeName: site.exitNodeName || undefined, + exitNodeEndpoint: site.exitNodeEndpoint || undefined, + remoteExitNodeId: (site as any).remoteExitNodeId || undefined + })); + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 161c757f6..5839ba2be 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -18,6 +18,7 @@ export default async function SitesPage(props: SitesPageProps) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "accepted"); let sites: ListSitesResponse["sites"] = []; let pagination: ListSitesResponse["pagination"] = { diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx new file mode 100644 index 000000000..f9126a091 --- /dev/null +++ b/src/components/PendingSitesTable.tsx @@ -0,0 +1,440 @@ +"use client"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { build } from "@server/build"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpRight, + Check, + ChevronsUpDownIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; +import { SiteRow } from "./SitesTable"; + +type PendingSitesTableProps = { + sites: SiteRow[]; + pagination: PaginationState; + orgId: string; + rowCount: number; +}; + +export default function PendingSitesTable({ + sites, + orgId, + pagination, + rowCount +}: PendingSitesTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [isRefreshing, startTransition] = useTransition(); + const [approvingIds, setApprovingIds] = useState>(new Set()); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); + } + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } + + function refreshData() { + startTransition(async () => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } + + async function approveSite(siteId: number) { + setApprovingIds((prev) => new Set(prev).add(siteId)); + try { + await api.post(`/site/${siteId}`, { status: "accepted" }); + toast({ + title: t("success"), + description: t("siteApproveSuccess"), + variant: "default" + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("siteApproveError"), + description: formatAxiosError(e, t("siteApproveError")) + }); + } finally { + setApprovingIds((prev) => { + const next = new Set(prev); + next.delete(siteId); + return next; + }); + } + } + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + + return ( + + ); + } + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => { + return {t("identifier")}; + }, + cell: ({ row }) => { + return {row.original.nice || "-"}; + } + }, + { + accessorKey: "online", + friendlyName: t("online"), + header: () => { + return ( + + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if ( + originalRow.type == "newt" || + originalRow.type == "wireguard" + ) { + if (originalRow.online) { + return ( + +
+ {t("online")} +
+ ); + } else { + return ( + +
+ {t("offline")} +
+ ); + } + } else { + return -; + } + } + }, + { + accessorKey: "mbIn", + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + } + }, + { + accessorKey: "type", + friendlyName: t("type"), + header: () => { + return {t("type")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + + if (originalRow.type === "newt") { + return ( +
+ +
+ Newt + {originalRow.newtVersion && ( + v{originalRow.newtVersion} + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )} +
+ ); + } + + if (originalRow.type === "wireguard") { + return ( +
+ WireGuard +
+ ); + } + + if (originalRow.type === "local") { + return ( +
+ Local +
+ ); + } + } + }, + { + accessorKey: "exitNode", + friendlyName: t("exitNode"), + header: () => { + return {t("exitNode")}; + }, + cell: ({ row }) => { + const originalRow = row.original; + if (!originalRow.exitNodeName) { + return "-"; + } + + const isCloudNode = + build == "saas" && + originalRow.exitNodeName && + [ + "mercury", + "venus", + "earth", + "mars", + "jupiter", + "saturn", + "uranus", + "neptune" + ].includes(originalRow.exitNodeName.toLowerCase()); + + if (isCloudNode) { + const capitalizedName = + originalRow.exitNodeName.charAt(0).toUpperCase() + + originalRow.exitNodeName.slice(1).toLowerCase(); + return ( + + Pangolin {capitalizedName} + + ); + } + + if (originalRow.remoteExitNodeId) { + return ( + + + + ); + } + + return {originalRow.exitNodeName}; + } + }, + { + accessorKey: "address", + header: () => { + return {t("address")}; + }, + cell: ({ row }: { row: any }) => { + const originalRow = row.original; + return originalRow.address ? ( +
+ {originalRow.address} +
+ ) : ( + "-" + ); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const siteRow = row.original; + const isApproving = approvingIds.has(siteRow.id); + return ( +
+ +
+ ); + } + } + ]; + + function toggleSort(column: string) { + const newSearch = getNextSortOrder(column, searchParams); + + filter({ + searchParams: newSearch + }); + } + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + + ); +} \ No newline at end of file From fcf92d4e2c910efbe24ed650222f218eba6c18d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 16:28:51 -0700 Subject: [PATCH 020/221] Add basic provisioning room v1 and update keys --- messages/en-US.json | 8 ++ server/db/pg/schema/privateSchema.ts | 3 +- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 5 +- server/db/sqlite/schema/schema.ts | 2 +- .../createSiteProvisioningKey.ts | 11 ++- .../listSiteProvisioningKeys.ts | 3 +- .../updateSiteProvisioningKey.ts | 15 ++- server/routers/newt/registerNewt.ts | 5 +- server/routers/site/createSite.ts | 6 +- server/routers/site/listSites.ts | 4 +- server/routers/site/updateSite.ts | 2 +- server/routers/siteProvisioning/types.ts | 3 + .../settings/provisioning/keys/page.tsx | 29 +++++- .../settings/provisioning/pending/page.tsx | 48 ++++++++-- src/app/[orgId]/settings/sites/page.tsx | 2 +- .../CreateSiteProvisioningKeyCredenza.tsx | 93 ++++++++++++------- .../EditSiteProvisioningKeyCredenza.tsx | 45 ++++++++- src/components/PendingSitesTable.tsx | 4 +- 19 files changed, 219 insertions(+), 71 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ad64cb5e2..b53c61ebe 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -365,9 +365,17 @@ "provisioningKeysNeverUsed": "Never", "provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", + "provisioningKeysApproveNewSites": "Approve new sites", + "provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.", "provisioningKeysUpdateError": "Error updating provisioning key", "provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdatedDescription": "Your changes have been saved.", + "provisioningKeysBannerTitle": "Site Provisioning Keys", + "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.", + "provisioningKeysBannerButtonText": "Learn More", + "pendingSitesBannerTitle": "Pending Sites", + "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.", + "pendingSitesBannerButtonText": "Learn More", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index bb1e866c4..c83d420a2 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -391,7 +391,8 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", { lastUsed: varchar("lastUsed", { length: 255 }), maxBatchSize: integer("maxBatchSize"), // null = no limit numUsed: integer("numUsed").notNull().default(0), - validUntil: varchar("validUntil", { length: 255 }) + validUntil: varchar("validUntil", { length: 255 }), + approveNewSites: boolean("approveNewSites").notNull().default(true) }); export const siteProvisioningKeyOrg = pgTable( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a64aad2ef..29e25bbdd 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -101,7 +101,7 @@ export const sites = pgTable("sites", { lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - status: varchar("status").$type<"pending" | "accepted">() + status: varchar("status").$type<"pending" | "approved">() }); export const resources = pgTable("resources", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 5913497b3..287c53884 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -375,7 +375,10 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", { lastUsed: text("lastUsed"), maxBatchSize: integer("maxBatchSize"), // null = no limit numUsed: integer("numUsed").notNull().default(0), - validUntil: text("validUntil") + validUntil: text("validUntil"), + approveNewSites: integer("approveNewSites", { mode: "boolean" }) + .notNull() + .default(true) }); export const siteProvisioningKeyOrg = sqliteTable( diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 52969d183..880fab3fd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -111,7 +111,7 @@ export const sites = sqliteTable("sites", { dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true), - status: text("status").$type<"pending" | "accepted">() + status: text("status").$type<"pending" | "approved">() }); export const resources = sqliteTable("resources", { diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index abed27550..e521eaa22 100644 --- a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -38,7 +38,8 @@ const bodySchema = z z.null(), z.coerce.number().int().positive().max(1_000_000) ]), - validUntil: z.string().max(255).optional() + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional().default(true) }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -82,7 +83,7 @@ export async function createSiteProvisioningKey( } const { orgId } = parsedParams.data; - const { name, maxBatchSize } = parsedBody.data; + const { name, maxBatchSize, approveNewSites } = parsedBody.data; const vuRaw = parsedBody.data.validUntil; const validUntil = vuRaw == null || vuRaw.trim() === "" @@ -106,7 +107,8 @@ export async function createSiteProvisioningKey( lastUsed: null, maxBatchSize, numUsed: 0, - validUntil + validUntil, + approveNewSites }); await trx.insert(siteProvisioningKeyOrg).values({ @@ -127,7 +129,8 @@ export async function createSiteProvisioningKey( lastUsed: null, maxBatchSize, numUsed: 0, - validUntil + validUntil, + approveNewSites }, success: true, error: false, diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 5f7531a2c..dd51179d3 100644 --- a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -57,7 +57,8 @@ function querySiteProvisioningKeys(orgId: string) { lastUsed: siteProvisioningKeys.lastUsed, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites }) .from(siteProvisioningKeyOrg) .innerJoin( diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts index 526d8bfb8..2f4dafbdf 100644 --- a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -39,16 +39,18 @@ const bodySchema = z z.coerce.number().int().positive().max(1_000_000) ]) .optional(), - validUntil: z.string().max(255).optional() + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional() }) .superRefine((data, ctx) => { if ( data.maxBatchSize === undefined && - data.validUntil === undefined + data.validUntil === undefined && + data.approveNewSites === undefined ) { ctx.addIssue({ code: "custom", - message: "Provide maxBatchSize and/or validUntil", + message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", path: ["maxBatchSize"] }); } @@ -129,6 +131,7 @@ export async function updateSiteProvisioningKey( const setValues: { maxBatchSize?: number | null; validUntil?: string | null; + approveNewSites?: boolean; } = {}; if (body.maxBatchSize !== undefined) { setValues.maxBatchSize = body.maxBatchSize; @@ -139,6 +142,9 @@ export async function updateSiteProvisioningKey( ? null : new Date(Date.parse(body.validUntil)).toISOString(); } + if (body.approveNewSites !== undefined) { + setValues.approveNewSites = body.approveNewSites; + } await db .update(siteProvisioningKeys) @@ -160,7 +166,8 @@ export async function updateSiteProvisioningKey( lastUsed: siteProvisioningKeys.lastUsed, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites }) .from(siteProvisioningKeys) .where( diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index 4923fb88e..6ad7c30a8 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -82,7 +82,8 @@ export async function registerNewt( orgId: siteProvisioningKeyOrg.orgId, maxBatchSize: siteProvisioningKeys.maxBatchSize, numUsed: siteProvisioningKeys.numUsed, - validUntil: siteProvisioningKeys.validUntil + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites, }) .from(siteProvisioningKeys) .innerJoin( @@ -197,7 +198,7 @@ export async function registerNewt( niceId, type: "newt", dockerSocketEnabled: true, - status: "pending" + status: keyRecord.approveNewSites ? "approved" : "pending", }) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index bf62e93cd..d397b2784 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -299,7 +299,7 @@ export async function createSite( address: updatedAddress || null, type, dockerSocketEnabled: true, - status: "accepted" + status: "approved" }) .returning(); } else if (type == "wireguard") { @@ -357,7 +357,7 @@ export async function createSite( subnet, type, pubKey: pubKey || null, - status: "accepted" + status: "approved" }) .returning(); } else if (type == "local") { @@ -373,7 +373,7 @@ export async function createSite( dockerSocketEnabled: false, online: true, subnet: "0.0.0.0/32", - status: "accepted" + status: "approved" }) .returning(); } else { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 1d7e99042..6f085d74d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -137,12 +137,12 @@ const listSitesSchema = z.object({ description: "Filter by online status" }), status: z - .enum(["pending", "accepted"]) + .enum(["pending", "approved"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["pending", "accepted"], + enum: ["pending", "approved"], description: "Filter by site status" }) }); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 244adf7b8..34d1341d7 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -20,7 +20,7 @@ const updateSiteBodySchema = z name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), - status: z.enum(["pending", "accepted"]).optional(), + status: z.enum(["pending", "approved"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/server/routers/siteProvisioning/types.ts b/server/routers/siteProvisioning/types.ts index d06c1fe26..785d9dfff 100644 --- a/server/routers/siteProvisioning/types.ts +++ b/server/routers/siteProvisioning/types.ts @@ -8,6 +8,7 @@ export type SiteProvisioningKeyListItem = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; export type ListSiteProvisioningKeysResponse = { @@ -26,6 +27,7 @@ export type CreateSiteProvisioningKeyResponse = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; export type UpdateSiteProvisioningKeyResponse = { @@ -38,4 +40,5 @@ export type UpdateSiteProvisioningKeyResponse = { maxBatchSize: number | null; numUsed: number; validUntil: string | null; + approveNewSites: boolean; }; diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx index 1cfbf5b05..021bb97b7 100644 --- a/src/app/[orgId]/settings/provisioning/keys/page.tsx +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -8,6 +8,10 @@ import SiteProvisioningKeysTable, { import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; import { getTranslations } from "next-intl/server"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; type ProvisioningKeysPageProps = { params: Promise<{ orgId: string }>; @@ -46,6 +50,29 @@ export default async function ProvisioningKeysPage( return ( <> + } + description={t("provisioningKeysBannerDescription")} + > + + + + + @@ -53,4 +80,4 @@ export default async function ProvisioningKeysPage( ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index 78178cb14..637f828b8 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -5,6 +5,10 @@ import { AxiosResponse } from "axios"; import { SiteRow } from "@app/components/SitesTable"; import PendingSitesTable from "@app/components/PendingSitesTable"; import { getTranslations } from "next-intl/server"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; type PendingSitesPageProps = { params: Promise<{ orgId: string }>; @@ -69,14 +73,38 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { })); return ( - + <> + } + description={t("pendingSitesBannerDescription")} + > + + + + + + ); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 5839ba2be..38083325b 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -18,7 +18,7 @@ export default async function SitesPage(props: SitesPageProps) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); - searchParams.set("status", "accepted"); + searchParams.set("status", "approved"); let sites: ListSitesResponse["sites"] = []; let pagination: ListSitesResponse["pagination"] = { diff --git a/src/components/CreateSiteProvisioningKeyCredenza.tsx b/src/components/CreateSiteProvisioningKeyCredenza.tsx index 3a1c7c372..f6b80a964 100644 --- a/src/components/CreateSiteProvisioningKeyCredenza.tsx +++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx @@ -79,7 +79,8 @@ export default function CreateSiteProvisioningKeyCredenza({ .max(1_000_000, { message: t("provisioningKeysMaxBatchSizeInvalid") }), - validUntil: z.string().optional() + validUntil: z.string().optional(), + approveNewSites: z.boolean() }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -103,7 +104,8 @@ export default function CreateSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true } }); @@ -114,7 +116,8 @@ export default function CreateSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true }); } }, [open, form]); @@ -123,18 +126,21 @@ export default function CreateSiteProvisioningKeyCredenza({ setLoading(true); try { const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/site-provisioning-key`, { - name: data.name, - maxBatchSize: data.unlimitedBatchSize - ? null - : data.maxBatchSize, - validUntil: - data.validUntil == null || data.validUntil.trim() === "" - ? undefined - : data.validUntil - }) + .put>( + `/org/${orgId}/site-provisioning-key`, + { + name: data.name, + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || + data.validUntil.trim() === "" + ? undefined + : data.validUntil, + approveNewSites: data.approveNewSites + } + ) .catch((e) => { toast({ variant: "destructive", @@ -152,9 +158,7 @@ export default function CreateSiteProvisioningKeyCredenza({ } } - const credential = - created && - created.siteProvisioningKey; + const credential = created && created.siteProvisioningKey; const unlimitedBatchSize = form.watch("unlimitedBatchSize"); @@ -213,15 +217,12 @@ export default function CreateSiteProvisioningKeyCredenza({ min={1} max={1_000_000} autoComplete="off" - disabled={ - unlimitedBatchSize - } + disabled={unlimitedBatchSize} name={field.name} ref={field.ref} onBlur={field.onBlur} onChange={(e) => { - const v = - e.target.value; + const v = e.target.value; field.onChange( v === "" ? 100 @@ -269,9 +270,7 @@ export default function CreateSiteProvisioningKeyCredenza({ const dateTimeValue: DateTimeValue = (() => { if (!field.value) return {}; - const d = new Date( - field.value - ); + const d = new Date(field.value); if (isNaN(d.getTime())) return {}; const hours = d @@ -313,11 +312,7 @@ export default function CreateSiteProvisioningKeyCredenza({ value.date ); if (value.time) { - const [ - h, - m, - s - ] = + const [h, m, s] = value.time.split( ":" ); @@ -352,6 +347,40 @@ export default function CreateSiteProvisioningKeyCredenza({ ); }} /> + ( + + + + field.onChange( + c === true + ) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> )} @@ -395,4 +424,4 @@ export default function CreateSiteProvisioningKeyCredenza({ ); -} +} \ No newline at end of file diff --git a/src/components/EditSiteProvisioningKeyCredenza.tsx b/src/components/EditSiteProvisioningKeyCredenza.tsx index 138190edc..e0e9cdde0 100644 --- a/src/components/EditSiteProvisioningKeyCredenza.tsx +++ b/src/components/EditSiteProvisioningKeyCredenza.tsx @@ -45,6 +45,7 @@ export type EditableSiteProvisioningKey = { name: string; maxBatchSize: number | null; validUntil: string | null; + approveNewSites: boolean; }; type EditSiteProvisioningKeyCredenzaProps = { @@ -76,7 +77,8 @@ export default function EditSiteProvisioningKeyCredenza({ .max(1_000_000, { message: t("provisioningKeysMaxBatchSizeInvalid") }), - validUntil: z.string().optional() + validUntil: z.string().optional(), + approveNewSites: z.boolean() }) .superRefine((data, ctx) => { const v = data.validUntil; @@ -100,7 +102,8 @@ export default function EditSiteProvisioningKeyCredenza({ name: "", unlimitedBatchSize: false, maxBatchSize: 100, - validUntil: "" + validUntil: "", + approveNewSites: true } }); @@ -112,7 +115,8 @@ export default function EditSiteProvisioningKeyCredenza({ name: provisioningKey.name, unlimitedBatchSize: provisioningKey.maxBatchSize == null, maxBatchSize: provisioningKey.maxBatchSize ?? 100, - validUntil: provisioningKey.validUntil ?? "" + validUntil: provisioningKey.validUntil ?? "", + approveNewSites: provisioningKey.approveNewSites }); }, [open, provisioningKey, form]); @@ -135,7 +139,8 @@ export default function EditSiteProvisioningKeyCredenza({ data.validUntil == null || data.validUntil.trim() === "" ? "" - : data.validUntil + : data.validUntil, + approveNewSites: data.approveNewSites } ) .catch((e) => { @@ -255,6 +260,38 @@ export default function EditSiteProvisioningKeyCredenza({ )} /> + ( + + + + field.onChange(c === true) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "accepted" }); + await api.post(`/site/${siteId}`, { status: "approved" }); toast({ title: t("success"), description: t("siteApproveSuccess"), @@ -437,4 +437,4 @@ export default function PendingSitesTable({ stickyRightColumn="actions" /> ); -} \ No newline at end of file +} From 11a6f1f47fd6c8fad3f33b3292349fdc13245c16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:34:28 +0000 Subject: [PATCH 021/221] Bump sigstore/cosign-installer from 4.1.0 to 4.1.1 Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.0 to 4.1.1. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/ba7bc0a3fef59531c69a25acd34668d6d3fe6f22...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd.yml | 2 +- .github/workflows/mirror.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index fff21995d..d4cde4ac4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -415,7 +415,7 @@ jobs: - name: Install cosign # cosign is used to sign and verify container images (key and keyless) - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Dual-sign and verify (GHCR & Docker Hub) # Sign each image by digest using keyless (OIDC) and key-based signing, diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index d6dfdb8fb..f60922d21 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -23,7 +23,7 @@ jobs: skopeo --version - name: Install cosign - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Input check run: | From c20dfdabfb5b1be7e409d9da21d2d8d88af9e22a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:35:59 +0000 Subject: [PATCH 022/221] Bump the dev-patch-updates group across 1 directory with 3 updates Bumps the dev-patch-updates group with 3 updates in the / directory: [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss), [esbuild](https://github.com/evanw/esbuild) and [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss). Updates `@tailwindcss/postcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss) Updates `esbuild` from 0.27.3 to 0.27.4 - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.3...v0.27.4) Updates `tailwindcss` from 4.2.1 to 4.2.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@tailwindcss/postcss" dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: esbuild dependency-version: 0.27.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] --- package-lock.json | 456 +++++++++++++++++++++++----------------------- package.json | 8 +- 2 files changed, 232 insertions(+), 232 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b63f1691..68bf7acf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,7 @@ "@dotenvx/dotenvx": "1.54.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "5.2.10", - "@tailwindcss/postcss": "4.2.1", + "@tailwindcss/postcss": "4.2.2", "@tanstack/react-query-devtools": "5.91.3", "@types/better-sqlite3": "7.6.13", "@types/cookie-parser": "1.4.10", @@ -137,14 +137,14 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.27.3", + "esbuild": "0.27.4", "esbuild-node-externals": "1.20.1", "eslint": "10.0.3", "eslint-config-next": "16.1.7", "postcss": "8.5.8", "prettier": "3.8.1", "react-email": "5.2.10", - "tailwindcss": "4.2.1", + "tailwindcss": "4.2.2", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", @@ -1577,9 +1577,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -1594,9 +1594,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -1611,9 +1611,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -1628,9 +1628,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -1645,9 +1645,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -1662,9 +1662,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -1679,9 +1679,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -1696,9 +1696,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -1713,9 +1713,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -1730,9 +1730,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -1747,9 +1747,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -1764,9 +1764,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -1781,9 +1781,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -1798,9 +1798,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -1815,9 +1815,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -1832,9 +1832,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -1849,9 +1849,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -1866,9 +1866,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -1883,9 +1883,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -1900,9 +1900,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -1917,9 +1917,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -1934,9 +1934,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -1951,9 +1951,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -1968,9 +1968,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -1985,9 +1985,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -2002,9 +2002,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -8049,49 +8049,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -8106,9 +8106,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -8123,9 +8123,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -8140,9 +8140,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -8157,9 +8157,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -8174,9 +8174,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], @@ -8191,9 +8191,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], @@ -8208,9 +8208,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], @@ -8225,9 +8225,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], @@ -8242,9 +8242,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -8336,9 +8336,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -8353,9 +8353,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -8370,17 +8370,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", - "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.2" } }, "node_modules/@tanstack/query-core": { @@ -12070,9 +12070,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { @@ -12283,9 +12283,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -12296,32 +12296,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/esbuild-node-externals": { @@ -14673,9 +14673,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -14689,23 +14689,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -14724,9 +14724,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -14745,9 +14745,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -14766,9 +14766,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -14787,9 +14787,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -14808,9 +14808,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -14829,9 +14829,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -14850,9 +14850,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -14871,9 +14871,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -14892,9 +14892,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -14913,9 +14913,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -18662,15 +18662,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 05ae3b49f..3d835f524 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "@dotenvx/dotenvx": "1.54.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "5.2.10", - "@tailwindcss/postcss": "4.2.1", + "@tailwindcss/postcss": "4.2.2", "@tanstack/react-query-devtools": "5.91.3", "@types/better-sqlite3": "7.6.13", "@types/cookie-parser": "1.4.10", @@ -160,21 +160,21 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.27.3", + "esbuild": "0.27.4", "esbuild-node-externals": "1.20.1", "eslint": "10.0.3", "eslint-config-next": "16.1.7", "postcss": "8.5.8", "prettier": "3.8.1", "react-email": "5.2.10", - "tailwindcss": "4.2.1", + "tailwindcss": "4.2.2", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.56.1" }, "overrides": { - "esbuild": "0.27.3", + "esbuild": "0.27.4", "dompurify": "3.3.2" } } From 1e9544af0773307ed1fceae9730648cf86290270 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 29 Mar 2026 20:29:31 -0700 Subject: [PATCH 023/221] Customize table a little more --- src/components/PendingSitesTable.tsx | 129 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index ae22c1bc1..2d1ac8769 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -2,6 +2,12 @@ import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; @@ -15,7 +21,8 @@ import { ArrowUp10Icon, ArrowUpRight, Check, - ChevronsUpDownIcon + ChevronsUpDownIcon, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -93,7 +100,7 @@ export default function PendingSitesTable({ async function approveSite(siteId: number) { setApprovingIds((prev) => new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "approved" }); + await api.post(`/site/${siteId}`, { status: "accepted" }); toast({ title: t("success"), description: t("siteApproveSuccess"), @@ -201,56 +208,56 @@ export default function PendingSitesTable({ } } }, - { - accessorKey: "mbIn", - friendlyName: t("dataIn"), - header: () => { - const dataInOrder = getSortDirection( - "megabytesIn", - searchParams - ); - const Icon = - dataInOrder === "asc" - ? ArrowDown01Icon - : dataInOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; - return ( - - ); - } - }, - { - accessorKey: "mbOut", - friendlyName: t("dataOut"), - header: () => { - const dataOutOrder = getSortDirection( - "megabytesOut", - searchParams - ); - const Icon = - dataOutOrder === "asc" - ? ArrowDown01Icon - : dataOutOrder === "desc" - ? ArrowUp10Icon - : ChevronsUpDownIcon; - return ( - - ); - } - }, + // { + // accessorKey: "mbIn", + // friendlyName: t("dataIn"), + // header: () => { + // const dataInOrder = getSortDirection( + // "megabytesIn", + // searchParams + // ); + // const Icon = + // dataInOrder === "asc" + // ? ArrowDown01Icon + // : dataInOrder === "desc" + // ? ArrowUp10Icon + // : ChevronsUpDownIcon; + // return ( + // + // ); + // } + // }, + // { + // accessorKey: "mbOut", + // friendlyName: t("dataOut"), + // header: () => { + // const dataOutOrder = getSortDirection( + // "megabytesOut", + // searchParams + // ); + // const Icon = + // dataOutOrder === "asc" + // ? ArrowDown01Icon + // : dataOutOrder === "desc" + // ? ArrowUp10Icon + // : ChevronsUpDownIcon; + // return ( + // + // ); + // } + // }, { accessorKey: "type", friendlyName: t("type"), @@ -375,6 +382,24 @@ export default function PendingSitesTable({ const isApproving = approvingIds.has(siteRow.id); return (
+ + + + + + + + {t("viewSettings")} + + + + +
+ ))} + + + ); +} + +// ── Destination card ─────────────────────────────────────────────────────────── + +interface DestinationCardProps { + destination: Destination; + onToggle: (id: number, enabled: boolean) => void; + onEdit: (destination: Destination) => void; + isToggling: boolean; + disabled?: boolean; +} + +function DestinationCard({ + destination, + onToggle, + onEdit, + isToggling, + disabled = false +}: DestinationCardProps) { + const cfg = parseConfig(destination.config); + + return ( +
+ {/* Top row: icon + name/type + toggle */} +
+
+
+ +
+
+

+ {cfg.name || "Unnamed destination"} +

+

+ HTTP +

+
+
+ + onToggle(destination.destinationId, v) + } + disabled={isToggling || disabled} + className="shrink-0 mt-0.5" + /> +
+ + {/* URL preview */} +

+ {cfg.url || ( + No URL configured + )} +

+ + {/* Footer: edit button */} +
+ +
+
+ ); +} + +// ── Add destination card ─────────────────────────────────────────────────────── + +function AddDestinationCard({ + onClick, + disabled = false +}: { + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ── Destination modal ────────────────────────────────────────────────────────── + +interface DestinationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Destination | null; + orgId: string; + onSaved: () => void; + onDeleted: () => void; +} + +function DestinationModal({ + open, + onOpenChange, + editing, + orgId, + onSaved, + onDeleted +}: DestinationModalProps) { + const api = createApiClient(useEnvContext()); + + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [cfg, setCfg] = useState(defaultConfig()); + + useEffect(() => { + if (open) { + setCfg(editing ? parseConfig(editing.config) : defaultConfig()); + setConfirmDelete(false); + } + }, [open, editing]); + + const update = (patch: Partial) => + setCfg((prev) => ({ ...prev, ...patch })); + + const isValid = + cfg.name.trim() !== "" && cfg.url.trim() !== ""; + + async function handleSave() { + if (!isValid) return; + setSaving(true); + try { + const payload = { + type: "http", + config: JSON.stringify(cfg) + }; + if (editing) { + await api.post( + `/org/${orgId}/event-streaming-destination/${editing.destinationId}`, + payload + ); + toast({ title: "Destination updated successfully" }); + } else { + await api.put( + `/org/${orgId}/event-streaming-destination`, + payload + ); + toast({ title: "Destination created successfully" }); + } + onSaved(); + onOpenChange(false); + } catch (e) { + toast({ + variant: "destructive", + title: editing + ? "Failed to update destination" + : "Failed to create destination", + description: formatAxiosError( + e, + "An unexpected error occurred." + ) + }); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!editing) return; + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setDeleting(true); + try { + await api.delete( + `/org/${orgId}/event-streaming-destination/${editing.destinationId}` + ); + toast({ title: "Destination deleted successfully" }); + onDeleted(); + onOpenChange(false); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to delete destination", + description: formatAxiosError( + e, + "An unexpected error occurred." + ) + }); + } finally { + setDeleting(false); + } + } + + return ( + + + + + {editing ? "Edit Destination" : "Add Destination"} + + + {editing + ? "Update the configuration for this HTTP event streaming destination." + : "Configure a new HTTP endpoint to receive your organization's events."} + + + + + + + Settings + Headers + Body Template + + + {/* ── Settings ─────────────────────────────────── */} + +
+ + + update({ name: e.target.value }) + } + /> +
+ +
+ + + update({ url: e.target.value }) + } + /> +
+ +
+ + + update({ authType: v as AuthType }) + } + className="gap-2" + > + {/* None */} +
+ +
+ +

+ Sends requests without an{" "} + + Authorization + {" "} + header. +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ Adds an{" "} + + Authorization: Bearer <token> + {" "} + header to each request. +

+
+ {cfg.authType === "bearer" && ( + + update({ + bearerToken: + e.target.value + }) + } + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ Adds an{" "} + + Authorization: Basic <credentials> + {" "} + header. Provide credentials as{" "} + + username:password + + . +

+
+ {cfg.authType === "basic" && ( + + update({ + basicCredentials: + e.target.value + }) + } + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ Specify a custom HTTP header name and value for + authentication (e.g.{" "} + + X-API-Key + + ). +

+
+ {cfg.authType === "custom" && ( +
+ + update({ + customHeaderName: + e.target.value + }) + } + className="flex-1" + /> + + update({ + customHeaderValue: + e.target.value + }) + } + className="flex-1" + /> +
+ )} +
+
+
+
+
+ + {/* ── Headers ───────────────────────────────────── */} + +
+

+ Custom HTTP Headers +

+

+ Add custom HTTP headers to every outgoing request. + Useful for passing static tokens, setting a custom{" "} + + Content-Type + + , or other API requirements. By default, the{" "} + + Content-Type + {" "} + is{" "} + + application/json + + . +

+ update({ headers })} + /> +
+
+ + {/* ── Body Template ─────────────────────────────── */} + +
+

+ Custom Body Template +

+

+ Control the structure of the JSON payload sent to your + endpoint. If disabled, a default JSON object is sent for + each event. +

+
+ +
+ + update({ useBodyTemplate: v }) + } + /> + +
+ + {cfg.useBodyTemplate && ( +
+ +